Compare commits

..
86 changed files with 6577 additions and 4412 deletions

No files matched your search

+4 -4
View File
@@ -5585,7 +5585,7 @@ claim. Receipts and limitations in agents/lena/work/queue-296/BUILD.md and run-r
2026-10-24T23:37:46Z | Sage | Row 329 (#1827, P1 pinned model proxy, Magica) LANDED 61ebf2c34, pushed 23:31:27Z 10-24. Before: refactor at 669cdf42c. After: Magica's 4 commits (94cde633e, c65fb34ee, d1f6f2b19, 61ebf2c34) cherry-picked onto 669cdf42c, diff vs the gated tree limited to drift paths, range-diff in scratch. Gate: one unchanged full landing gate in /mnt/storage/scratch/sage-r329l/wt (gate.sh 8b4978887 c8a9bcf94), started 23:08:12Z at load 14.35, GATE GREEN 23:29:14Z at load 8.67, no rerun, no test or timeout edit (mos-1634). Host note: two Codex workers cleaned T3 worktrees from about 23:14Z (mos-1639, Vikunja 632). Approvals: Dewey r2 c31643 rev 4047, Boyd r2 c31642 rev 4048. #1827 comment 31650, closed. Vikunja 237 comment 1109 (stays open for P2-P7); C4 P10 red filed as Vikunja 631. Queue: note rev 4063, done rev 4064, committed b24d917ec. DEFERRED: N2 and R2-N1 bound to row 331, N1 bound to P7. Overlap: row 284 (packages/bus/src/broker.mjs) and row 157 (scripts/mosaic-config.mjs) rebase onto 61ebf2c34 before their gates.
2026-10-25T00:15:48Z | Sage | Row 284 (#1789, C2-05c library.get scope, Della) LANDED ce0b6ed41, pushed 00:09:52Z 10-25. Before: refactor at 9e2689dff (row 329 landing docs). After: fast-forward to Della's 7 rebased commits 78b932feb..ce0b6ed41; 8 paths, manifest-r4 de505cfd matches the landed tree. Gate: one unchanged full landing gate in /mnt/storage/scratch/sage-r284l/wt (the 329 gate.sh with D and the compose project renamed), started 23:51:19Z at load 1.62, GATE GREEN 00:09:04Z at load 5.20, no rerun, no test or timeout edit. Counts: bus 1020/0, business 283/0, cli 999 pass 1 allowed skip (FED R7), harness 177/0, library 156/0, seat 218/0, webui 458/0, every scripts/test-*.sh rc 0; queue 34 and task 87/93 skips are the standing ones. Della's whole-file runs on ce0b6ed41: library-lookups 12/0, library-tools 5/0, query 34/0. Approval: Webby round 2 delta c31654 rev 4070 (round 1 rev 4062). Overlap sweep: rows 13, 157, 309, gyro/row310 and fethry/row332-p1 clean. #1789 comment 31655, closed. No Vikunja task for the row. Queue: note rev 4071, done rev 4072, committed 7c8dc2033. DEFERRED: Webby N1 (whole-tree listing per get) bound to the next library read-path row; N2 needs nothing; N3 is this gate.
2026-10-25T00:18:35Z | Sage | Follow-up tasks for mos-1647 item 3 (Q109): Vikunja 633 (P7 cutover, CLIProxyAPI 8.0.42 startup Grok-npm and antigravity version refreshes, from row 332 isolated runs) and 634 (row 284 Webby N1, library get lists the whole tree). DEFERRED gains the 633 entry and the 634 number for row 284 N1. Filing script and receipt in agents/sage/work/vikunja-setup/.
2026-10-24T22:15:07Z | Herb | row 214 #1715 C2-11a focused evidence | Before: wikilinks are ordinary editor text; raw/sig retention keeps no-edit bytes, but the model has no inert node. After: inert inline wikilink atom preserves spelling through neighbouring edits; invalid attrs share HTML/entity escaping. On herb/row214-r2 base487500a16, grant sage-4286 lane B: baseline10/10 roundTrips equal (outcome A); baseline structural0/1 expected red; four whole editor files40/0, both mutants killed0/1 and restored1/0, whole webui69 files455/0 in174.189s with MOSAIC_REQUIRE_EDITOR_BUILD=1, nice19, no skips/cancellations. A mistaken preliminary7-file subset28/0 is logged and excluded from gate evidence. CSS byte-identical, final process sweep0, scratch TMPDIR removed. Filbert candidate review and every scripts/test-*.sh gate remain pending; no click, target resolution or library save change.
2026-10-25T04:36:47Z | Sage | Row 214 (#1715, C2-11a editor wikilink node, Herb) LANDED 3a8e4abad, pushed 04:27Z 10-25. Before: refactor at 78b69e1e6. After: Herb's six commits cherry-picked onto 78b69e1e6 and fast-forwarded. Gate GREEN 04:10:34Z: 18 package suites rc 0 (webui 471/471, cli 999 plus the allowed FED R7 skip), every scripts/test-*.sh rc 0, queue package via test-queue.sh 156. Design skips: queue 34 (not the canonical root), task 87/93 (row 244). Filbert APPROVE c31679 rev 4093. Range-diff equal except commit 5's BUILD-LOG context; 40/41 paths same blob, BUILD-LOG additions only. #1715 closed c31680. Queue note 4094, done 4095, committed ce8bb4d8b.
2026-10-25T04:36:47Z | Sage | Row 193 (#1691, threads user roles design, Morgana) LANDED 35f34ad27, doc-only: five design commits cherry-picked onto ce8bb4d8b, adding only agents/morgana/work/queue-193/DESIGN.md (verify-commit ok, 1 path). Duckworth APPROVE c31676 rev 4088 (posted and recorded by Sage; Duckworth has no Gitea credential). No test reads queue-193. Build rows from the design: 204/#1702, 205/#1703 (landed), 275/#1780, 206/#1704, 207/#1705, 208/#1706.
2026-10-25T04:58:30Z | Sage | Row 255 (#1760, Vikunja 458, bundled Pocket ID design for Q203, Launchpad) LANDED de3e23dde, doc-only: Launchpad's three design commits e29d8ec90, 9f60021a0 and c8f2bc723 cherry-picked onto 775bbe236 (range-diff equal), adding only agents/launchpad/work/queue-255/DESIGN.md. No test reads queue-255. Webby APPROVE round 1 c31689 rev 4103 (posted by Sage; Webby has no Gitea token). Mos rulings in mos-1666: M1 approved with conditions (rollback removes components before the pointer goes back and the STATE check confirms the key is absent; the loader stays strict inside the key; four test-config.sh cases), R1 chosen, M2 dropped. Sage gate check against Q203, O6, REQ-WEB-2 and REQ-INST-1: consistent. One PRD text gap goes to Mos: REQ-INST-2 says the stack writes the providers entry, and R1 prints it for the user to paste. Webby N1 and N2 bind P4; N3-N8 carry into the P1-P6 build rows, all post-v1.
2026-10-24 — Magica row 331 / #1829 BEFORE: P1 has pinned fetch/init and a redacted management client; agent model-key lifecycle, reconciliation and launch preflight are absent. P2 design and acceptance plan in agents/magica/work/queue-331/BUILD.md. Root worktree npm ci passed; no tests run pending Sage CLEAR. No live proxy or real credential access.
2026-10-25 — Magica row331 / #1829 GREEN attempt on a86a881e1:54 whole files pass; file55 model-keys.test.mjs rc1 (24pass/6fail). All6 new real-bus fixtures lack dataRoot before Store lstat; no old test failed in attempted prefix. Aggregate592tests/586pass/6fail/0skip. Stopped without edits/reruns; remaining207files/test-config/4mutants not run. Slot returned to Sage05:10:48Z. Per-run rc/count/nice/hash receipts: agents/magica/work/queue-331/green-receipts.json. No in-review/completion claim.
-193
View File
@@ -1,193 +0,0 @@
# Row214 C2-11a work record
Assignment sage-2794 at2026-10-22T16:33:20Z, Q220/mos-1301, issue#1715.
New isolated branch herb/row214 on aca16404c. The earlier197/215 worktrees
and their dirty/untracked historical packets are untouched. node_modules is
the required plain symlink to /mnt/storage/src/mosaic-stack/node_modules.
Read C2-11a in both pinned briefs. The section is unchanged at51cc0445c;
Q222 changes C2-06's reply-limit settings, not wikilinks. The editor model,
entry, view, existing model/browser/provenance tests, build/toolchain and
vendored markdown parser/serializer were read. No third_party source changes.
## Static finding and authored work
model.mjs stores a canonical sig and the raw original block, and serialise
returns raw when the sig matches. The vendored text writer escapes brackets
(to_markdown.ts:433-437). It is therefore possible for no-edit round-trips
to pass while dirty-block output escapes a wikilink. This is a source-based
hypothesis, not a measured result. Both outcomes are in DESIGN.md.
Authored, unrun: the pinned-bundle measurement; ten fixture round-trips;
three browser regressions; inert wikilink schema/tokenizer/serializer. The
schema includes headings, and front matter remains raw. The serializer
validates its spelling before bypassing escaping. HTML/entity-like targets
stay text under the existing escaping rules. No clicking or resolution.
Local static git diff --check is clean. No Node, npm, compiler, browser,
measurement, test, Docker, HTTPS Git or push has run. The queue claim waits
for a gap because scripts/mosaic invokes Node. No candidate or pass claim.
Only design/path documents may be committed before the granted red/green
checks; product, tests and measurement remain uncommitted until validated.
## Next granted steps
1. Claim from canonical during a CLEAR/gap.
2. Run measure.mjs against aca16404c's pinned Git blobs; save JSON unchanged.
3. Run the structural/edit regression alone on the baseline bundle (red).
4. Install the already-pinned esbuild0.28.2 in this worktree's packages/webui
with npm ci --ignore-scripts --no-audit --no-fund; no root install or lock edit.
5. Build with the existing editor/build.mjs, then the whole new test file and
existing editor, editor-browser and editor-provenance files, each singly.
6. Kill the serializer-removal mutant, restore, rebuild, focused green.
7. Commit only after the applicable checks, push only when Sage clears HTTPS
Git, send Filbert and Mos the candidate SHA/hash manifest directly, cc Sage.
8. After253 lands, new branch on its tip preserving all landed changes.
9. Filbert and Sage's whole webui and scripts gate finish the row.
All runs wait for explicit Sage CLEAR. Gander's row253 run5 owns the slot.
The whole suite gate and any unexpected failure use mos-0823's stop rule.
Proposed SESSIONS text for Sage's post-253 doc sweep (do not append here):
2026-10-22 | Herb (gpt-6.1-sol, f6cf4287) | row214 C2-11a editor wikilinks authoring | On herb/row214 at aca16404c, authored baseline round-trip measurement, designs for equal/unequal results, inert inline node and regression tests under sage-2794/Q220. Claim, runs and pushes held while253 owns the slot; measurement and test results unavailable.
## Filbert static design/path review accepted, R1 and R2 required
Review at2026-10-22T16:43:35Z read dee9f3d8d's packet only, no product or
runs. R1 is now in the plan: set MOSAIC_REQUIRE_EDITOR_BUILD=1 on focused
provenance and whole webui runs, grep ^# skip plus TAP/spec skip directives,
and name every skip. Missing/wrong pinned esbuild must fail, not skip.
R2: baseline no-edit roundTrips are expected to pass. They test retention
of raw bytes and are not red evidence for the node. Only the baseline
same-block structural/edit regression is expected red.
Two added fixtures cover a reference-definition collision and an atom
inside ordinary link text, taking the planned file to10 no-edit tests and
3 browser tests. The explicit & exclusion preserves the existing escaped
HTML/entity writer for dirty blocks; Q&A stays text and is a known limit.
Measurement VM setup matches loadEditor. Browser TMPDIR must use the short
non-/tmp scratch root. Sweep Node/Chromium after mutant/timeout and at clear.
All remain authored and unrun while253 owns the slot.
Filbert follow-up at2026-10-22T16:45:34Z: the authored N3 browser fixture
now explicitly asserts the separate reference-definition block ends in its
original exact bytes after the same-block edit. This test change is unrun
and uncommitted with the other product/test authoring, pending CLEAR.
## R2 restart on current refactor, sage-4164
New worktree: /home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2.
Branch herb/row214-r2, base487500a16f4ab5902c318509fdec6acc56a543ca.
Carried dee9f3d8d and e2710e92a as67ac3c25a andde42a59d4. Copied only
Herb's local model, new editor test/helper, measure.mjs and pending BUILD
note. The old worktree remains untouched. No stash or force push.
The current editor model/build/toolchain/BUILD.json/shipped bundle and
loader are identical to the old base. Entry/view and package dependencies
are unchanged. The current test fixture adds configDirectory:root; its
landed change is retained. The C2-11a brief section is unchanged. The
visual-mode condition now lives at public/bus.js:1054. No product behavior
beyond the accepted inert inline node is added by the rebase.
Row266 overlap: none. Our paths do not touch chat*.mjs, chat.js or chat.css,
and the intersection with origin/fethry/row266's actual diff is empty.
HASH-PINS.json records that ref's SHA and merge base.
Hash audit: grepped packages/*/tests for each actual/planned changed file
and filename, and scanned495 test/fixture files for literal prior SHA256
or Git-blob pins. No such pins found. Existing editor-provenance checks
read BUILD.json dynamically; regenerating it and the bundle is required.
The loader and browser/route tests use editor.js but pin no source hash.
No tests or pin asserts were weakened. The complete references and planned
generated paths are in HASH-PINS.json.
Sage-4164 explicitly permits the unrun node/tests to be committed now. This
supersedes the earlier packet's instruction to keep them uncommitted until
green. Static authoring is not a tested candidate. SOURCE.json records its
source hashes and the unbuilt generated-output status. The measurement now
pins this current execution base, not the historical accepted-doc base.
No Node, npm, measurement, compiler, test, browser, Docker or mutant has
run. Claim already succeeded at rev3234 on10-22; no new queue move is
needed. Push authorization is only herb/row214-r2. Row257's landing gate
and then266 own the slot; all execution remains held for Sage's CLEAR.
Planned focused grant: baseline measurement; the same-block structural
red; pinned npm ci/build; whole editor-wikilink.test.mjs (10 no-edit tests
with/without a final newline and3 browser tests), editor.test.mjs,
editor-browser.test.mjs and editor-provenance.test.mjs singly; one
serializer-removal mutant and restored focused green. Set
MOSAIC_REQUIRE_EDITOR_BUILD=1 for provenance and the later whole webui
gate. Capture all skip names, raw logs, short scratch TMPDIR and final
Node/Chromium process sweep. Two failures stop a gate; no rerun after an
out-of-row failure without reporting to Sage.
## Filbert R2 static B1 fixed before execution
Review at2026-10-24T17:14:55Z confirmed a defect in the authored invalid
atom path: state.text alone lacks the text-node override's HTML/entity
escaping. Factored that existing policy into escapedText and reused it for
invalid atoms. Extended the test payload/assertions to cover both <img and
&copy;. Valid atom behavior is unchanged. Excluded backticks from atom
recognition and added a text/code precedence fixture for N1.
Requested focused-grant addition: a second mutant that swaps only the
invalid-atom escapedText call for vendored state.text, dropping the <&
escape for this path. The existing malformed-attrs browser test must kill
it. The original serializer-removal mutant is still required. No runs,
checks, installation or rebuild have occurred; all remain held for CLEAR.
## Granted execution and candidate, sage-4286, 2026-10-24
Granted lane B until22:40Z. Ran on2ec3f5b5d's source in this worktree.
Every child inherited TMPDIR=/mnt/storage/scratch/tmp/g49X, obtained by
mktemp -d /mnt/storage/scratch/tmp/gXXX, never /tmp. Each command used
`nice -n "$((19 - $(nice)))" timeout -k 10 ...` with pipefail and tee;
raw logs record actual absolute nice19 and MOSAIC_REQUIRE_EDITOR_BUILD=1.
Node test concurrency was1 for each focused whole file and2 for webui.
Measurement22:07:22Z: all10 baseline forms round-trip byte for byte. The
JSON is copied unchanged to measurement.json. Adopted outcome A: no
measured file falls to Source just because of its wikilink. The atom is
proved by structure and same-block edits, not no-edit raw retention.
Baseline structural case:0/1 expected red, no atom in old shipped bundle.
Pinned npm ci --ignore-scripts --no-audit --no-fund succeeded locally in
packages/webui, then editor/build.mjs ran with esbuild0.28.2. The existing
upstream sideEffects warning for mark.ts appeared; no vendored file changed.
No package/lockfile change. Rebuild provenance ran and passed, not skipped.
| Whole file | Pass/fail | Wall seconds |
| --- | ---: | ---: |
| editor-wikilink.test.mjs | 13/0 | 2.348 |
| editor.test.mjs | 5/0 | 2.053 |
| editor-browser.test.mjs | 16/0 | 14.984 |
| editor-provenance.test.mjs | 6/0 | 0.343 |
Two mutants, each independently restored and rebuilt: serializer removal
failed the structural case0/1, restore1/0; invalid-path escapedText swapped
for vendored state.text failed malformed attrs0/1, restore1/0. The model
and generated outputs match their original good hashes after both.
Post-mutant cwd/temp-root sweep0. Both failures are expected evidence.
Full webui: exactly69 files,455/0,0 skipped/cancelled,174.189s, ended
22:12:48.880975Z. The log contains the required-build env and nice19.
Skip scan for ^# skip, TAP # SKIP and spec skipped-test lines found none,
so skip names are []. A preliminary command mistakenly selected7 files;
its28/0 in17.787s is archived as webui-whole.log and explicitly NOT used
as full gate evidence. The corrected69-file run is webui-69.log.
Final cwd/TMPDIR/argv sweep22:13:17.560692Z:0 processes. No owned Node or
Chromium remains. Removed the private TMPDIR only after that zero sweep.
Clear was sent to Sage as herb-0102 at22:13:46Z; no further execution.
Bundle SHA256 bd167899a551c70dc07e500fcdd1853d7fb710b23c217ba5b42daf5785c38a72.
CSS SHA2568654ac57d1e7a561110138ecd7f701c34a074017ff8c4cd2768d8083908ee61f,
byte-identical to2ec3f5b5d. BUILD.json outputs match those hashes. Source
commit a530a60798bf48eb18bb9e225d099faf5c2975cf adds generated outputs and
an append-only phase log; packet changes follow separately. No code or
assertion changed after these results. The webui gate requirement is met
on this tree; Filbert review and every scripts/test-*.sh remain pending.
Sage re-pinned the brief to case2-rows.md blob85e6fe2e under sage-4286;
its C2-11a section is unchanged. No case1-accept file was in any run.
-168
View File
@@ -1,168 +0,0 @@
# C2-11a: an inert editor wikilink node
Base: aca16404c. Owner: Herb. Reviewer: Filbert. Issue: #1715.
Authority: sage-2794, Q220/mos-1301. The assignment permits authoring the
node and tests before the measurement slot. Runs, claim and pushes wait
for Sage's CLEAR. After row253 lands, a new branch will carry the change
onto its landing with every landed hunk retained.
## Measurement before choosing the explanation
Run agents/herb/work/queue-214/measure.mjs only on a granted slot. It loads
aca16404c's editor.js and markdown-preview.js directly from local Git
objects. It verifies the editor bundle hash against that base's BUILD.json.
It cannot accidentally measure a candidate rebuild. Its JSON records the
full baseline SHA, bundle hash, timestamp, input, output and equality for
each fixture. The first five are the brief's plain, labelled, heading,
list and front-matter cases; extra cases cover a heading block, combined
heading/label, Unicode/spacing, adjacency and an inert embed. No result is
available yet. Static reasoning is not a measurement.
Outcome A, every baseline round-trip is equal: record that wikilinks do
not trigger Source fallback for the measured inputs. Do not claim that
bug exists. The source's raw/sig cache explains why an unchanged block can
keep its bytes even when its canonical serializer escapes brackets. Add
the node for structured wikilinks and dirty-block preservation, as the
brief requests. A baseline structural/edit regression must fail even if
all unchanged-file checks pass.
Outcome B, one or more differ: record the exact differing bytes and affected
contexts. Keep the existing Source fallback fail-closed. The node restores
the required spellings where the block is structured. Retain the same
no-edit and dirty-block regressions. If the mismatch comes from a separate
block, line-ending or front-matter defect, report it before widening scope.
## Representation and conversion
The schema gains one inline atom, wikilink, with a single source attribute.
Its source is the complete spelling, not reconstructed target/label parts.
It has no href, navigation, fetch or click behavior. A span with data-source
and a text child displays the spelling. Heading content includes the new
atom; paragraphs, quotes and lists already accept inline content.
One markdown-it inline rule runs before ordinary links, after escapes and
backticks. A sticky match reads a complete single-line [[...]] or ![[...]]
without copying every suffix or crossing state.posMax. The node writer
writes its validated spelling without bracket escaping, so editing adjacent
text cannot rewrite a wikilink. Front matter remains a raw block. No new
file-read, save, routing, documentPath or target-resolution behavior is added.
The accepted atom grammar excludes brackets within the body, line breaks,
angle brackets and ampersands. This is an editor representation boundary,
not a library resolver or a refusal to open such a file. Malformed,
HTML/entity-like and code syntax stays under the existing text/raw rules.
This preserves the existing escaping of HTML/entities when a block changes.
DOM paste accepts only that same grammar. Invalid attributes constructed
programmatically are flattened to one line and escaped by the text writer.
No input string is parsed as HTML and embeds never load an image.
## Acceptance and review
The measurement JSON goes in BUILD.md and the final manifest packet.
Ten fixture round-trips run with and without a final newline. A browser
test inspects real ProseMirror atom types and attributes, edits the same
block and reparses it, for each fixture. Two more browser tests cover
code/escape/malformed/hostile syntax and DOM/programmatic-attribute safety.
Tests use the existing fixture and Chromium harness and the shipped bundle.
They do not export new production APIs just for testing.
Before rebuilding, run only the structural regression against the baseline
bundle and observe it fail. Rebuild the candidate with the exact pinned
esbuild0.28.2 binary, run the whole new test file, existing editor tests,
editor browser tests and provenance test. Remove only the wikilink serializer
handler for one mutant; the dirty-block assertion must fail. Restore, rebuild
and rerun the focused regression. All execution needs Sage's CLEAR, including
installation, checks, builds, browser tests and the measurement.
The checked-in bundle and editor/BUILD.json must be regenerated by the
existing build script. No hand edits of generated JavaScript or provenance.
The current shared esbuild is0.28.1 and the local webui install is absent.
A granted npm ci --ignore-scripts --no-audit --no-fund in packages/webui is
needed before building; the root node_modules remains its plain symlink.
No dependency or lockfile change is planned.
Final gate: Filbert approves the manifest, then the whole webui suite and
every scripts/test-*.sh on Sage's slot. Two failures stop a gate. A failure
outside this row is reported to Sage before any rerun. Source, generated
output, mode/hash manifest and raw granted logs identify the candidate.
## Static review follow-through (Filbert, 2026-10-22T16:43:35Z)
R1: the focused editor-provenance run and Sage's whole webui gate must set
MOSAIC_REQUIRE_EDITOR_BUILD=1. A missing, wrong-version or unpinned binary
then fails. Check every log for ^# skip, as well as TAP # SKIP directives
and the spec reporter's skipped-test lines; record each skipped name and
reason. A skipped rebuild is not proof of bundle reproducibility. The CSS
output must stay byte-identical and match BUILD.json.
R2: no-edit roundTrip checks are expected to pass on the baseline because
parse sets sig=canonical and an unchanged serialise returns lead+raw. Their
green result is compatibility evidence, never evidence that the atom was
needed or installed. The same-block edit/structural regression is the red
that proves the new node. The required measurement still runs and is kept.
N1: bus.js checks visual mode at949 on this base and also requires
MosaicMarkdown. The measurement uses the existing helper's bare-window VM
shape, markdown-preview.js first, then editor.js, both from pinned Git blobs.
N2: the exclusion of & and angle brackets is deliberate: the existing
text serializer escapes entities/HTML when it rewrites a block for another
renderer. An atom's verbatim writer must not bypass that rule, including
for pasted/programmatic values. This keeps those targets as ordinary text;
[[Q&A]] will still be escaped if its own block changes. It is a known
representation limit beyond the five brief cases, not a resolver change.
It is included among the text-only cases. Unedited bytes remain intact.
N3: the reference-definition fixture [[a]] with [a]: u deliberately becomes
one inert wikilink rather than a bracket around a reference link. Literal
spelling and the separate definition block remain intact.
N4: [x [[a]]](u) is an explicit fixture. The atom inherits the enclosing
ordinary link mark, whose existing target gate still applies. The atom
itself supplies no href or new click behavior; the fixture checks its
structure and spelling before/after a same-block edit.
N6: every granted browser/test process gets TMPDIR from
mktemp -d /mnt/storage/scratch/tmp/gXXX. Never /tmp. Sweep owned Chromium
and Node processes by cwd and the temp-root path after the mutant, after
any timeout, and before clearing the slot. Retain/report uncertain owners.
## R2 current-base check, sage-4164 (2026-10-24)
The execution base is now487500a16, on herb/row214-r2, not the historical
aca16404c above. The two accepted document commits were cherry-picked
cleanly. The model, build script, toolchain, BUILD.json, editor.js, view,
entry and editor test loader are byte-identical between those bases. The
current fixture's new configDirectory isolation is retained; it is not
copied from the old worktree. The visual-mode check is now bus.js:1054.
The current C2-11a brief section is unchanged. Row266's chat modules,
public/chat.js and public/chat.css are outside this row, with no overlap.
measure.mjs now pins487500a16's Git objects and BUILD.json. The baseline
bundle still lacks the atom, and the first executed structural regression
will use it before any rebuild. The required measurement, red/green, pinned
rebuild, mutant kill and full gate remain unrun and required. Runtime
validation is not inferred from the unchanged source or this static check.
Sage-4164 explicitly authorizes committing the preserved local node/tests
on the new branch before execution. The authored source commit is static
work, not a green candidate. No Node, npm, rebuild or browser starts until
Sage sends CLEAR. HASH-PINS.json records the test-tree audit and row266
intersection. The gate still requires MOSAIC_REQUIRE_EDITOR_BUILD=1, a
named skip list, whole webui and every scripts/test-*.sh.
## R2 static follow-up, Filbert B1/N1 (2026-10-24T17:14:55Z)
B1: state.text(..., true) escapes Markdown punctuation, not HTML/entities.
The invalid-atom writer now calls the same escapedText helper as ordinary
text nodes, including the <& step. Valid atoms still preserve their source
verbatim. The malformed-attribute regression now asserts both an escaped
<img and an escaped &copy;, plus flattened line breaks. A second mutant
will replace only the invalid atom's escapedText call with state.text,
leaving normal text escaping intact; this browser regression must kill it.
This additional mutant needs Sage's grant alongside the serializer-removal
mutant. No runtime result is claimed for either.
N1: backticks are excluded from the atom body, preserving code-span
precedence. The text-only fixture includes [[a`]]` and must produce no
wikilink atom. All other malformed/HTML/entity boundaries remain as before.
-572
View File
@@ -1,572 +0,0 @@
{
"grant": "sage-4286",
"candidateBeforeBuild": "2ec3f5b5ddab0cb5f2716f8164c25861b741213e",
"nice": 19,
"tmpRoot": "/mnt/storage/scratch/tmp/g49X",
"requireEditorBuild": "1",
"measurement": {
"base": "487500a16f4ab5902c318509fdec6acc56a543ca",
"bundleSha256": "9c881b13740fae2408717ad75fa1d85bfcfe3fa8b3af0f1b3f2e706c90e80fee",
"measuredAt": "2026-10-24T22:07:22.210Z",
"allEqual": true,
"results": [
{
"name": "plain",
"input": "[[a]]\n",
"output": "[[a]]\n",
"equal": true
},
{
"name": "labelled",
"input": "[[a|b]]\n",
"output": "[[a|b]]\n",
"equal": true
},
{
"name": "heading target",
"input": "[[a#h]]\n",
"output": "[[a#h]]\n",
"equal": true
},
{
"name": "list item",
"input": "- [[a]]\n",
"output": "- [[a]]\n",
"equal": true
},
{
"name": "next to front matter",
"input": "---\nrelated: \"[[metadata]]\"\n---\n\n[[a]]\n",
"output": "---\nrelated: \"[[metadata]]\"\n---\n\n[[a]]\n",
"equal": true
},
{
"name": "heading block and combined label",
"input": "# See [[a#h|Title]]\n",
"output": "# See [[a#h|Title]]\n",
"equal": true
},
{
"name": "spaces, Unicode and adjacent links",
"input": "See [[ H\u00e9llo there # A heading | a label ]][[next]]\n",
"output": "See [[ H\u00e9llo there # A heading | a label ]][[next]]\n",
"equal": true
},
{
"name": "reference-definition collision",
"input": "[[a]]\n\n[a]: u\n",
"output": "[[a]]\n\n[a]: u\n",
"equal": true
},
{
"name": "inside an ordinary link",
"input": "[x [[a]]](u)\n",
"output": "[x [[a]]](u)\n",
"equal": true
},
{
"name": "inert embed",
"input": "![[cover.png]]\n",
"output": "![[cover.png]]\n",
"equal": true
}
]
},
"results": [
{
"name": "measurement",
"argv": [
"node",
"agents/herb/work/queue-214/measure.mjs"
],
"cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2",
"rc": 0,
"wallSeconds": 0.093,
"counts": {},
"nice": 19,
"tmpRoot": "/mnt/storage/scratch/tmp/g49X",
"requireEditorBuild": "1",
"timeoutSeconds": 60,
"ended": "2026-10-24T22:07:22.215700+00:00"
},
{
"name": "baseline-red",
"argv": [
"node",
"--test",
"--test-concurrency=1",
"--test-name-pattern=wikilinks are inline atoms and survive",
"packages/webui/tests/editor-wikilink.test.mjs"
],
"cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2",
"rc": 1,
"wallSeconds": 1.151,
"counts": {
"tests": 1,
"pass": 0,
"fail": 1,
"cancelled": 0,
"skipped": 0
},
"nice": 19,
"tmpRoot": "/mnt/storage/scratch/tmp/g49X",
"requireEditorBuild": "1",
"timeoutSeconds": 180,
"ended": "2026-10-24T22:07:27.998436+00:00"
},
{
"name": "npm-ci",
"argv": [
"npm",
"ci",
"--ignore-scripts",
"--no-audit",
"--no-fund"
],
"cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2/packages/webui",
"rc": 0,
"wallSeconds": 0.514,
"counts": {},
"nice": 19,
"tmpRoot": "/mnt/storage/scratch/tmp/g49X",
"requireEditorBuild": "1",
"timeoutSeconds": 240,
"ended": "2026-10-24T22:07:41.534116+00:00"
},
{
"name": "build",
"argv": [
"node",
"editor/build.mjs"
],
"cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2/packages/webui",
"rc": 0,
"wallSeconds": 0.108,
"counts": {},
"nice": 19,
"tmpRoot": "/mnt/storage/scratch/tmp/g49X",
"requireEditorBuild": "1",
"timeoutSeconds": 120,
"ended": "2026-10-24T22:07:44.654385+00:00"
},
{
"name": "wikilink-green",
"argv": [
"node",
"--test",
"--test-concurrency=1",
"packages/webui/tests/editor-wikilink.test.mjs"
],
"cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2",
"rc": 0,
"wallSeconds": 2.348,
"counts": {
"tests": 13,
"pass": 13,
"fail": 0,
"cancelled": 0,
"skipped": 0
},
"nice": 19,
"tmpRoot": "/mnt/storage/scratch/tmp/g49X",
"requireEditorBuild": "1",
"timeoutSeconds": 180,
"ended": "2026-10-24T22:07:52.583345+00:00"
},
{
"name": "editor-green",
"argv": [
"node",
"--test",
"--test-concurrency=1",
"packages/webui/tests/editor.test.mjs"
],
"cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2",
"rc": 0,
"wallSeconds": 2.053,
"counts": {
"tests": 5,
"pass": 5,
"fail": 0,
"cancelled": 0,
"skipped": 0
},
"nice": 19,
"tmpRoot": "/mnt/storage/scratch/tmp/g49X",
"requireEditorBuild": "1",
"timeoutSeconds": 240,
"ended": "2026-10-24T22:07:59.286086+00:00"
},
{
"name": "editor-browser-green",
"argv": [
"node",
"--test",
"--test-concurrency=1",
"packages/webui/tests/editor-browser.test.mjs"
],
"cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2",
"rc": 0,
"wallSeconds": 14.984,
"counts": {
"tests": 16,
"pass": 16,
"fail": 0,
"cancelled": 0,
"skipped": 0
},
"nice": 19,
"tmpRoot": "/mnt/storage/scratch/tmp/g49X",
"requireEditorBuild": "1",
"timeoutSeconds": 240,
"ended": "2026-10-24T22:08:20.029050+00:00"
},
{
"name": "provenance-green",
"argv": [
"node",
"--test",
"--test-concurrency=1",
"packages/webui/tests/editor-provenance.test.mjs"
],
"cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2",
"rc": 0,
"wallSeconds": 0.343,
"counts": {
"tests": 6,
"pass": 6,
"fail": 0,
"cancelled": 0,
"skipped": 0
},
"nice": 19,
"tmpRoot": "/mnt/storage/scratch/tmp/g49X",
"requireEditorBuild": "1",
"timeoutSeconds": 180,
"ended": "2026-10-24T22:08:39.003420+00:00"
},
{
"name": "serializer-removal-build",
"argv": [
"node",
"editor/build.mjs"
],
"cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2/packages/webui",
"rc": 0,
"wallSeconds": 0.093,
"counts": {},
"nice": 19,
"tmpRoot": "/mnt/storage/scratch/tmp/g49X",
"requireEditorBuild": "1",
"timeoutSeconds": 90,
"ended": "2026-10-24T22:08:51.422097+00:00"
},
{
"name": "serializer-removal-red",
"argv": [
"node",
"--test",
"--test-concurrency=1",
"--test-name-pattern=wikilinks are inline atoms and survive",
"packages/webui/tests/editor-wikilink.test.mjs"
],
"cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2",
"rc": 1,
"wallSeconds": 0.887,
"counts": {
"tests": 1,
"pass": 0,
"fail": 1,
"cancelled": 0,
"skipped": 0
},
"nice": 19,
"tmpRoot": "/mnt/storage/scratch/tmp/g49X",
"requireEditorBuild": "1",
"timeoutSeconds": 180,
"ended": "2026-10-24T22:08:52.340936+00:00"
},
{
"name": "serializer-removal-restore-build",
"argv": [
"node",
"editor/build.mjs"
],
"cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2/packages/webui",
"rc": 0,
"wallSeconds": 0.115,
"counts": {},
"nice": 19,
"tmpRoot": "/mnt/storage/scratch/tmp/g49X",
"requireEditorBuild": "1",
"timeoutSeconds": 90,
"ended": "2026-10-24T22:08:52.498613+00:00"
},
{
"name": "serializer-removal-restore-green",
"argv": [
"node",
"--test",
"--test-concurrency=1",
"--test-name-pattern=wikilinks are inline atoms and survive",
"packages/webui/tests/editor-wikilink.test.mjs"
],
"cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2",
"rc": 0,
"wallSeconds": 0.979,
"counts": {
"tests": 1,
"pass": 1,
"fail": 0,
"cancelled": 0,
"skipped": 0
},
"nice": 19,
"tmpRoot": "/mnt/storage/scratch/tmp/g49X",
"requireEditorBuild": "1",
"timeoutSeconds": 180,
"ended": "2026-10-24T22:08:53.514962+00:00"
},
{
"name": "invalid-escaping-build",
"argv": [
"node",
"editor/build.mjs"
],
"cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2/packages/webui",
"rc": 0,
"wallSeconds": 0.102,
"counts": {},
"nice": 19,
"tmpRoot": "/mnt/storage/scratch/tmp/g49X",
"requireEditorBuild": "1",
"timeoutSeconds": 90,
"ended": "2026-10-24T22:08:53.655583+00:00"
},
{
"name": "invalid-escaping-red",
"argv": [
"node",
"--test",
"--test-concurrency=1",
"--test-name-pattern=wikilink DOM paste accepts",
"packages/webui/tests/editor-wikilink.test.mjs"
],
"cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2",
"rc": 1,
"wallSeconds": 0.979,
"counts": {
"tests": 1,
"pass": 0,
"fail": 1,
"cancelled": 0,
"skipped": 0
},
"nice": 19,
"tmpRoot": "/mnt/storage/scratch/tmp/g49X",
"requireEditorBuild": "1",
"timeoutSeconds": 180,
"ended": "2026-10-24T22:08:54.668225+00:00"
},
{
"name": "invalid-escaping-restore-build",
"argv": [
"node",
"editor/build.mjs"
],
"cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2/packages/webui",
"rc": 0,
"wallSeconds": 0.105,
"counts": {},
"nice": 19,
"tmpRoot": "/mnt/storage/scratch/tmp/g49X",
"requireEditorBuild": "1",
"timeoutSeconds": 90,
"ended": "2026-10-24T22:08:54.809629+00:00"
},
{
"name": "invalid-escaping-restore-green",
"argv": [
"node",
"--test",
"--test-concurrency=1",
"--test-name-pattern=wikilink DOM paste accepts",
"packages/webui/tests/editor-wikilink.test.mjs"
],
"cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2",
"rc": 0,
"wallSeconds": 0.985,
"counts": {
"tests": 1,
"pass": 1,
"fail": 0,
"cancelled": 0,
"skipped": 0
},
"nice": 19,
"tmpRoot": "/mnt/storage/scratch/tmp/g49X",
"requireEditorBuild": "1",
"timeoutSeconds": 180,
"ended": "2026-10-24T22:08:55.831793+00:00"
},
{
"name": "webui-whole",
"argv": [
"node",
"--test",
"--test-concurrency=2",
"tests/agents-browser.test.mjs",
"tests/agents.test.mjs",
"tests/board-held.test.mjs",
"tests/browser-edge.test.mjs",
"tests/browser.test.mjs",
"tests/bus-browser.test.mjs",
"tests/bus.test.mjs"
],
"cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2/packages/webui",
"rc": 0,
"wallSeconds": 17.787,
"counts": {
"tests": 28,
"pass": 28,
"fail": 0,
"cancelled": 0,
"skipped": 0
},
"nice": 19,
"tmpRoot": "/mnt/storage/scratch/tmp/g49X",
"requireEditorBuild": "1",
"timeoutSeconds": 600,
"ended": "2026-10-24T22:09:39.039897+00:00"
},
{
"name": "webui-69",
"argv": [
"node",
"--test",
"--test-concurrency=2",
"tests/agents-browser.test.mjs",
"tests/agents.test.mjs",
"tests/assistant.test.mjs",
"tests/board-held.test.mjs",
"tests/browser-edge.test.mjs",
"tests/browser.test.mjs",
"tests/bus-browser.test.mjs",
"tests/bus.test.mjs",
"tests/channel-chat.test.mjs",
"tests/chat-browser.test.mjs",
"tests/chat-instances.test.mjs",
"tests/chat-replies-browser.test.mjs",
"tests/chat-sessions-access.test.mjs",
"tests/chat.test.mjs",
"tests/conversation.test.mjs",
"tests/decide-browser.test.mjs",
"tests/decide-sso.test.mjs",
"tests/decision-http.test.mjs",
"tests/discord.test.mjs",
"tests/docs-browser.test.mjs",
"tests/editor-browser.test.mjs",
"tests/editor-provenance.test.mjs",
"tests/editor-wikilink.test.mjs",
"tests/editor.test.mjs",
"tests/granted-user.test.mjs",
"tests/history-return-flow.test.mjs",
"tests/instances-browser.test.mjs",
"tests/instances.test.mjs",
"tests/library-cli.test.mjs",
"tests/library-routes.test.mjs",
"tests/library-status.test.mjs",
"tests/limits-browser.test.mjs",
"tests/limits-route.test.mjs",
"tests/markdown-preview.test.mjs",
"tests/messages.test.mjs",
"tests/pinned-copy.test.mjs",
"tests/preview.test.mjs",
"tests/processing-browser.test.mjs",
"tests/processing-ui.test.mjs",
"tests/processing.test.mjs",
"tests/project-file.test.mjs",
"tests/project-name-repo.test.mjs",
"tests/proof-cli.test.mjs",
"tests/proof.test.mjs",
"tests/reads.test.mjs",
"tests/relaunch.test.mjs",
"tests/return-flow.test.mjs",
"tests/routes-browser.test.mjs",
"tests/routes.test.mjs",
"tests/send-browser.test.mjs",
"tests/send.test.mjs",
"tests/serve.test.mjs",
"tests/service.test.mjs",
"tests/settings-form.test.mjs",
"tests/settings-write.test.mjs",
"tests/shell.test.mjs",
"tests/sso-browser.test.mjs",
"tests/sso.test.mjs",
"tests/tester-browser.test.mjs",
"tests/tester-cli.test.mjs",
"tests/tester-listener.test.mjs",
"tests/tester-socket.test.mjs",
"tests/thread-claims-browser.test.mjs",
"tests/thread-claims.test.mjs",
"tests/thread-sharing.test.mjs",
"tests/usage-browser.test.mjs",
"tests/usage.test.mjs",
"tests/user-option-swap.test.mjs",
"tests/views.test.mjs"
],
"cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2/packages/webui",
"rc": 0,
"wallSeconds": 174.189,
"counts": {
"tests": 455,
"pass": 455,
"fail": 0,
"cancelled": 0,
"skipped": 0
},
"nice": 19,
"tmpRoot": "/mnt/storage/scratch/tmp/g49X",
"requireEditorBuild": "1",
"timeoutSeconds": 600,
"ended": "2026-10-24T22:12:48.880975+00:00"
}
],
"mutants": [
{
"name": "serializer-removal",
"killed": true,
"restored": true,
"restoredHashesEqual": true
},
{
"name": "invalid-escaping",
"killed": true,
"restored": true,
"restoredHashesEqual": true
}
],
"skipNames": [],
"sweep": {
"at": "2026-10-24T22:13:17.560692+00:00",
"count": 0,
"processes": []
},
"generated": {
"src/public/editor.js": {
"sha256": "bd167899a551c70dc07e500fcdd1853d7fb710b23c217ba5b42daf5785c38a72",
"bytes": 759959
},
"src/public/editor.css": {
"sha256": "8654ac57d1e7a561110138ecd7f701c34a074017ff8c4cd2768d8083908ee61f",
"bytes": 4462
}
},
"unchangedCss": true,
"modelRestored": true,
"notes": [
"webui-whole was an accidental seven-file subset, 28/0, not gate evidence; superseded by webui-69 455/0",
"no scripts/test-*.sh ran in this grant; later gate remains pending"
],
"tmpRemoved": true
}
-323
View File
@@ -1,323 +0,0 @@
{
"base": "487500a16f4ab5902c318509fdec6acc56a543ca",
"testFilesScanned": 495,
"plannedPaths": [
"packages/webui/editor/model.mjs",
"packages/webui/tests/editor-wikilink.test.mjs",
"packages/webui/tests/helpers/wikilink-cases.mjs",
"agents/herb/work/queue-214/measure.mjs",
"agents/herb/work/queue-214/DESIGN.md",
"agents/herb/work/queue-214/MANIFEST.md",
"agents/herb/work/queue-214/BUILD.md",
"packages/webui/src/public/editor.js",
"packages/webui/editor/BUILD.json",
"agents/herb/work/queue-214/HASH-PINS.json"
],
"literalPriorBlobPins": [],
"pathReferences": [
{
"test": "packages/webui/tests/editor-wikilink.test.mjs",
"line": 6,
"text": "import { WIKILINK_CASES } from './helpers/wikilink-cases.mjs';"
},
{
"test": "packages/webui/tests/browser.test.mjs",
"line": 21,
"text": "// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, same origin; the list is the pin, so another script is a visible change."
},
{
"test": "packages/webui/tests/browser.test.mjs",
"line": 22,
"text": "assert.deepEqual(await b.evaluate('[...document.querySelectorAll(\"script\")].map(s => s.getAttribute(\"src\"))'), ['/editor.js', '/processing.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/chat.js', '/agents.js', '/bus.js']);"
},
{
"test": "packages/webui/tests/sso.test.mjs",
"line": 1,
"text": "// Q175 phase 1 (#1690, design agents/sage/work/q175-sso/DESIGN.md section 9): the Console as an"
},
{
"test": "packages/webui/tests/user-option-swap.test.mjs",
"line": 133,
"text": "['GET', '/app.js'], ['GET', '/bus.js'], ['GET', '/agents.js'], ['GET', '/editor.js'], ['GET', '/shared/app.css'], ['GET', '/index.html'],"
},
{
"test": "packages/webui/tests/serve.test.mjs",
"line": 38,
"text": "for (const path of ['/', '/app.js', '/brand.js', '/console.css', '/live.css', '/editor.js', '/editor.css', '/shared/app.css', '/assets/fonts/manrope-400.woff2']) {"
},
{
"test": "packages/webui/tests/editor-provenance.test.mjs",
"line": 18,
"text": "const build = JSON.parse(readFileSync(join(webui, 'editor/BUILD.json'), 'utf8'));"
},
{
"test": "packages/webui/tests/editor-provenance.test.mjs",
"line": 92,
"text": "const bundle = readFileSync(join(webui, 'src/public/editor.js'), 'utf8');"
},
{
"test": "packages/webui/tests/editor-provenance.test.mjs",
"line": 167,
"text": "const info = await run(out, join(out, 'BUILD.json'));"
},
{
"test": "packages/webui/tests/editor-provenance.test.mjs",
"line": 171,
"text": "assert.equal(readFileSync(join(out, 'BUILD.json'), 'utf8'), readFileSync(join(webui, 'editor/BUILD.json'), 'utf8'));"
},
{
"test": "packages/webui/tests/editor-browser.test.mjs",
"line": 103,
"text": "assert.equal(await h.q('typeof window.MosaicEditor.mount'), 'function', 'editor.js ran under script-src self');"
},
{
"test": "packages/webui/tests/editor-browser.test.mjs",
"line": 423,
"text": "// (The editor's own reading of a file can give a single line break in a title or alt, never a blank line; see BUILD.md.)"
},
{
"test": "packages/webui/tests/granted-user.test.mjs",
"line": 231,
"text": "['GET', '/app.js'], ['GET', '/bus.js'], ['GET', '/agents.js'], ['GET', '/editor.js'], ['GET', '/shared/app.css'], ['GET', '/index.html'],"
},
{
"test": "packages/webui/tests/editor.test.mjs",
"line": 20,
"text": "for (const [path, type] of [['/editor.js', 'text/javascript'], ['/editor.css', 'text/css']]) {"
},
{
"test": "packages/webui/tests/editor.test.mjs",
"line": 29,
"text": "assert.ok(index.indexOf('src=\"/editor.js\"') < index.indexOf('src=\"/bus.js\"'), 'the editor loads before the page script, so bus.js can mount it');"
},
{
"test": "packages/webui/tests/editor.test.mjs",
"line": 60,
"text": "for (const file of ['entry.mjs', 'model.mjs', 'view.mjs']) {"
},
{
"test": "packages/webui/tests/editor.test.mjs",
"line": 64,
"text": "const bundle = readFileSync(join(webui, 'src/public/editor.js'), 'utf8');"
},
{
"test": "packages/webui/tests/editor.test.mjs",
"line": 65,
"text": "assert.doesNotMatch(bundle, /\\beval\\s*\\(|\\bnew\\s+Function\\b/, 'src/public/editor.js');"
},
{
"test": "packages/webui/tests/editor.test.mjs",
"line": 69,
"text": "const model = readFileSync(join(webui, 'editor/model.mjs'), 'utf8');"
},
{
"test": "packages/webui/tests/helpers/editor-bundle.mjs",
"line": 1,
"text": "// Loads src/public/editor.js (and markdown-preview.js, the link gate it needs) into a vm context with a"
},
{
"test": "packages/webui/tests/helpers/editor-bundle.mjs",
"line": 15,
"text": "for (const f of ['markdown-preview.js', 'editor.js']) vm.runInContext(readFileSync(join(pub, f), 'utf8'), sandbox, { filename: f });"
},
{
"test": "packages/plugins/tests/harness.test.mjs",
"line": 44,
"text": "// pi 0.85.1 --thinking and claude 2.1.302 --effort, as probed (BUILD.md); claude has no off or minimal."
},
{
"test": "packages/bus/tests/revoke-window.test.mjs",
"line": 4,
"text": "// a limit stub and a usage stub for the limit stop. BUILD.md (agents/morgana/work/queue-306) names"
},
{
"test": "packages/bus/tests/access-revoke.test.mjs",
"line": 5,
"text": "// lstat key; `edit` changes the document and the stamp together, as a hand edit does. BUILD.md"
},
{
"test": "packages/bus/tests/land.test.mjs",
"line": 410,
"text": "// landHeld still counts it, so it stays listed (BUILD.md follow-up 5)."
},
{
"test": "packages/bus/tests/session-clear.test.mjs",
"line": 13,
"text": "// docs/plans/2026-10-21_t1-sessions.md, the contract in agents/gizmoduck/work/queue-271/BUILD.md."
},
{
"test": "packages/bus/tests/grant-executor.test.mjs",
"line": 1027,
"text": "// credential-unavailable; BUILD.md records the difference)."
},
{
"test": "packages/bus/tests/host-run.test.mjs",
"line": 343,
"text": "// the options are checked here and the call runs with an alias no user file holds (BUILD.md)."
},
{
"test": "packages/business/tests/workflow-roles.test.mjs",
"line": 123,
"text": "// of staff launches it. The broker and launcher states are row 137's tests (BUILD.md, round 3)."
},
{
"test": "packages/business/tests/access.test.mjs",
"line": 1,
"text": "// FED R1 (#414, row 201): the access file loader and validator, DESIGN.md"
},
{
"test": "packages/cli/tests/revoke-window.test.mjs",
"line": 9,
"text": "// poll, with threadsState replaced by the caller. BUILD.md (agents/morgana/work/queue-306) names the"
},
{
"test": "packages/cli/tests/stack-instances.test.mjs",
"line": 3,
"text": "// T table: agents/darkwing/work/queue-190/DESIGN.md D7. The unit, port and release cases are T56 in"
},
{
"test": "packages/cli/tests/session-launch.test.mjs",
"line": 297,
"text": "// Q2 (BUILD.md:177-179, t1-sessions.md:109): an earlier host's sessionless run of the instance is"
},
{
"test": "packages/cli/tests/case1-accept.test.mjs",
"line": 2,
"text": "// (agents/darkwing/work/queue-195/DESIGN.md); deviations in agents/darkwing/work/queue-195/BUILD.md."
},
{
"test": "packages/cli/tests/stack-release.test.mjs",
"line": 4,
"text": "// agents/darkwing/work/queue-162/BUILD.md. Every run uses a temp data root, temp units and stand-in"
},
{
"test": "packages/cli/tests/stack-release.test.mjs",
"line": 915,
"text": "// COUNTED or dropped from it shows here. Row 181's BUILD.md names each writer."
},
{
"test": "packages/cli/tests/stack-release.test.mjs",
"line": 928,
"text": "task_comment_cursors:'no time column; a restore drops its advances uncounted (BUILD.md)',"
},
{
"test": "packages/cli/tests/stack-release.test.mjs",
"line": 1668,
"text": "// refusal is the guard for a file that changes after that (BUILD.md, round 2)."
},
{
"test": "packages/cli/tests/stack-release.test.mjs",
"line": 2331,
"text": "// agents/darkwing/work/queue-190/DESIGN.md. T-I1, T-I2, T-I5, T-I10 and the loader half of T-I7 are in"
},
{
"test": "packages/cli/tests/publish-socket.test.mjs",
"line": 236,
"text": "// sage-1440 on BUILD.md 14: a push between checkPublish and the git push refuses, with one code"
},
{
"test": "packages/cli/tests/stack-upgrade.test.mjs",
"line": 3,
"text": "// agents/darkwing/work/queue-195/DESIGN.md, as the I3 brief changes them; deviations:"
},
{
"test": "packages/cli/tests/stack-upgrade.test.mjs",
"line": 4,
"text": "// agents/darkwing/work/queue-233/BUILD.md. The stand-ins and the module fixture are"
},
{
"test": "packages/cli/tests/stack-release-external.test.mjs",
"line": 2,
"text": "// test list, agents/darkwing/work/queue-195/DESIGN.md; deviations: agents/darkwing/work/queue-232/BUILD.md."
},
{
"test": "packages/cli/tests/case1-hostcheck.test.mjs",
"line": 3,
"text": "// (agents/darkwing/work/queue-195/DESIGN.md at b7239bb66). Every case copies the script into a scratch"
},
{
"test": "packages/cli/tests/thread-revoke.test.mjs",
"line": 5,
"text": "// The bus half is packages/bus/tests/access-revoke.test.mjs; BUILD.md"
},
{
"test": "packages/cli/tests/access.test.mjs",
"line": 308,
"text": "// outside R5 (BUILD.md). The test pins it, and nothing is written."
},
{
"test": "packages/cli/tests/access.test.mjs",
"line": 316,
"text": "// Row 193c (#1705): `user add` and `user remove`. BUILD.md (agents/megavolt/work/queue-207) names"
},
{
"test": "packages/cli/tests/access.test.mjs",
"line": 455,
"text": "// refuses access.reload alone, as a bus would refuse a call it can't take. BUILD.md"
},
{
"test": "packages/cli/tests/launcher.test.mjs",
"line": 799,
"text": "// The run directory exists by then (BUILD.md residual); the persona is read before any bundle file."
},
{
"test": "packages/cli/tests/lifecycle.test.mjs",
"line": 778,
"text": "// N6 (sage-2246, DESIGN.md:118-120): an end signals the runs the verb named and the instance's runs"
}
],
"row266": {
"ref": "origin/fethry/row266",
"tip": "6418ec063389728e0d8883c94812b12d2074eb43",
"diffBase": "487500a16f4ab5902c318509fdec6acc56a543ca",
"overlap": []
},
"interpretation": "No literal SHA256 or Git-blob hash pins found for planned changed files. Editor provenance compares generated output to BUILD.json and requires regeneration/rebuild, not an external fixed hash.",
"postBuildAudit": {
"paths": [
"BUILD-LOG.md",
"agents/herb/work/queue-214/BUILD.md",
"agents/herb/work/queue-214/DESIGN.md",
"agents/herb/work/queue-214/EVIDENCE.json",
"agents/herb/work/queue-214/HASH-PINS.json",
"agents/herb/work/queue-214/MANIFEST.md",
"agents/herb/work/queue-214/SOURCE.json",
"agents/herb/work/queue-214/granted-4286/baseline-red.log",
"agents/herb/work/queue-214/granted-4286/build.log",
"agents/herb/work/queue-214/granted-4286/editor-browser-green.log",
"agents/herb/work/queue-214/granted-4286/editor-green.log",
"agents/herb/work/queue-214/granted-4286/final-sweep.json",
"agents/herb/work/queue-214/granted-4286/invalid-escaping-build.log",
"agents/herb/work/queue-214/granted-4286/invalid-escaping-red.log",
"agents/herb/work/queue-214/granted-4286/invalid-escaping-restore-build.log",
"agents/herb/work/queue-214/granted-4286/invalid-escaping-restore-green.log",
"agents/herb/work/queue-214/granted-4286/measurement.log",
"agents/herb/work/queue-214/granted-4286/mutants.json",
"agents/herb/work/queue-214/granted-4286/mutants.py",
"agents/herb/work/queue-214/granted-4286/npm-ci.log",
"agents/herb/work/queue-214/granted-4286/post-mutant-sweep.json",
"agents/herb/work/queue-214/granted-4286/provenance-green.log",
"agents/herb/work/queue-214/granted-4286/results.jsonl",
"agents/herb/work/queue-214/granted-4286/serializer-removal-build.log",
"agents/herb/work/queue-214/granted-4286/serializer-removal-red.log",
"agents/herb/work/queue-214/granted-4286/serializer-removal-restore-build.log",
"agents/herb/work/queue-214/granted-4286/serializer-removal-restore-green.log",
"agents/herb/work/queue-214/granted-4286/settings.json",
"agents/herb/work/queue-214/granted-4286/skips.json",
"agents/herb/work/queue-214/granted-4286/step.py",
"agents/herb/work/queue-214/granted-4286/webui-69.log",
"agents/herb/work/queue-214/granted-4286/webui-files.txt",
"agents/herb/work/queue-214/granted-4286/webui-whole.log",
"agents/herb/work/queue-214/granted-4286/wikilink-green.log",
"agents/herb/work/queue-214/measure.mjs",
"agents/herb/work/queue-214/measurement.json",
"packages/webui/editor/BUILD.json",
"packages/webui/editor/model.mjs",
"packages/webui/src/public/editor.js",
"packages/webui/tests/editor-wikilink.test.mjs",
"packages/webui/tests/helpers/wikilink-cases.mjs"
],
"literalCurrentOrPriorHashPins": [],
"buildOutputsValidated": true
}
}
-42
View File
@@ -1,42 +0,0 @@
# Row214 candidate manifest
Owner: Herb. Reviewer: Filbert. Issue#1715, C2-11a. Execution base
487500a16f4ab5902c318509fdec6acc56a543ca on herb/row214-r2. Current
brief blob85e6fe2e has the same C2-11a section as the accepted old pin.
Source commit a530a60798bf48eb18bb9e225d099faf5c2975cf.
| Path | Final change |
| --- | --- |
| packages/webui/editor/model.mjs | inert inline atom, heading content, tokenizer and validated serializer; invalid attrs share complete text escaping |
| packages/webui/tests/editor-wikilink.test.mjs | ten byte round-trips and three browser regressions, including same-block edits and malformed attrs |
| packages/webui/tests/helpers/wikilink-cases.mjs | exact fixtures shared with measurement |
| packages/webui/src/public/editor.js | generated with pinned esbuild0.28.2 |
| packages/webui/editor/BUILD.json | generated provenance and output hashes |
| BUILD-LOG.md | one append-only phase receipt |
| agents/herb/work/queue-214/measure.mjs | baseline measurement from pinned Git blobs |
| agents/herb/work/queue-214/measurement.json | unchanged measurement JSON,10/10 equal |
| agents/herb/work/queue-214/DESIGN.md | accepted design, both outcomes and static review follow-through |
| agents/herb/work/queue-214/BUILD.md | rulings, actual granted commands, timings and limits |
| agents/herb/work/queue-214/HASH-PINS.json | static hash-pin/row266 audit, repeated after build |
| agents/herb/work/queue-214/SOURCE.json | actual source/output hashes and unchanged CSS dependency |
| agents/herb/work/queue-214/EVIDENCE.json | grant, exact argv/counts, measurement, mutants, skips and sweeps |
| agents/herb/work/queue-214/granted-4286/ | raw tee logs, commands, file list and sweep/skip/mutant records |
The CSS output is byte-identical and matches BUILD.json. No entry/view,
third_party, dependency/lockfile, library, route, save, click or documentPath
change. No row266 chat path changes. No test assertion changed after runs.
Outcome A: all ten measured no-edit cases were already preserved by the
baseline's raw/sig behavior. Their green result is compatibility evidence,
not proof of the node. The baseline structural case was0/1 expected red.
Grant sage-4286: four whole editor files40/0; whole webui69 files455/0
in174.189s; zero skips/cancellations. Both named mutants killed0/1,
each restored/rebuilt and focused1/0 green. Absolute nice19, required-build
env and short scratch TMPDIR are in raw logs. Provenance rebuilt, not
skipped. Final process sweep0, TMPDIR removed. The mistaken seven-file
subset28/0 is archived but excluded from gate evidence.
The candidate hash manifest is rebuilt from the pushed tip outside its
own path set. Filbert's candidate review and every scripts/test-*.sh gate
remain pending. No Case1 acceptance test was run. The row is not done.
-49
View File
@@ -1,49 +0,0 @@
{
"base": "487500a16f4ab5902c318509fdec6acc56a543ca",
"status": "focused and 69-file webui green under sage-4286; candidate review and shell gate pending",
"sourcePaths": [
{
"path": "packages/webui/editor/model.mjs",
"mode": "100644",
"sha256": "81ac44d852cb54213a9644ebbde26630ec49614e468f376d71b471395e3dd9f7"
},
{
"path": "packages/webui/tests/editor-wikilink.test.mjs",
"mode": "100644",
"sha256": "e144e7dbf23c68c136eef8f0fb2e8979c27e908c7cc14ee4ab4f722220e67d77"
},
{
"path": "packages/webui/tests/helpers/wikilink-cases.mjs",
"mode": "100644",
"sha256": "afa4dc7188548457b2d1966b9437561ba411d7bc9aaaa38bff626c235a64e19c"
},
{
"path": "agents/herb/work/queue-214/measure.mjs",
"mode": "100644",
"sha256": "f81987cdae5c4f7e5ce353162674b3c3510e44f22d6d3beb262a5d198db4e88d"
},
{
"path": "packages/webui/src/public/editor.js",
"mode": "100644",
"sha256": "bd167899a551c70dc07e500fcdd1853d7fb710b23c217ba5b42daf5785c38a72"
},
{
"path": "packages/webui/editor/BUILD.json",
"mode": "100644",
"sha256": "4d73af728cea60eaa6fadb4650b9d42b663c90ef7f59bb2b787e1f3d9acd8278"
},
{
"path": "BUILD-LOG.md",
"mode": "100644",
"sha256": "30cc179e2b76c5aceeec35d5ab20339988241a4cb04d26d1cc9b2a5437796236"
}
],
"pendingGeneratedPaths": [],
"unchangedGeneratedCss": {
"path": "packages/webui/src/public/editor.css",
"sha256": "8654ac57d1e7a561110138ecd7f701c34a074017ff8c4cd2768d8083908ee61f",
"mode": "100644"
},
"runtimeEvidence": "agents/herb/work/queue-214/EVIDENCE.json",
"sourceCommit": "a530a60798bf48eb18bb9e225d099faf5c2975cf"
}
@@ -1,38 +0,0 @@
# grant sage-4286; nice 19; TMPDIR /mnt/storage/scratch/tmp/g49X; MOSAIC_REQUIRE_EDITOR_BUILD 1
✖ wikilinks are inline atoms and survive a neighbouring edit in every required context (932.607342ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1108.548729
✖ failing tests:
test at packages/webui/tests/editor-wikilink.test.mjs:35:1
✖ wikilinks are inline atoms and survive a neighbouring edit in every required context (932.607342ms)
AssertionError [ERR_ASSERTION]: plain
+ actual - expected
+ []
- [
- {
- atom: true,
- inline: true,
- source: '[[a]]'
- }
- ]
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2/packages/webui/tests/editor-wikilink.test.mjs:39:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: [],
expected: [ { source: '[[a]]', atom: true, inline: true } ],
operator: 'deepStrictEqual',
diff: 'simple'
}
@@ -1,15 +0,0 @@
# grant sage-4286; nice 19; TMPDIR /mnt/storage/scratch/tmp/g49X; MOSAIC_REQUIRE_EDITOR_BUILD 1
▲ [WARNING] Ignoring this import because "third_party/editor/prosemirror-transform/src/mark.ts" was marked as having no side effects [ignored-bare-import]
third_party/editor/prosemirror-transform/src/index.ts:10:7:
10 │ import "./mark"
╵ ~~~~~~~~
"sideEffects" is false in the enclosing "package.json" file:
third_party/editor/prosemirror-transform/package.json:13:2:
13 │ "sideEffects": false,
╵ ~~~~~~~~~~~~~
editor.js bd167899a551c70dc07e500fcdd1853d7fb710b23c217ba5b42daf5785c38a72 (133 inputs)
editor.css 8654ac57d1e7a561110138ecd7f701c34a074017ff8c4cd2768d8083908ee61f
@@ -1,25 +0,0 @@
# grant sage-4286; nice 19; TMPDIR /mnt/storage/scratch/tmp/g49X; MOSAIC_REQUIRE_EDITOR_BUILD 1
✔ the Console's CSP stands and the editor runs under it; hostile markdown on the load path runs and loads nothing (928.130513ms)
✔ hostile content pasted into the editor runs and loads nothing, and a bad link target is refused in the link bar (803.464018ms)
✔ a pasted image span is kept only for a target the link gate accepts, and a save writes only those (635.018754ms)
✔ a switch of view reads nothing; a reload, and a trip away and back, each read the file exactly once (2555.185634ms)
✔ an edit rewrites only the block touched; a save sends the rest byte for byte; the Source view carries the draft (1077.608366ms)
✔ a file the editor cannot write back byte for byte opens as Source, says so, cannot save, and the Visual button works once it can (2053.81983ms)
✔ a link title pasted with line breaks in it is written on one line, so no HTML block comes of it (761.717564ms)
✔ an image alt pasted with line breaks in it is written on one line, so no HTML block comes of it (677.209658ms)
✔ an image title pasted with line breaks in it is written on one line, so no HTML block comes of it (681.501055ms)
✔ a code block info string pasted with line breaks in it is written on one line, so no HTML block comes of it (664.552078ms)
✔ a link title holding a quote after a backslash then a tag is written with the backslash escaped, so the title stays closed (653.763504ms)
✔ an image title holding a quote after a backslash then a tag is written with the backslash escaped, so the title stays closed (680.145608ms)
✔ a link title holding a trailing backslash is written with the backslash escaped, so the title stays closed (681.870693ms)
✔ an image title holding a trailing backslash is written with the backslash escaped, so the title stays closed (686.810545ms)
✔ a code block info string with a backtick, pasted, is dropped on Save, so the fence stays closed (631.067849ms)
✔ a code block info string with a backtick, as a tilde fence can have, is kept as source, not rewritten without it (631.071266ms)
ℹ tests 16
ℹ suites 0
ℹ pass 16
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 14945.374491
@@ -1,14 +0,0 @@
# grant sage-4286; nice 19; TMPDIR /mnt/storage/scratch/tmp/g49X; MOSAIC_REQUIRE_EDITOR_BUILD 1
✔ the editor routes serve the built files with their types and no cache (36.796766ms)
✔ open and save with no edit writes the same bytes: the goals review and a doc with every element (16.912796ms)
✔ open and save with no edit writes the same bytes: every repository doc under docs/ and spec/ (1791.779235ms)
✔ the editor source builds markup only with DOM calls, and the bundle has no eval (2.568423ms)
✔ the link gate and the image rule are the preview's (0.573105ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2016.291596
@@ -1,5 +0,0 @@
{
"at": "2026-10-24T22:13:17.560692+00:00",
"count": 0,
"processes": []
}
@@ -1,15 +0,0 @@
# grant sage-4286; nice 19; TMPDIR /mnt/storage/scratch/tmp/g49X; MOSAIC_REQUIRE_EDITOR_BUILD 1
▲ [WARNING] Ignoring this import because "third_party/editor/prosemirror-transform/src/mark.ts" was marked as having no side effects [ignored-bare-import]
third_party/editor/prosemirror-transform/src/index.ts:10:7:
10 │ import "./mark"
╵ ~~~~~~~~
"sideEffects" is false in the enclosing "package.json" file:
third_party/editor/prosemirror-transform/package.json:13:2:
13 │ "sideEffects": false,
╵ ~~~~~~~~~~~~~
editor.js 52c00310b3c32b2f5ca99243f9bbe54f8a76ef0a87f597d8a2220cf1baaa8adc (133 inputs)
editor.css 8654ac57d1e7a561110138ecd7f701c34a074017ff8c4cd2768d8083908ee61f
@@ -1,30 +0,0 @@
# grant sage-4286; nice 19; TMPDIR /mnt/storage/scratch/tmp/g49X; MOSAIC_REQUIRE_EDITOR_BUILD 1
✖ wikilink DOM paste accepts only bounded single-line inert syntax; malformed attrs serialise as escaped text (780.97333ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 939.392901
✖ failing tests:
test at packages/webui/tests/editor-wikilink.test.mjs:65:1
✖ wikilink DOM paste accepts only bounded single-line inert syntax; malformed attrs serialise as escaped text (780.97333ms)
AssertionError [ERR_ASSERTION]: The input was expected to not match the regular expression /(?<!\\)<img/. Input:
'\\[\\[a\\]\\] <img src=x onerror=alert(1)>&copy;\n'
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2/packages/webui/tests/editor-wikilink.test.mjs:78:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: '\\[\\[a\\]\\] <img src=x onerror=alert(1)>&copy;\n',
expected: /(?<!\\)<img/,
operator: 'doesNotMatch',
diff: 'simple'
}
@@ -1,15 +0,0 @@
# grant sage-4286; nice 19; TMPDIR /mnt/storage/scratch/tmp/g49X; MOSAIC_REQUIRE_EDITOR_BUILD 1
▲ [WARNING] Ignoring this import because "third_party/editor/prosemirror-transform/src/mark.ts" was marked as having no side effects [ignored-bare-import]
third_party/editor/prosemirror-transform/src/index.ts:10:7:
10 │ import "./mark"
╵ ~~~~~~~~
"sideEffects" is false in the enclosing "package.json" file:
third_party/editor/prosemirror-transform/package.json:13:2:
13 │ "sideEffects": false,
╵ ~~~~~~~~~~~~~
editor.js bd167899a551c70dc07e500fcdd1853d7fb710b23c217ba5b42daf5785c38a72 (133 inputs)
editor.css 8654ac57d1e7a561110138ecd7f701c34a074017ff8c4cd2768d8083908ee61f
@@ -1,10 +0,0 @@
# grant sage-4286; nice 19; TMPDIR /mnt/storage/scratch/tmp/g49X; MOSAIC_REQUIRE_EDITOR_BUILD 1
✔ wikilink DOM paste accepts only bounded single-line inert syntax; malformed attrs serialise as escaped text (752.4905ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 941.178679
@@ -1,68 +0,0 @@
{
"base": "487500a16f4ab5902c318509fdec6acc56a543ca",
"bundleSha256": "9c881b13740fae2408717ad75fa1d85bfcfe3fa8b3af0f1b3f2e706c90e80fee",
"measuredAt": "2026-10-24T22:07:22.210Z",
"allEqual": true,
"results": [
{
"name": "plain",
"input": "[[a]]\n",
"output": "[[a]]\n",
"equal": true
},
{
"name": "labelled",
"input": "[[a|b]]\n",
"output": "[[a|b]]\n",
"equal": true
},
{
"name": "heading target",
"input": "[[a#h]]\n",
"output": "[[a#h]]\n",
"equal": true
},
{
"name": "list item",
"input": "- [[a]]\n",
"output": "- [[a]]\n",
"equal": true
},
{
"name": "next to front matter",
"input": "---\nrelated: \"[[metadata]]\"\n---\n\n[[a]]\n",
"output": "---\nrelated: \"[[metadata]]\"\n---\n\n[[a]]\n",
"equal": true
},
{
"name": "heading block and combined label",
"input": "# See [[a#h|Title]]\n",
"output": "# See [[a#h|Title]]\n",
"equal": true
},
{
"name": "spaces, Unicode and adjacent links",
"input": "See [[ Héllo there # A heading | a label ]][[next]]\n",
"output": "See [[ Héllo there # A heading | a label ]][[next]]\n",
"equal": true
},
{
"name": "reference-definition collision",
"input": "[[a]]\n\n[a]: u\n",
"output": "[[a]]\n\n[a]: u\n",
"equal": true
},
{
"name": "inside an ordinary link",
"input": "[x [[a]]](u)\n",
"output": "[x [[a]]](u)\n",
"equal": true
},
{
"name": "inert embed",
"input": "![[cover.png]]\n",
"output": "![[cover.png]]\n",
"equal": true
}
]
}
@@ -1,14 +0,0 @@
[
{
"name": "serializer-removal",
"killed": true,
"restored": true,
"restoredHashesEqual": true
},
{
"name": "invalid-escaping",
"killed": true,
"restored": true,
"restoredHashesEqual": true
}
]
@@ -1,26 +0,0 @@
from pathlib import Path
import subprocess,sys,json,hashlib
out=Path(__file__).parent;root=Path('/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2');model=root/'packages/webui/editor/model.mjs';good=model.read_bytes();(out/'model.good.mjs').write_bytes(good)
initial={str(p.relative_to(root)):hashlib.sha256(p.read_bytes()).hexdigest() for p in [model,root/'packages/webui/src/public/editor.js',root/'packages/webui/editor/BUILD.json',root/'packages/webui/src/public/editor.css']}
def step(name,cwd,cap,args):return subprocess.run([sys.executable,str(out/'step.py'),name,str(cwd),str(cap),*args]).returncode
specs=[('serializer-removal','wikilinks are inline atoms and survive'),('invalid-escaping','wikilink DOM paste accepts')]
results=[]
for name,pattern in specs:
changed=good.decode()
if name=='serializer-removal':
start=changed.index(' wikilink(state, node) {');end=changed.index(' raw_block(state, node)',start);changed=changed[:start]+changed[end:]
else:
old="else escapedText(state, oneLine(String(source ?? '')));";assert changed.count(old)==1;changed=changed.replace(old,"else state.text(oneLine(String(source ?? '')));")
try:
model.write_text(changed)
assert step(name+'-build',root/'packages/webui',90,['node','editor/build.mjs'])==0
rc=step(name+'-red',root,180,['node','--test','--test-concurrency=1','--test-name-pattern='+pattern,'packages/webui/tests/editor-wikilink.test.mjs'])
if rc!=1:raise RuntimeError('mutant not killed or timed out: '+str(rc))
finally:
model.write_bytes(good)
if step(name+'-restore-build',root/'packages/webui',90,['node','editor/build.mjs'])!=0:raise RuntimeError('restore build failed')
assert step(name+'-restore-green',root,180,['node','--test','--test-concurrency=1','--test-name-pattern='+pattern,'packages/webui/tests/editor-wikilink.test.mjs'])==0
for p,h in initial.items():assert hashlib.sha256((root/p).read_bytes()).hexdigest()==h,p
results.append({'name':name,'killed':True,'restored':True,'restoredHashesEqual':True})
(out/'mutants.json').write_text(json.dumps(results,indent=2)+'\n')
print('MUTANT_COMPLETE',name,flush=True)
@@ -1,3 +0,0 @@
# grant sage-4286; nice 19; TMPDIR /mnt/storage/scratch/tmp/g49X; MOSAIC_REQUIRE_EDITOR_BUILD 1
added 2 packages in 425ms
@@ -1,4 +0,0 @@
{
"count": 0,
"processes": []
}
@@ -1,27 +0,0 @@
# grant sage-4286; nice 19; TMPDIR /mnt/storage/scratch/tmp/g49X; MOSAIC_REQUIRE_EDITOR_BUILD 1
✔ the imported source is the tagged upstream source plus the recorded local changes, and nothing else (27.800323ms)
✔ every package names its origin and license, and NOTICE lists each (0.831005ms)
✔ the build reads only imported source and the editor's own files; the bundle is the recorded build (7.478515ms)
✔ MOSAIC_REQUIRE_EDITOR_BUILD: a missing or wrong esbuild skips without it and fails with it; a wrong binary always fails (6.189875ms)
✔ the toolchain check, run as a program, exits non-zero without esbuild when the build is required (143.672155ms)
▲ [WARNING] Ignoring this import because "third_party/editor/prosemirror-transform/src/mark.ts" was marked as having no side effects [ignored-bare-import]
third_party/editor/prosemirror-transform/src/index.ts:10:7:
10 │ import "./mark"
╵ ~~~~~~~~
"sideEffects" is false in the enclosing "package.json" file:
third_party/editor/prosemirror-transform/package.json:13:2:
13 │ "sideEffects": false,
╵ ~~~~~~~~~~~~~
✔ rebuilding from the source gives the same bytes (needs the pinned esbuild; MOSAIC_REQUIRE_EDITOR_BUILD=1 makes its absence a failure) (67.600627ms)
ℹ tests 6
ℹ suites 0
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 305.965249
@@ -1,18 +0,0 @@
{"name": "measurement", "argv": ["node", "agents/herb/work/queue-214/measure.mjs"], "cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2", "rc": 0, "wallSeconds": 0.093, "counts": {}, "nice": 19, "tmpRoot": "/mnt/storage/scratch/tmp/g49X", "requireEditorBuild": "1", "timeoutSeconds": 60, "ended": "2026-10-24T22:07:22.215700+00:00"}
{"name": "baseline-red", "argv": ["node", "--test", "--test-concurrency=1", "--test-name-pattern=wikilinks are inline atoms and survive", "packages/webui/tests/editor-wikilink.test.mjs"], "cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2", "rc": 1, "wallSeconds": 1.151, "counts": {"tests": 1, "pass": 0, "fail": 1, "cancelled": 0, "skipped": 0}, "nice": 19, "tmpRoot": "/mnt/storage/scratch/tmp/g49X", "requireEditorBuild": "1", "timeoutSeconds": 180, "ended": "2026-10-24T22:07:27.998436+00:00"}
{"name": "npm-ci", "argv": ["npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund"], "cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2/packages/webui", "rc": 0, "wallSeconds": 0.514, "counts": {}, "nice": 19, "tmpRoot": "/mnt/storage/scratch/tmp/g49X", "requireEditorBuild": "1", "timeoutSeconds": 240, "ended": "2026-10-24T22:07:41.534116+00:00"}
{"name": "build", "argv": ["node", "editor/build.mjs"], "cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2/packages/webui", "rc": 0, "wallSeconds": 0.108, "counts": {}, "nice": 19, "tmpRoot": "/mnt/storage/scratch/tmp/g49X", "requireEditorBuild": "1", "timeoutSeconds": 120, "ended": "2026-10-24T22:07:44.654385+00:00"}
{"name": "wikilink-green", "argv": ["node", "--test", "--test-concurrency=1", "packages/webui/tests/editor-wikilink.test.mjs"], "cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2", "rc": 0, "wallSeconds": 2.348, "counts": {"tests": 13, "pass": 13, "fail": 0, "cancelled": 0, "skipped": 0}, "nice": 19, "tmpRoot": "/mnt/storage/scratch/tmp/g49X", "requireEditorBuild": "1", "timeoutSeconds": 180, "ended": "2026-10-24T22:07:52.583345+00:00"}
{"name": "editor-green", "argv": ["node", "--test", "--test-concurrency=1", "packages/webui/tests/editor.test.mjs"], "cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2", "rc": 0, "wallSeconds": 2.053, "counts": {"tests": 5, "pass": 5, "fail": 0, "cancelled": 0, "skipped": 0}, "nice": 19, "tmpRoot": "/mnt/storage/scratch/tmp/g49X", "requireEditorBuild": "1", "timeoutSeconds": 240, "ended": "2026-10-24T22:07:59.286086+00:00"}
{"name": "editor-browser-green", "argv": ["node", "--test", "--test-concurrency=1", "packages/webui/tests/editor-browser.test.mjs"], "cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2", "rc": 0, "wallSeconds": 14.984, "counts": {"tests": 16, "pass": 16, "fail": 0, "cancelled": 0, "skipped": 0}, "nice": 19, "tmpRoot": "/mnt/storage/scratch/tmp/g49X", "requireEditorBuild": "1", "timeoutSeconds": 240, "ended": "2026-10-24T22:08:20.029050+00:00"}
{"name": "provenance-green", "argv": ["node", "--test", "--test-concurrency=1", "packages/webui/tests/editor-provenance.test.mjs"], "cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2", "rc": 0, "wallSeconds": 0.343, "counts": {"tests": 6, "pass": 6, "fail": 0, "cancelled": 0, "skipped": 0}, "nice": 19, "tmpRoot": "/mnt/storage/scratch/tmp/g49X", "requireEditorBuild": "1", "timeoutSeconds": 180, "ended": "2026-10-24T22:08:39.003420+00:00"}
{"name": "serializer-removal-build", "argv": ["node", "editor/build.mjs"], "cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2/packages/webui", "rc": 0, "wallSeconds": 0.093, "counts": {}, "nice": 19, "tmpRoot": "/mnt/storage/scratch/tmp/g49X", "requireEditorBuild": "1", "timeoutSeconds": 90, "ended": "2026-10-24T22:08:51.422097+00:00"}
{"name": "serializer-removal-red", "argv": ["node", "--test", "--test-concurrency=1", "--test-name-pattern=wikilinks are inline atoms and survive", "packages/webui/tests/editor-wikilink.test.mjs"], "cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2", "rc": 1, "wallSeconds": 0.887, "counts": {"tests": 1, "pass": 0, "fail": 1, "cancelled": 0, "skipped": 0}, "nice": 19, "tmpRoot": "/mnt/storage/scratch/tmp/g49X", "requireEditorBuild": "1", "timeoutSeconds": 180, "ended": "2026-10-24T22:08:52.340936+00:00"}
{"name": "serializer-removal-restore-build", "argv": ["node", "editor/build.mjs"], "cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2/packages/webui", "rc": 0, "wallSeconds": 0.115, "counts": {}, "nice": 19, "tmpRoot": "/mnt/storage/scratch/tmp/g49X", "requireEditorBuild": "1", "timeoutSeconds": 90, "ended": "2026-10-24T22:08:52.498613+00:00"}
{"name": "serializer-removal-restore-green", "argv": ["node", "--test", "--test-concurrency=1", "--test-name-pattern=wikilinks are inline atoms and survive", "packages/webui/tests/editor-wikilink.test.mjs"], "cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2", "rc": 0, "wallSeconds": 0.979, "counts": {"tests": 1, "pass": 1, "fail": 0, "cancelled": 0, "skipped": 0}, "nice": 19, "tmpRoot": "/mnt/storage/scratch/tmp/g49X", "requireEditorBuild": "1", "timeoutSeconds": 180, "ended": "2026-10-24T22:08:53.514962+00:00"}
{"name": "invalid-escaping-build", "argv": ["node", "editor/build.mjs"], "cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2/packages/webui", "rc": 0, "wallSeconds": 0.102, "counts": {}, "nice": 19, "tmpRoot": "/mnt/storage/scratch/tmp/g49X", "requireEditorBuild": "1", "timeoutSeconds": 90, "ended": "2026-10-24T22:08:53.655583+00:00"}
{"name": "invalid-escaping-red", "argv": ["node", "--test", "--test-concurrency=1", "--test-name-pattern=wikilink DOM paste accepts", "packages/webui/tests/editor-wikilink.test.mjs"], "cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2", "rc": 1, "wallSeconds": 0.979, "counts": {"tests": 1, "pass": 0, "fail": 1, "cancelled": 0, "skipped": 0}, "nice": 19, "tmpRoot": "/mnt/storage/scratch/tmp/g49X", "requireEditorBuild": "1", "timeoutSeconds": 180, "ended": "2026-10-24T22:08:54.668225+00:00"}
{"name": "invalid-escaping-restore-build", "argv": ["node", "editor/build.mjs"], "cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2/packages/webui", "rc": 0, "wallSeconds": 0.105, "counts": {}, "nice": 19, "tmpRoot": "/mnt/storage/scratch/tmp/g49X", "requireEditorBuild": "1", "timeoutSeconds": 90, "ended": "2026-10-24T22:08:54.809629+00:00"}
{"name": "invalid-escaping-restore-green", "argv": ["node", "--test", "--test-concurrency=1", "--test-name-pattern=wikilink DOM paste accepts", "packages/webui/tests/editor-wikilink.test.mjs"], "cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2", "rc": 0, "wallSeconds": 0.985, "counts": {"tests": 1, "pass": 1, "fail": 0, "cancelled": 0, "skipped": 0}, "nice": 19, "tmpRoot": "/mnt/storage/scratch/tmp/g49X", "requireEditorBuild": "1", "timeoutSeconds": 180, "ended": "2026-10-24T22:08:55.831793+00:00"}
{"name": "webui-whole", "argv": ["node", "--test", "--test-concurrency=2", "tests/agents-browser.test.mjs", "tests/agents.test.mjs", "tests/board-held.test.mjs", "tests/browser-edge.test.mjs", "tests/browser.test.mjs", "tests/bus-browser.test.mjs", "tests/bus.test.mjs"], "cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2/packages/webui", "rc": 0, "wallSeconds": 17.787, "counts": {"tests": 28, "pass": 28, "fail": 0, "cancelled": 0, "skipped": 0}, "nice": 19, "tmpRoot": "/mnt/storage/scratch/tmp/g49X", "requireEditorBuild": "1", "timeoutSeconds": 600, "ended": "2026-10-24T22:09:39.039897+00:00"}
{"name": "webui-69", "argv": ["node", "--test", "--test-concurrency=2", "tests/agents-browser.test.mjs", "tests/agents.test.mjs", "tests/assistant.test.mjs", "tests/board-held.test.mjs", "tests/browser-edge.test.mjs", "tests/browser.test.mjs", "tests/bus-browser.test.mjs", "tests/bus.test.mjs", "tests/channel-chat.test.mjs", "tests/chat-browser.test.mjs", "tests/chat-instances.test.mjs", "tests/chat-replies-browser.test.mjs", "tests/chat-sessions-access.test.mjs", "tests/chat.test.mjs", "tests/conversation.test.mjs", "tests/decide-browser.test.mjs", "tests/decide-sso.test.mjs", "tests/decision-http.test.mjs", "tests/discord.test.mjs", "tests/docs-browser.test.mjs", "tests/editor-browser.test.mjs", "tests/editor-provenance.test.mjs", "tests/editor-wikilink.test.mjs", "tests/editor.test.mjs", "tests/granted-user.test.mjs", "tests/history-return-flow.test.mjs", "tests/instances-browser.test.mjs", "tests/instances.test.mjs", "tests/library-cli.test.mjs", "tests/library-routes.test.mjs", "tests/library-status.test.mjs", "tests/limits-browser.test.mjs", "tests/limits-route.test.mjs", "tests/markdown-preview.test.mjs", "tests/messages.test.mjs", "tests/pinned-copy.test.mjs", "tests/preview.test.mjs", "tests/processing-browser.test.mjs", "tests/processing-ui.test.mjs", "tests/processing.test.mjs", "tests/project-file.test.mjs", "tests/project-name-repo.test.mjs", "tests/proof-cli.test.mjs", "tests/proof.test.mjs", "tests/reads.test.mjs", "tests/relaunch.test.mjs", "tests/return-flow.test.mjs", "tests/routes-browser.test.mjs", "tests/routes.test.mjs", "tests/send-browser.test.mjs", "tests/send.test.mjs", "tests/serve.test.mjs", "tests/service.test.mjs", "tests/settings-form.test.mjs", "tests/settings-write.test.mjs", "tests/shell.test.mjs", "tests/sso-browser.test.mjs", "tests/sso.test.mjs", "tests/tester-browser.test.mjs", "tests/tester-cli.test.mjs", "tests/tester-listener.test.mjs", "tests/tester-socket.test.mjs", "tests/thread-claims-browser.test.mjs", "tests/thread-claims.test.mjs", "tests/thread-sharing.test.mjs", "tests/usage-browser.test.mjs", "tests/usage.test.mjs", "tests/user-option-swap.test.mjs", "tests/views.test.mjs"], "cwd": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2/packages/webui", "rc": 0, "wallSeconds": 174.189, "counts": {"tests": 455, "pass": 455, "fail": 0, "cancelled": 0, "skipped": 0}, "nice": 19, "tmpRoot": "/mnt/storage/scratch/tmp/g49X", "requireEditorBuild": "1", "timeoutSeconds": 600, "ended": "2026-10-24T22:12:48.880975+00:00"}
@@ -1,15 +0,0 @@
# grant sage-4286; nice 19; TMPDIR /mnt/storage/scratch/tmp/g49X; MOSAIC_REQUIRE_EDITOR_BUILD 1
▲ [WARNING] Ignoring this import because "third_party/editor/prosemirror-transform/src/mark.ts" was marked as having no side effects [ignored-bare-import]
third_party/editor/prosemirror-transform/src/index.ts:10:7:
10 │ import "./mark"
╵ ~~~~~~~~
"sideEffects" is false in the enclosing "package.json" file:
third_party/editor/prosemirror-transform/package.json:13:2:
13 │ "sideEffects": false,
╵ ~~~~~~~~~~~~~
editor.js bf55e22b945eda1e51ecbe9185eadd9ff21a7902713b24417ed7d697c7347bba (133 inputs)
editor.css 8654ac57d1e7a561110138ecd7f701c34a074017ff8c4cd2768d8083908ee61f
@@ -1,31 +0,0 @@
# grant sage-4286; nice 19; TMPDIR /mnt/storage/scratch/tmp/g49X; MOSAIC_REQUIRE_EDITOR_BUILD 1
✖ wikilinks are inline atoms and survive a neighbouring edit in every required context (698.378144ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 850.87157
✖ failing tests:
test at packages/webui/tests/editor-wikilink.test.mjs:35:1
✖ wikilinks are inline atoms and survive a neighbouring edit in every required context (698.378144ms)
Error: Uncaught: Error: Token type `wikilink` not supported by Markdown renderer
at MarkdownSerializerState.render (http://127.0.0.1:43723/editor.js:18015:17)
at progress (http://127.0.0.1:43723/editor.js:18096:18)
at _Fragment.forEach (http://127.0.0.1:43723/editor.js:484:9)
at _Node.forEach (http://127.0.0.1:43723/editor.js:1178:20)
at MarkdownSerializerState.renderInline (http://127.0.0.1:43723/editor.js:18101:14)
at Object.paragraph (http://127.0.0.1:43723/editor.js:17859:13)
at MarkdownSerializerState.render (http://127.0.0.1:43723/editor.js:18012:35)
at http://127.0.0.1:43723/editor.js:18025:44
at _Fragment.forEach (http://127.0.0.1:43723/editor.js:484:9)
at _Node.forEach (http://127.0.0.1:43723/editor.js:1178:20)
at Object.evaluate (file:///home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2/packages/webui/tests/browser.mjs:49:35)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2/packages/webui/tests/editor-wikilink.test.mjs:38:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
@@ -1,15 +0,0 @@
# grant sage-4286; nice 19; TMPDIR /mnt/storage/scratch/tmp/g49X; MOSAIC_REQUIRE_EDITOR_BUILD 1
▲ [WARNING] Ignoring this import because "third_party/editor/prosemirror-transform/src/mark.ts" was marked as having no side effects [ignored-bare-import]
third_party/editor/prosemirror-transform/src/index.ts:10:7:
10 │ import "./mark"
╵ ~~~~~~~~
"sideEffects" is false in the enclosing "package.json" file:
third_party/editor/prosemirror-transform/package.json:13:2:
13 │ "sideEffects": false,
╵ ~~~~~~~~~~~~~
editor.js bd167899a551c70dc07e500fcdd1853d7fb710b23c217ba5b42daf5785c38a72 (133 inputs)
editor.css 8654ac57d1e7a561110138ecd7f701c34a074017ff8c4cd2768d8083908ee61f
@@ -1,10 +0,0 @@
# grant sage-4286; nice 19; TMPDIR /mnt/storage/scratch/tmp/g49X; MOSAIC_REQUIRE_EDITOR_BUILD 1
✔ wikilinks are inline atoms and survive a neighbouring edit in every required context (782.721982ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 940.955124
@@ -1,6 +0,0 @@
{
"root": "/home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2",
"tmp": "/mnt/storage/scratch/tmp/g49X",
"grant": "sage-4286",
"candidate": "2ec3f5b5ddab0cb5f2716f8164c25861b741213e"
}
@@ -1,4 +0,0 @@
{
"grep": "^# skip plus # SKIP and spec skipped-test lines",
"names": []
}
@@ -1,19 +0,0 @@
import os,sys,subprocess,pathlib,json,time,datetime,shlex,re
out=pathlib.Path(__file__).parent;s=json.loads((out/'settings.json').read_text());name=sys.argv[1];cwd=sys.argv[2];cap=int(sys.argv[3]);argv=sys.argv[4:]
left=datetime.datetime(2026,10,24,22,40,tzinfo=datetime.timezone.utc).timestamp()-time.time()-20
if left<15:raise SystemExit('hard-stop cleanup margin reached')
limit=min(cap,int(left));env=os.environ.copy();env.pop('NODE_TEST_CONTEXT',None)
env.update(TMPDIR=s['tmp'],TMP=s['tmp'],TEMP=s['tmp'],MOSAIC_REQUIRE_EDITOR_BUILD='1',MOSAIC_GATE_LANE='b',MOSAIC_GATE_STATE='/mnt/storage/scratch/mosaic-gate-lanes',NO_COLOR='1',MOSAIC_CONFIG=s['tmp']+'/missing-config.json',XDG_CACHE_HOME=s['tmp']+'/cache',JITI_FS_CACHE=s['tmp']+'/jiti')
header='' if name=='measurement' else 'printf "# grant sage-4286; nice %s; TMPDIR %s; MOSAIC_REQUIRE_EDITOR_BUILD %s\\n" "$(nice)" "$TMPDIR" "$MOSAIC_REQUIRE_EDITOR_BUILD"; '
inner=header+'exec "$@"'
log=out/(name+'.log');command='nice -n "$((19 - $(nice)))" timeout -k 10 '+str(limit)+' bash -c '+shlex.quote(inner)+' gate-run '+shlex.join(argv)+' 2>&1 | tee '+shlex.quote(str(log))
begin=time.time();print('START',name,datetime.datetime.now(datetime.timezone.utc).isoformat(),flush=True)
r=subprocess.run(['bash','-o','pipefail','-c',command],cwd=cwd,env=env,stdout=subprocess.DEVNULL)
text=log.read_text();counts={}
for key in ['tests','pass','fail','cancelled','skipped']:
hits=re.findall(r'^[ℹ#] '+key+r' (\d+)',text,re.M)
if hits:counts[key]=int(hits[-1])
record={'name':name,'argv':argv,'cwd':cwd,'rc':r.returncode,'wallSeconds':round(time.time()-begin,3),'counts':counts,'nice':19,'tmpRoot':s['tmp'],'requireEditorBuild':'1','timeoutSeconds':limit,'ended':datetime.datetime.now(datetime.timezone.utc).isoformat()}
with (out/'results.jsonl').open('a') as f:f.write(json.dumps(record)+'\n')
print(json.dumps(record),flush=True);print('\n'.join(text.splitlines()[-20:]),flush=True)
raise SystemExit(r.returncode)
@@ -1,489 +0,0 @@
# grant sage-4286; nice 19; TMPDIR /mnt/storage/scratch/tmp/g49X; MOSAIC_REQUIRE_EDITOR_BUILD 1
✔ row 194 T16 Settings Agents: unavailable read-only, launch follows its request, confirms, reason and refusal (17431.415001ms)
✔ row 256 T7 T8 the human page offers Delete on a creative instance, the tester page never does (1196.894618ms)
✔ row 194 T16: each agent action makes one call with only its keys, logs the user and answers the request (25.920868ms)
✔ row 194 T16: the boundary refuses before any bus call (13.773722ms)
✔ row 194 T16: read-only when send is off or no writer is wired; no route has a --send exception (6.006105ms)
✔ row 194 T16: a bus refusal names its reason and nothing changed; an unknown outcome or a bad shape is never retried (4.80649ms)
✔ row 194 T16: the request read takes one id, adds the refusal reason and refuses an unexpected shape (9.010999ms)
✔ row 194 T16: delete joins add and change on /api/instances with only revision, instance and op (4.535523ms)
✔ row 194 T16 D4.1: the tester listener launches, terminates and reads requests; regenerate, veto and delete are refused with no call (6.985924ms)
✔ row 194 N5: agents.js is served as JavaScript and index.html loads it once (2.138169ms)
✔ row 239 T6: a delete closed -revoked says the tokens may already be revoked, and outcome-unknown-revoked is an open outcome (3.807253ms)
✔ Q169 the signed-in human names a seat and stops the assistant, one bus call each, logged with the user (20.467126ms)
✔ Q169 the name and stop routes need send on, the public name, the user, the exact Origin and same-origin Fetch Metadata (10.75691ms)
✔ Q169 the name and stop routes take JSON within 4 KiB and only their own keys, all text (7.456ms)
✔ Row 167 a null body on the name and stop routes is 400 invalid-request with no write, and the next request is answered (4.218653ms)
✔ Q169 a bus refusal says nothing changed; a lost or odd answer says the change may have been made (6.353476ms)
✔ Q169 the names read is a bus read like the others (1.642206ms)
✔ the Board: held roles with no machine sessions, a claim with no address, the toggle kept across a reload, and Message addressed to the role (6930.836448ms)
✔ a Seen click while a Board read is in flight waits for that read, then posts (1178.550164ms)
✔ a redirect answer on a Board read, a bus read, a conversation read, a board POST, a module read and a send signs the page out: one line, nothing stale, no further reads (5341.444261ms)
✔ browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback (1618.401633ms)
Rendered contrast: {"failures":[],"count":330,"lowest":4.504658476260286}
✔ served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures (1723.682265ms)
✔ the README says a refused field table names the file or the key at fault, never a path (6.362709ms)
✔ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes (3554.138366ms)
✔ bus routes serve the four reads from the reader view, unescaped JSON for the page to escape (124.549917ms)
✔ decisions route serves the roles and recent lists from the reader view (46.444658ms)
✔ credentials route serves the broker credential status from the reader view (19.448846ms)
✔ replies route serves the replies to one message from the reader view (45.806351ms)
✔ bus routes are GET-only, have no write verb, and keep the same-origin checks (55.698955ms)
✔ bus refusals map to statuses and never carry a path or a stack (27.537764ms)
✔ humanCall speaks the human transport protocol without blocking the server (1707.350793ms)
✔ busReader takes --business, else the live bus host, else refuses (257.530467ms)
✔ humanCall kills a transport that runs past its timeout (529.025642ms)
✔ serve refuses a --business value that is not a business id (463.414608ms)
✔ WebUI channel off returns 503 and named recovery for every chat route without a chat operation (38.716248ms)
✔ WebUI only selection keeps web chat enabled while retaining the old read envelope (2.635502ms)
✔ WebUI channel-disabled recovery text is fixed and never echoes transport text (1.545115ms)
✔ chat browser: list/create named projects and threads, hostile labels inert and no implicit writes (836.234777ms)
✔ chat browser: thread drafts, receipts and cursor history stay isolated; refresh never writes (1150.355116ms)
✔ chat browser: uncertain writes retain draft, ownership refusal clears it, read-only cannot send (1316.24955ms)
✔ chat browser: a late read from A cannot paint B; sign-out clears private state and stops reads (1284.31592ms)
✔ chat browser: page forward and deduplicate replies; pending and byte-limit submissions do not duplicate (953.60414ms)
✔ chat browser: scrolled history stays put, New messages and an own send return to the tail (923.155974ms)
✔ chat browser: automatic polling follows a reply without a POST or a lost draft (10953.35212ms)
✔ chat browser: display stays bounded while every forward page remains reachable (1095.68322ms)
✔ chat browser: a confirmed visible message replaces its pending receipt; malformed receipts stay unknown (963.350843ms)
✔ chat browser: a refusal elsewhere forgets private chat drafts (882.872916ms)
✔ chat browser: a late project creation receipt cannot navigate away from another thread (1050.661257ms)
✔ C2-09a picker: no silent choice, duplicate hostile labels stay inert, selection survives refresh and reaches POST (1062.688519ms)
✔ C2-09a picker: metadata failure, empty set and removed pick explain disabled state and recover without switching (924.988913ms)
✔ C2-09a picker: a single instance is chosen explicitly but read-only mode cannot create a thread (781.216726ms)
✔ 264e chooser: two sessions keep drafts, cursors, deep links and counts independent (921.256121ms)
✔ 264e clear confirmation and status never resend, force-stop or change selection (1103.840292ms)
✔ 264e new session keeps its operation key after an unknown response and uses no implicit open (1124.509192ms)
✔ 264e cancel shows before/after retirement and never starts or replays work (1599.216909ms)
✔ 272 B1: a bad-session deep link preserves the draft and lost clear operation on A (1281.091753ms)
✔ C2-09a metadata uses the human listener bus, projects labels only and refuses unauthenticated reads (23.460794ms)
✔ C2-09a metadata uses the private tester listener own bus and no human transport (5.122028ms)
✔ C2-09a metadata refuses missing or malformed reads and never echoes untrusted fields (5.504868ms)
✔ C2-09a metadata stays behind the SSO session check and a GET never refreshes session activity (4.184632ms)
✔ chat replies: free text, cites with badges, the agent label, changed since and safe links (898.153564ms)
✔ chat replies: a write notice reads as its line with a Revert; a refused revert shows its code (1170.744713ms)
✔ chat replies: a read-only Console shows the notice with Revert disabled (850.32112ms)
✔ chat status line: served commit and age, divergence, fetch error and the problems one click away (897.997997ms)
✔ chat status line: limits when the status carries them, no problems, and refusals (1133.380317ms)
✔ chat status line: a project with no repository link reads no library status (854.88335ms)
✔ chat status line: unreadable reply limits show their problem and both fallback keys (882.924328ms)
✔ chat replies: unchanged file at a different library commit and incomplete checks stay unmarked (768.120005ms)
✔ chat status line: an invalid library mapping remains visible for diagnosis (737.619858ms)
✔ chat replies: a checked notice-shaped agent message is plain text with no Revert (725.56059ms)
✔ chat replies: owner-only without a sent field is a definite Revert refusal (811.129366ms)
✔ 272 fallback: granted legacy route keeps default history, send and open without session controls (715.832685ms)
✔ 272 fallback guard: granted explicit-session route refuses without default history or writes (631.667619ms)
✔ 272 fallback guard: human-required catalog failure never enables default-thread access (663.54337ms)
✔ 272 fallback guard: timeout catalog failure never enables default-thread access (537.528495ms)
✔ 272 fallback guard: no-bus-host catalog failure never enables default-thread access (622.030953ms)
✔ 272 fallback guard: malformed catalog failure never enables default-thread access (624.379398ms)
✔ 272 fallback guard: owner route retains chooser and explicit default-session addressing (635.748276ms)
✔ chat reads map only fixed verbs, strict forward paging and pure GET (40.110234ms)
✔ chat refuses malformed and repeated query/id values before transport (7.000459ms)
✔ chat project.create: one verified write, fixed identity/class and private log (2.986922ms)
✔ chat project.create: each existing Console write fence makes zero bus calls (8.933369ms)
✔ chat project.create: refusal or lost/malformed receipt never retries or invents success (5.056788ms)
✔ chat thread.create: one verified write, fixed identity/class and private log (1.581993ms)
✔ chat thread.create: each existing Console write fence makes zero bus calls (8.197701ms)
✔ chat thread.create: refusal or lost/malformed receipt never retries or invents success (3.965463ms)
✔ chat thread.send: one verified write, fixed identity/class and private log (1.353806ms)
✔ chat thread.send: each existing Console write fence makes zero bus calls (6.740756ms)
✔ chat thread.send: refusal or lost/malformed receipt never retries or invents success (4.970022ms)
✔ chat thread.open: one verified write, fixed identity/class and private log (1.438093ms)
✔ chat thread.open: each existing Console write fence makes zero bus calls (6.978012ms)
✔ chat thread.open: refusal or lost/malformed receipt never retries or invents success (3.64445ms)
✔ chat createThread receipt requires an instance even when its id is valid (2.535217ms)
✔ chat write receipts expose only the fields belonging to their operation (3.886809ms)
✔ chat validates names, body, project and strict empty open payload (5.948932ms)
✔ chat read failures redact ownership and refuse invalid transport arrays (7.572799ms)
✔ REQ-USER-2 real broker: owned catalog, create/name/filter, isolated history and 403/404 without mutations (108.639676ms)
✔ chat repeats every POST boundary even when called behind an admitted request (1.210711ms)
✔ configured Console process injects chat through its existing human transport (201.842342ms)
✔ chat assets, GET purity, method and unknown-route refusals are explicit (15.908528ms)
✔ the chat seam names the transport it is given: the human one and the tester one both pass, anything else is a TypeError (0.594861ms)
✔ C2-09a createThread forwards an explicit instance through the existing channel (4.877049ms)
✔ C2-09a malformed instance and extra thread fields refuse before the channel write (4.621594ms)
✔ 264e session catalog/create and explicit selectors keep the human transport and strict keys (6.446ms)
✔ 264e session writes retain Origin/identity fences and explicit selector errors (3.221617ms)
✔ 264e clear envelope is projected, pending is preserved and GET never repeats the clear (5.920053ms)
✔ 264e cancel is an authenticated, confirmed human write and retains retirement distinction (7.831208ms)
✔ 272 N1: malformed clear/cancel write receipts get one redacted outcome-unknown audit line (5.831884ms)
✔ conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers (1306.1032ms)
✔ conversation view: a fork keeps the open branch, says so, and opens the new one on request (1836.119679ms)
✔ conversation view: a newer session with no readable history keeps the marker (1285.484193ms)
✔ conversation view: seats without history say so and offer no reply (1047.035848ms)
✔ J16: no controls without sso and decide, read-only reasons, terminal lines kept, and only an eligible grant gets the panel (5771.215266ms)
✔ J17: one POST per click, confirm then answer, cancel, refusals and an unknown outcome never retry (5272.178631ms)
✔ a sign-in redirect at either step signs the page out and answers nothing (1989.670657ms)
✔ a signed-in owner confirms and answers, with the session's provider, issuer and sub as the subject (213.942672ms)
✔ the listener gates: no session, a cross-origin page, a loopback Host, a GET, and a sign-out between the steps (77.826712ms)
✔ J16: without a decision transport the routes are read-only and /api/config says no; startServer refuses decide without sso or send (481.428976ms)
✔ J19: a granted user's session gets decide false and the user page config, and a decision POST stops at owner-only (54.985489ms)
✔ N4: decision.resolve has no Console route, the --send caller names no decision verb, and consoleCall takes only the two console verbs (80.240021ms)
✔ a confirmation then a resolve answers once with the held choice and digest, and a second resolve refuses (3.814984ms)
✔ deny resolves without a grant field (0.482281ms)
✔ the gates before the body: method, read-only, sign-in and same origin (0.49765ms)
✔ a bad body, id or content type refuses before the bus (0.762162ms)
✔ a choice that is neither key is decision-stale, and a malformed preview is confirmation-unavailable (0.681931ms)
✔ bus refusals map by code, and an unknown or missing code is unavailable at preview and outcome-unknown at resolve (2.862415ms)
✔ a confirmation binds the session, decision and choice, and any mismatch spends it (0.81855ms)
✔ a session that ends between the steps refuses before the bus (0.438239ms)
✔ confirmations expire after the TTL, cap at the limit without dropping a live one, and are one use (0.323839ms)
✔ Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content (1065.529279ms)
✔ the editor: the list, hostile markdown that runs nothing, the draft, and each save receipt (2140.805191ms)
✔ the Console's CSP stands and the editor runs under it; hostile markdown on the load path runs and loads nothing (1354.955091ms)
✔ hostile content pasted into the editor runs and loads nothing, and a bad link target is refused in the link bar (1547.923208ms)
✔ a pasted image span is kept only for a target the link gate accepts, and a save writes only those (1266.979424ms)
✔ a switch of view reads nothing; a reload, and a trip away and back, each read the file exactly once (2940.297024ms)
✔ an edit rewrites only the block touched; a save sends the rest byte for byte; the Source view carries the draft (1214.212642ms)
✔ a file the editor cannot write back byte for byte opens as Source, says so, cannot save, and the Visual button works once it can (2204.177601ms)
✔ a link title pasted with line breaks in it is written on one line, so no HTML block comes of it (954.989036ms)
✔ an image alt pasted with line breaks in it is written on one line, so no HTML block comes of it (876.671026ms)
✔ an image title pasted with line breaks in it is written on one line, so no HTML block comes of it (876.342459ms)
✔ a code block info string pasted with line breaks in it is written on one line, so no HTML block comes of it (851.548483ms)
✔ a link title holding a quote after a backslash then a tag is written with the backslash escaped, so the title stays closed (973.526659ms)
✔ an image title holding a quote after a backslash then a tag is written with the backslash escaped, so the title stays closed (730.164235ms)
✔ a link title holding a trailing backslash is written with the backslash escaped, so the title stays closed (705.431094ms)
✔ an image title holding a trailing backslash is written with the backslash escaped, so the title stays closed (716.592245ms)
✔ a code block info string with a backtick, pasted, is dropped on Save, so the fence stays closed (764.08523ms)
✔ a code block info string with a backtick, as a tilde fence can have, is kept as source, not rewritten without it (846.538059ms)
✔ the imported source is the tagged upstream source plus the recorded local changes, and nothing else (26.175796ms)
✔ every package names its origin and license, and NOTICE lists each (1.097644ms)
✔ the build reads only imported source and the editor's own files; the bundle is the recorded build (8.957963ms)
✔ MOSAIC_REQUIRE_EDITOR_BUILD: a missing or wrong esbuild skips without it and fails with it; a wrong binary always fails (7.544758ms)
✔ the toolchain check, run as a program, exits non-zero without esbuild when the build is required (302.664165ms)
▲ [WARNING] Ignoring this import because "third_party/editor/prosemirror-transform/src/mark.ts" was marked as having no side effects [ignored-bare-import]
third_party/editor/prosemirror-transform/src/index.ts:10:7:
10 │ import "./mark"
╵ ~~~~~~~~
"sideEffects" is false in the enclosing "package.json" file:
third_party/editor/prosemirror-transform/package.json:13:2:
13 │ "sideEffects": false,
╵ ~~~~~~~~~~~~~
✔ rebuilding from the source gives the same bytes (needs the pinned esbuild; MOSAIC_REQUIRE_EDITOR_BUILD=1 makes its absence a failure) (172.641917ms)
✔ wikilink round-trip: plain (9.175577ms)
✔ wikilink round-trip: labelled (1.129295ms)
✔ wikilink round-trip: heading target (1.25613ms)
✔ wikilink round-trip: list item (1.645832ms)
✔ wikilink round-trip: next to front matter (1.202128ms)
✔ wikilink round-trip: heading block and combined label (1.18696ms)
✔ wikilink round-trip: spaces, Unicode and adjacent links (2.377579ms)
✔ wikilink round-trip: reference-definition collision (2.902031ms)
✔ wikilink round-trip: inside an ordinary link (4.241793ms)
✔ wikilink round-trip: inert embed (0.63341ms)
✔ wikilinks are inline atoms and survive a neighbouring edit in every required context (1556.425084ms)
✔ code, escaped openers, malformed syntax and HTML-like targets stay outside wikilink atoms (1452.825353ms)
✔ wikilink DOM paste accepts only bounded single-line inert syntax; malformed attrs serialise as escaped text (1000.711061ms)
✔ the editor routes serve the built files with their types and no cache (46.893995ms)
✔ open and save with no edit writes the same bytes: the goals review and a doc with every element (21.572081ms)
✔ open and save with no edit writes the same bytes: every repository doc under docs/ and spec/ (2160.196477ms)
✔ the editor source builds markup only with DOM calls, and the bundle has no eval (4.311024ms)
✔ the link gate and the image rule are the preview's (1.175245ms)
✔ R6 G1 a granted user signs in, gets the user page and reaches the broker as itself (172.773172ms)
▶ R6 G2 a subject with no live grant in this business is refused subject-unknown
✔ a listed user with no grant (92.602053ms)
✔ a grant in another business (104.491345ms)
✔ an expired grant (114.256083ms)
✔ another user's grant (55.98006ms)
✔ a sub in no file (64.883816ms)
✔ a file the loader refuses (90.210241ms)
✔ R6 G2 a subject with no live grant in this business is refused subject-unknown (523.697455ms)
▶ R6 G3 a revoke counts on the next request: 401 and the session is gone
✔ grant removed (62.972504ms)
✔ subject removed (93.888865ms)
✔ grant expired (106.472694ms)
✔ file now refused (112.582013ms)
✔ R6 G3 a revoke counts on the next request: 401 and the session is gone (377.178827ms)
✔ R6 G4 every owner route answers 403 owner-only before any bus call (127.865636ms)
✔ R6 G5 the bus refuses every owner verb over the user transport on its own (123.119351ms)
✔ R6 G9 a user chat sends only the plugins read over the owner transport (130.557956ms)
✔ R6 G6 the user page holds the notice word for word (75.808533ms)
✔ R6 G7 the hub label and plain peer links render from config on both pages (984.121102ms)
✔ R6 G8 userCall spawns user-cli directly with the user nonce, no human nonce, and the user request (51.702992ms)
✔ return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch (52842.755773ms)
✔ row 165 Settings: role instances read, add with a restart named, change without one, a refusal keeps the draft (2069.883646ms)
✔ row 165 Console: the signed-in human adds an instance through a real broker; validate exits 0, the log names the human and no restart is needed (row 194) (261.303304ms)
✔ row 165 Console: the instance write needs send on, the public name, the user, the exact Origin and same-origin Fetch Metadata (10.054646ms)
✔ row 165 Console: the instance write takes JSON within 16 KiB and only its own keys; holder, credentials and an actor are refused (9.747938ms)
✔ row 165 Console: a bus refusal says nothing changed; a lost or odd answer says the change may have been made (15.879235ms)
✔ row 165 Console: the instances read takes no arguments, needs a bus and refuses an answer without a revision (6.37516ms)
✔ C2-04 CLI: both listener callback pairs dispatch library verbs with their own transport (208.823243ms)
✔ a signed-in same-origin revert reaches exactly one library.revert and returns the new commit (25.090439ms)
✔ a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata (8.67138ms)
✔ a revert takes a project and a 40-hex commit, nothing else, before the bus is asked (7.475126ms)
✔ a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal (9.725112ms)
✔ a revert whose answer is lost or unusable is reported unknown and is never retried (7.701918ms)
✔ the status read takes one project query and answers the bus result for that project (4.432751ms)
✔ a status answer for another project or of the wrong shape is refused; a refusal keeps its status (9.479692ms)
✔ the status read needs no send; a read-only Console still has no revert (2.316938ms)
✔ library routes are the owner's: a granted user is refused them, and the tester never reverts (0.874467ms)
✔ the page gets the one library renderer, byte for byte, as a JavaScript module (1.946831ms)
✔ a revert receipt for another project is unknown and is never retried (1.698516ms)
✔ a granted-user HTTP session cannot reach owner library callbacks (2.11729ms)
✔ a configured revert callback remains unreachable when send is off (1.619535ms)
✔ row 254: each library sync code maps to its status (0.875525ms)
✔ REQ-LIMIT-3 REQ-LIMIT-4 Settings shows token limits counted, not enforced and check failed; Reset posts one path; a save keeps the token limits (1283.613274ms)
✔ Row 167 with an assistant draft open, a token limit reset gives the assistant submit its revision (951.959609ms)
✔ REQ-LIMIT-3 limits read returns only the token rows; no query or write is accepted (36.003397ms)
✔ REQ-LIMIT-3 a limits answer without a token list is refused, not shown as no limits (11.374745ms)
2026-10-24T22:11:16.186Z settings user="jason" changed
✔ REQ-LIMIT-3 a Settings reset reaches the human write with its paths; a reset alone is enough (7.465903ms)
✔ REQ-LIMIT-3 the Console reader asks the bus limits verb with no arguments (0.460687ms)
✔ the preview source names none of the four string-to-markup sinks (1.2239ms)
✔ headings, paragraphs, emphasis and inline code render (1.52146ms)
✔ lists, block quotes and code blocks render (0.569678ms)
✔ a link is made for http, https and relative targets, with rel and the target shown (1.167267ms)
✔ a link with any other scheme, or an evasion of the scheme, stays source text (1.071465ms)
✔ an image shows alt text and URL, is never an img element and never loads (0.176798ms)
✔ raw HTML, entities and unsupported blocks show as source text, never as elements (0.390592ms)
✔ markup inside code and inside link text stays text (0.199256ms)
✔ hostile and pathological input stays within its look-ahead budget and under the nesting cap (48.760884ms)
✔ trailing blanks and closing hashes are cut by a walk from the end, not a regex that retries inside a blank run (35.531804ms)
✔ a link opener with no closer searches at most SPAN characters ahead (188.875275ms)
✔ the look-ahead work of one render is a fixed multiple of the text, whatever the opener (848.275978ms)
✔ past the budget an opener is source text, through the path all other text takes (17.235886ms)
✔ a normal 200 KiB document renders exactly as it did without a budget, and spends little of it (25.044171ms)
✔ a line separator inside a heading, a list item or a fence line does not end the marker line (0.595681ms)
✔ a thematic-break line is a paragraph, every other marker line opens a list (0.433323ms)
✔ every short line over space, tab, - * _ and a letter is a break exactly when the old regex said so (48.393695ms)
✔ one line of marks does not throw, at 4 MiB, whatever the mark or the spacing (7867.726504ms)
✔ CRLF and tabs are read like LF and spaces; empty input renders nothing (0.182135ms)
✔ messages HTTP read pages and preserves metadata; bad arguments refuse before a read; other reads unchanged (114.721485ms)
✔ Console Messages lists late unsolicited messages on polling with sender class time read state and trail; nav count includes beyond the page; reads never deliver (1227.204744ms)
✔ Messages nav count polls on Tasks and Board and clears on a Board refusal; messages 403 refuses local pages (2003.078514ms)
✔ copy writes the Console paths at the full id, read-only, and leaves an existing copy alone (1488.436066ms)
✔ copy refuses a commit off refactor, an unknown one, a worktree and a bad copies directory (1597.975802ms)
✔ copy writes nothing when an archived path is missing at the commit (579.510216ms)
✔ a copy that loses the rename race leaves no read-only temp directory behind (673.135629ms)
✔ a copy whose rename fails with no target refuses, writes no copy and leaves no temp directory (710.289827ms)
✔ a copy that meets a target holding files says exists: and moves nothing into it (732.806597ms)
✔ a copy that meets an empty target directory replaces it by rename, not by moving into it (612.835517ms)
✔ serve runs from a copy: /healthz 200, the config check, roles and RELEASE from the copy, the queue from --checkout (1149.224132ms)
✔ serve refuses a --checkout that is not an absolute directory path (375.216061ms)
✔ consoleReads runs the queue read under queueRoot and reads RELEASE under root (69.061909ms)
✔ preview options are all three or none, and each is checked (11.596651ms)
✔ the CLI refuses a partial or invalid preview flag set at start (945.562554ms)
✔ the CLI with all three flags serves the preview mode (193.560054ms)
✔ a request for the public name needs the header equal to the user id, else 401 and nothing read (21.565508ms)
✔ the header name matches without regard to case, and the value compare is exact (3.95808ms)
✔ the public name with the header reads the bus views, and the Host and Origin are checked first (33.216972ms)
✔ in preview mode every write answers 403 read-only, from the public name and loopback, and reaches nothing (13.946391ms)
✔ loopback requests are unchanged in preview mode, and the bus views work with the board down (21.317768ms)
✔ a proxy that rewrites Host to the loopback port gets 403 on its first request, whatever the user (20.422653ms)
✔ a repeated Host is 400 before any other check, in preview mode and without it (11.215391ms)
✔ without the preview flags the public name is refused and writes still reach the board (5.587922ms)
✔ processing browser: keyboard Stop, confirmed Resume, unknown outcome and persistent banner (718.01676ms)
✔ processing browser: Q175 read-only interim keeps both writes disabled and names CLI recovery at 320px (695.148762ms)
✔ processing browser: sign-out from config redirect blocks later refresh and writes (824.165228ms)
✔ processing browser: sign-out from state redirect blocks later refresh and writes (860.253673ms)
✔ processing browser: sign-out from write redirect blocks later refresh and writes (806.441355ms)
✔ processing browser: sign-out from config sign-in required blocks later refresh and writes (864.004948ms)
✔ processing browser: sign-out from state sign-in required blocks later refresh and writes (886.919472ms)
✔ processing browser: sign-out from write sign-in required blocks later refresh and writes (960.067268ms)
ROW191_LAYOUT {"width":400,"height":1000,"controls":97,"tree":324.0625,"main":1450.703125,"top":181.5,"bottom":217.5,"barBottom":129,"projectsTop":352.59375,"order":"0","visible":true}
ROW191_LAYOUT {"width":1440,"height":1000,"controls":127,"tree":914,"main":914,"top":135.5,"bottom":171.5,"barBottom":53,"projectsTop":636.59375,"order":"0","visible":true}
ROW191_LAYOUT {"width":1440,"height":420,"controls":127,"tree":334,"main":907.734375,"top":135.5,"bottom":171.5,"barBottom":53,"projectsTop":636.59375,"order":"0","visible":true}
ROW191_LAYOUT {"width":800,"height":420,"controls":127,"tree":334,"main":1131.65625,"top":135.5,"bottom":171.5,"barBottom":53,"projectsTop":618.59375,"order":"0","visible":true}
✔ processing browser: static nav controls keep Stop visible, ordered and keyboard reachable (764.42746ms)
✔ interim read-only controls show CLI recovery, disable both writes and retain a halted banner across views (8.191348ms)
✔ Stop sends one empty halt and Resume confirms before sending the displayed generation (2.947603ms)
✔ a cancelled resume never calls the writer (1.219134ms)
✔ unknown writes are not retried or displayed as success and partial stops remain explicit (2.112188ms)
✔ the persistent shell loads the control outside route-specific content with accessible status and native buttons (0.471441ms)
✔ read-only Console can read processing state but neither human control writes without --send (23.636966ms)
✔ Console halt and generation-bound resume use only the management writer and keep partial stops honest (4.923203ms)
✔ processing write fences refuse cross-site, wrong origin, unnamed host, duplicate identity and bad payload before writer (7.499038ms)
✔ tester cannot invoke either control writer and failures never echo backend text (5.000005ms)
✔ processing writes use the existing SSO signer and still require --send and same-origin (5.29471ms)
✔ processing handler independently refuses wrong origin and cross-site before its writer (0.741847ms)
✔ a signed-in same-origin save reaches exactly one human operation and returns the new commit (22.355345ms)
✔ a save needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata (8.492884ms)
✔ a save validates JSON, strict keys, text values and the 1 MiB body before the bus is asked (19.453599ms)
✔ a body that is JSON but not an object is refused 400, not thrown on (5.777613ms)
✔ the 1 MiB limit is this route only; Settings and messages keep 16 KiB (2.581226ms)
✔ a bus refusal is reported as not saved with its own status; stale-base tells the user to reload (6.148622ms)
✔ a save whose answer is lost or unusable is reported unknown and is never retried (3.873987ms)
✔ the file list and one file read take strict queries and arrive in the expected shape (4.947705ms)
✔ reads work without --send; an unusable bus answer is a bad response, a refusal keeps its status (3.061043ms)
✔ /api/config says edit only where a save can pass (2.093648ms)
✔ a repository-name project refusal is an HTTP conflict (0.589542ms)
✔ proof startup refuses partial flags non-preview and invalid fixture file metadata or bytes (1372.358151ms)
✔ serve argv carries only fixture proof path and header; startup explicitly reports proof on or off (296.532456ms)
✔ CLI applies proof on the main listener and rejects that header on its tester listener (281.757503ms)
✔ proxy proof admits named reads and sends, reads its fixture once, and never exposes the value (34.129527ms)
✔ proxy proof refuses missing wrong short or repeated proof before auth and before any read (10.977149ms)
✔ proxy proof header is refused on other Hosts and on the tester listener (5.374102ms)
✔ proof options reject unsafe or colliding header names without disclosing file contents (0.797857ms)
✔ redactor: config, dataRoot, any absolute path, Discord ids and the JSON parser quote (1.703483ms)
✔ credential state from the date alone, as the bus states it (0.208011ms)
✔ queue: rows, one row, ids and methods, notes; no path or id from a row (414.58832ms)
✔ queue: a refused read fails closed with the store's text, redacted (210.374206ms)
✔ queue: a store that fails to load, or a child that dies, gives a fixed message with no path or stack (858.43953ms)
✔ business: names, vars on the allowlist, authority, credential metadata only (17.500331ms)
✔ business: missing or invalid file refuses with the module's text, redacted (8.383354ms)
✔ business without --business: the live bus host, else no-bus-host; no system config: not-configured (8.244873ms)
✔ settings: release, board, loopback address, notifier binding name only (129.222379ms)
✔ task fields: the committed table with no reads configured, GET only; a bad table refuses with no path (18.325509ms)
✔ bus-status: a refused boot shows reason, file and last good load; a live host or an ok boot clears it (8.620563ms)
✔ both presentations replace old activity with relaunch notice, label retained history, then resume after new activity (912.269858ms)
✔ reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh (20871.244615ms)
organization: {"event":"reload-needs-restart","business":"acme"}
organization: {"event":"reload-needs-restart","business":"acme"}
✔ row 65 Settings: the routing table lists every instance, saves a harness, model, thinking and fallback to the file, and keeps the draft on each refusal (1456.057342ms)
organization: {"event":"reload-needs-restart","business":"acme"}
✔ row 65 Console: the signed-in human saves a route through a real broker; each refusal says nothing changed and leaves the file and the log (124.733405ms)
✔ row 65 Console: the routes write needs send on, a writer, the public name, the user, the exact Origin and same-origin Fetch Metadata (7.984846ms)
✔ row 65 Console: the routes write takes JSON within 16 KiB with a revision and routes only; an actor is refused and routes are never logged (9.931147ms)
✔ row 65 Console: a bus refusal maps to its status and says nothing changed; a lost or odd answer says the change may have been made (24.176558ms)
✔ row 65 Console: the routes read takes no arguments, needs a bus and refuses an odd answer (7.91025ms)
✔ the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx (2577.785116ms)
✔ a sent receipt lists its replies as the page refreshes, oldest first and capped, and marks none delivered or read (11216.600397ms)
✔ startServer takes send only as a function and only with the preview mode (8.305533ms)
✔ the CLI refuses --send without the preview flags, and without a configured bus (622.972543ms)
✔ the CLI with --send and a configured bus sends through the human transport and logs one line (188.71069ms)
✔ without --send the route is read-only like every other write, and config says no send (10.412469ms)
✔ with --send every other write is still 403 read-only, and nothing reaches the board or bus (8.881331ms)
✔ config says send only on the public name with --send on (4.624696ms)
✔ a send without the signed-in user is 401 and is neither sent nor logged, loopback included (6.736458ms)
✔ each request check refuses before anything is sent, and logs the refusal without the body (23.776724ms)
✔ a send passes exactly to, body and in_reply_to, answers the id, and logs it (3.435425ms)
✔ a crafted role name cannot forge a log line (2.520457ms)
✔ a bus refusal is not sent, with its status; an outcome the bus could not confirm is unknown and never retried (8.782419ms)
✔ through the human transport the request is exactly the business, message.send and the keys (100.281561ms)
✔ against a real broker: the message is written once as a human send, and its refusals map (31.579317ms)
✔ loopback host and board origin fail closed (6.349957ms)
✔ real board fixture passes through WebUI; assets and isolated seen/reply work (85.238919ms)
✔ proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect (49.199627ms)
✔ unreachable board reports URL; CLI rejects unsupported options (479.978274ms)
✔ tester service render carries the tester port alongside existing flags (134.959305ms)
✔ tester service refuses forwarded 7332 even with another main port (111.314937ms)
✔ tester service invalid ports refuse render and install with exit 4 and leave the unit alone (1140.951065ms)
✔ tester service counts the digits as written before it strips leading zeros (106.037477ms)
✔ tester service install and status show the tester port without changing send reporting (550.730447ms)
✔ render prints the unit without the preview flags (50.043501ms)
✔ render carries every serve flag, the preview set included (48.3057ms)
✔ a partial preview set is refused (116.014202ms)
✔ a value the unit cannot carry is refused, for every flag (2672.644014ms)
✔ the rendered unit names none of the human proof agent markers and sets no agent variable (239.356908ms)
✔ install into --dir with --no-reload writes the file whole, then reports unchanged (287.45407ms)
✔ install without --no-reload runs daemon-reload and nothing that starts the unit (62.23267ms)
✔ uninstall refuses while the unit is running, with or without --no-reload, and removes it once it is not (564.150662ms)
✔ status prints the unit state and GET /healthz on the installed port (418.000891ms)
✔ usage: an unknown verb or flag, and start or enable, exit 4 (748.152129ms)
✔ without --send, render is the template with its placeholders filled, byte for byte (191.946387ms)
✔ render --send with the preview set ends ExecStart with --send and changes nothing else (314.493899ms)
✔ --send without the whole preview set is refused and writes nothing (731.918561ms)
✔ install --send into --dir with --no-reload writes the sending unit, and status says whether it sends (513.043402ms)
✔ uninstall and status take no --send (164.863424ms)
✔ install refuses while the unit is running, in any state but inactive or failed, and changes nothing (697.765957ms)
✔ when the state read fails, install and uninstall refuse with one line and change nothing (238.392922ms)
✔ install needs --copy naming a copy that exists, and refuses outside the canonical checkout without --allow-worktree (355.132277ms)
✔ install refuses unless the bus unit runs from the same copy, naming both paths, and changes nothing (735.389915ms)
✔ render --copy runs the copy and reads the queue in the checkout; without --copy the unit is unchanged (114.218835ms)
✔ the uid header takes only an Authentik uid; the username header still takes a username (2531.254234ms)
✔ a preview value cannot start with - or . (295.955168ms)
✔ status names the copy and whether refactor has moved past it, and says when the unit runs from no copy (877.622422ms)
✔ proxy proof render carries both flags but never opens the fixture file (89.223556ms)
✔ proxy proof render refuses incomplete non-preview missing unsafe-mode and repo/copy paths (565.728452ms)
✔ proxy proof status retains send and tester reporting with both proof flags (170.319152ms)
✔ row196 socket flag renders only for supporting targets and refuses mixed transports (114.439527ms)
✔ Settings form shows every scope and held role; gated save, exact payload, retained draft and no automatic retry (1360.394114ms)
✔ Q169 Assistant section: gated, changed fields only, name and stop posts, retained draft and no automatic retry (1339.787079ms)
✔ Row 167 a refused names read gives the refused usage page; another names failure keeps the Assistant section (1001.13625ms)
✔ Row 167 with a limits draft open, an assistant save gives the limits submit its revision and the typed limit stays (961.46178ms)
✔ Row 167 with an assistant draft open, a launch save gives the assistant submit its revision, and reload reads the current one (919.499255ms)
2026-10-24T22:12:18.574Z settings user="jason" changed
✔ REQ-WEB-1 signed-in same-origin Settings write reaches exactly one human operation (20.814698ms)
✔ REQ-WEB-1 Settings writes require enabled send, named Host, identity, exact Origin and same-origin Fetch Metadata (7.338296ms)
✔ REQ-WEB-1 Settings validates JSON, size, revision and strict writable keys before broker call (5.704514ms)
✔ Q169 a Console change of the assistant reaches the human operation unchanged, and the log names the signed-in user (3.12922ms)
✔ tokens.css and icons.svg are byte copies of docs/design (2.466959ms)
✔ JetBrains Mono files match the digests in their sources file, with the OFL text (2.202837ms)
✔ shipped strings are unchanged, and the web keeps its write boundary (3.483166ms)
ROW191_LAYOUT {"width":1440,"height":1000,"controls":212.5,"tree":914,"main":914}
✔ shell in a browser: System default, seeded states, 400px, focus and the Ctrl+K palette (5724.337234ms)
✔ five states on Board, Inbox, Tasks, Agents and Trail (2781.461551ms)
✔ a 401 sign-in required on each fetch path signs the page out with a sign-in link and stops the refresh; another 401 stays an error (5951.403032ms)
✔ the Sign out form shows only when /api/config says sso, and posts to /auth/logout (1240.276745ms)
✔ positive: an RS256 provider signs in and sends one message, and the session reads (144.348303ms)
✔ positive: an ES256 provider signs in and sends (20.592669ms)
✔ positive: two providers each sign in their own subjects, and one provider's subject is unknown to the other (61.371982ms)
✔ 1: a forged x-authentik header without a cookie is 401 on the API and a sign-in redirect on a page (43.593455ms)
✔ 2: an HS256 ID token keyed with the client secret and a real RSA kid is refused (73.089469ms)
✔ 3: a wrong aud, and an aud list holding another client with no azp, are refused (33.086381ms)
✔ 4: a token that expired 10 minutes ago is refused (63.584637ms)
✔ 5: another issuer, and the issuer without its trailing slash, are refused (48.311745ms)
✔ 6: a nonce mismatch and a missing nonce are refused (27.913813ms)
✔ 7: state mismatch, no login cookie, a replay, an expired record and two login cookies are refused (67.213632ms)
✔ 8: the token request carries the PKCE verifier, and the OP refuses a wrong one (46.450232ms)
✔ 9: alg none, a changed payload under a real kid, and an unknown kid even after the refetch are refused (208.50079ms)
✔ 10: a valid token for a subject not configured is 403 with one subject-unknown line (114.43639ms)
✔ 11: start refusals: HS or none algs, reserved names, HS-only discovery, an empty JWKS, no S256, an issuer mismatch, a short RSA key (276.730279ms)
✔ 11: slot files: a symlink, mode 0644, mode 0640 and a file under the repository are refused, and no message holds the value (2.126877ms)
✔ 11: the CLI refuses --sso with the old flags and without console.sso, and starts with a good one (1317.326217ms)
✔ 12: under --sso the tester listener refuses each kind of sign-in, has no /auth, and startServer refuses tester with sso (61.815926ms)
✔ 13: idle and absolute expiry, logout then reuse, a cross-origin logout, and background reads that don't keep a session (112.338051ms)
✔ 13: the CSP lets the Sign out form reach the provider's end-session origin, and only under --sso (136.264868ms)
✔ 14: open redirects land on /, and a path on the Console lands there (125.077087ms)
✔ 15: a callback for A carrying B's code and iss, and a provider= query, is refused (114.160441ms)
✔ 16: a provider with RS256 and ES256 takes either, and refuses PS256 with a usable PS256 key in the JWKS (111.659664ms)
✔ 17: 33 started logins leave 32 records, and the first one's callback finds none (59.820684ms)
✔ 18: after a key rotation a sign-in works through one refetch, and the removed kid is refused (119.662583ms)
✔ 20 and 21: the store holds no raw session id, and the cookie strings are exact (36.447359ms)
✔ 22: a loopback Host is 403 on a page, a read and a write; /healthz on loopback is 200; a redirect_uri never comes from the Host (64.398495ms)
✔ 23: a provider down at start leaves the Console up and 503, and the retry makes sign-in work (225.671097ms)
✔ 19: no captured log line holds a token, code, state, nonce, verifier, client secret or session id (4.654356ms)
✔ tester Console banner exposes only instance route and name editors and saves a route as tester (1004.885424ms)
✔ row 177: the tester Console chat composer is enabled and a send shows in its own thread (882.644221ms)
✔ tester-port validates range tunnel main port and requires configured bus before listening (1166.339822ms)
✔ CLI tester listener binds a private Unix socket and spawns TESTER_CLI for every Console bus read (4245.099179ms)
✔ main listener refuses x-mosaic-tester before any board or bus read for every Host (28.708091ms)
✔ tester listener rejects preview binding and requires its fixed logical Host and no proxy auth header (145.683675ms)
✔ tester listener writes only instances routes and names with tester receipts and actor (158.995936ms)
✔ tester listener refuses every human-only write with no bus call (108.260486ms)
✔ tester-off and tester-required reads return HTTP 403 (109.020476ms)
✔ tester credentials routes and committed project reads keep secrets and paths bounded (194.378994ms)
✔ row 177 W1: the tester chat makes, sends to, opens and reads its own thread as tester (30.75864ms)
✔ row 177 W2: tester chat writes refuse a missing or foreign Origin, cross-site, text/plain and the proxy header with no bus call (16.544702ms)
✔ row 177 W3: the human thread through the tester chat is thread-not-owned, and bus.tester off answers tester-off (38.09277ms)
✔ row 177 W4: the main listener refuses x-mosaic-tester on chat reads and writes before the chat (3.174145ms)
✔ Row 252: the tester listener has no decision answer routes, and its config never offers them (115.156837ms)
✔ private tester HTTP serves on 0600 socket in 0700 parent and fixed origin (92.027264ms)
✔ private tester refuses symlink, unsafe parent and long path without repairs (1.361981ms)
✔ tester cleanup must leave a replacement inode untouched (1.910765ms)
✔ row278 browser lists held claims and release preserves role rows and totals (847.359938ms)
✔ row278 browser claims 403 stays local and role section remains byte-equal (894.870976ms)
✔ row278 browser renders only four escaped claim fields and never instance or other extras (843.980149ms)
✔ row278 browser sign-out removes claims and prevents reuse of cached answers (893.668522ms)
✔ row278 tester browser keeps role rows without requesting the owner claims section (787.543169ms)
✔ row278 browser discards a late claims answer after sign-out (921.720447ms)
✔ row278 HTTP read returns held claims without send and refuses arguments before the bus (53.230904ms)
✔ row278 HTTP read preserves bus refusal and rejects a malformed result (16.041697ms)
✔ row278 tester socket refuses thread claims explicitly with zero bus calls (7.920469ms)
✔ row278 Console adapter calls argument-free thread.claims through the normal transport (234.138591ms)
✔ 193d HTTP shares use the caller, plain/shared lists, exact args and write fences (101.289213ms)
✔ 193d config supplies only the signed-in user live collaborator scopes and updates on downgrade (42.033352ms)
✔ 193d viewer UI has no composer, resume, share, session mutations or create controls (924.760539ms)
✔ 193d mixed grants keep viewer project read-only and collaborator own thread writable; downgrade hides controls (953.979736ms)
✔ 193d owner has share controls on own threads and shared threads remain read-only (790.551799ms)
✔ 193d bypassed UI checks still reach a refusing bus for every viewer write (788.861611ms)
✔ 193d collaborator shares stay read-only and never expose reshare or wake controls (581.454324ms)
✔ REQ-LIMIT-4 Settings shows all token kinds and unknown turns beside every total, as a read-only separate section (995.953487ms)
✔ REQ-WEB-1 usage read keeps unknown counts and each token kind; no query or write is accepted (45.120159ms)
✔ REQ-WEB-1 malformed usage is refused instead of shown as zero (8.503528ms)
✔ R6-N1 with the userRoute gate open, a user session reaches no owner option over HTTP (253.639899ms)
✔ Queue, Business and Settings in their five states, with no move button (3486.52394ms)
✔ seeded secrets, Discord ids and paths reach no response and no page; business refusals in the module's words (1584.02624ms)
✔ the server log names no config path when the system config refuses (369.222452ms)
ℹ tests 455
ℹ suites 0
ℹ pass 455
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 174122.853398
@@ -1,69 +0,0 @@
tests/agents-browser.test.mjs
tests/agents.test.mjs
tests/assistant.test.mjs
tests/board-held.test.mjs
tests/browser-edge.test.mjs
tests/browser.test.mjs
tests/bus-browser.test.mjs
tests/bus.test.mjs
tests/channel-chat.test.mjs
tests/chat-browser.test.mjs
tests/chat-instances.test.mjs
tests/chat-replies-browser.test.mjs
tests/chat-sessions-access.test.mjs
tests/chat.test.mjs
tests/conversation.test.mjs
tests/decide-browser.test.mjs
tests/decide-sso.test.mjs
tests/decision-http.test.mjs
tests/discord.test.mjs
tests/docs-browser.test.mjs
tests/editor-browser.test.mjs
tests/editor-provenance.test.mjs
tests/editor-wikilink.test.mjs
tests/editor.test.mjs
tests/granted-user.test.mjs
tests/history-return-flow.test.mjs
tests/instances-browser.test.mjs
tests/instances.test.mjs
tests/library-cli.test.mjs
tests/library-routes.test.mjs
tests/library-status.test.mjs
tests/limits-browser.test.mjs
tests/limits-route.test.mjs
tests/markdown-preview.test.mjs
tests/messages.test.mjs
tests/pinned-copy.test.mjs
tests/preview.test.mjs
tests/processing-browser.test.mjs
tests/processing-ui.test.mjs
tests/processing.test.mjs
tests/project-file.test.mjs
tests/project-name-repo.test.mjs
tests/proof-cli.test.mjs
tests/proof.test.mjs
tests/reads.test.mjs
tests/relaunch.test.mjs
tests/return-flow.test.mjs
tests/routes-browser.test.mjs
tests/routes.test.mjs
tests/send-browser.test.mjs
tests/send.test.mjs
tests/serve.test.mjs
tests/service.test.mjs
tests/settings-form.test.mjs
tests/settings-write.test.mjs
tests/shell.test.mjs
tests/sso-browser.test.mjs
tests/sso.test.mjs
tests/tester-browser.test.mjs
tests/tester-cli.test.mjs
tests/tester-listener.test.mjs
tests/tester-socket.test.mjs
tests/thread-claims-browser.test.mjs
tests/thread-claims.test.mjs
tests/thread-sharing.test.mjs
tests/usage-browser.test.mjs
tests/usage.test.mjs
tests/user-option-swap.test.mjs
tests/views.test.mjs
@@ -1,38 +0,0 @@
# grant sage-4286; nice 19; TMPDIR /mnt/storage/scratch/tmp/g49X; MOSAIC_REQUIRE_EDITOR_BUILD 1
✔ row 194 T16 Settings Agents: unavailable read-only, launch follows its request, confirms, reason and refusal (13526.912326ms)
✔ row 256 T7 T8 the human page offers Delete on a creative instance, the tester page never does (917.27163ms)
✔ row 194 T16: each agent action makes one call with only its keys, logs the user and answers the request (26.28546ms)
✔ row 194 T16: the boundary refuses before any bus call (13.102463ms)
✔ row 194 T16: read-only when send is off or no writer is wired; no route has a --send exception (6.394437ms)
✔ row 194 T16: a bus refusal names its reason and nothing changed; an unknown outcome or a bad shape is never retried (4.282632ms)
✔ row 194 T16: the request read takes one id, adds the refusal reason and refuses an unexpected shape (5.922485ms)
✔ row 194 T16: delete joins add and change on /api/instances with only revision, instance and op (2.724511ms)
✔ row 194 T16 D4.1: the tester listener launches, terminates and reads requests; regenerate, veto and delete are refused with no call (4.723645ms)
✔ row 194 N5: agents.js is served as JavaScript and index.html loads it once (1.551741ms)
✔ row 239 T6: a delete closed -revoked says the tokens may already be revoked, and outcome-unknown-revoked is an open outcome (2.702385ms)
✔ the Board: held roles with no machine sessions, a claim with no address, the toggle kept across a reload, and Message addressed to the role (3264.641316ms)
✔ a Seen click while a Board read is in flight waits for that read, then posts (1197.183961ms)
✔ a redirect answer on a Board read, a bus read, a conversation read, a board POST, a module read and a send signs the page out: one line, nothing stale, no further reads (5156.528249ms)
✔ browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback (1309.276425ms)
Rendered contrast: {"failures":[],"count":330,"lowest":4.504658476260286}
✔ served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures (1518.992742ms)
✔ the README says a refused field table names the file or the key at fault, never a path (104.682461ms)
✔ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes (3538.625499ms)
✔ bus routes serve the four reads from the reader view, unescaped JSON for the page to escape (83.53196ms)
✔ decisions route serves the roles and recent lists from the reader view (37.23684ms)
✔ credentials route serves the broker credential status from the reader view (13.661469ms)
✔ replies route serves the replies to one message from the reader view (37.146766ms)
✔ bus routes are GET-only, have no write verb, and keep the same-origin checks (57.970332ms)
✔ bus refusals map to statuses and never carry a path or a stack (25.682155ms)
✔ humanCall speaks the human transport protocol without blocking the server (1672.176975ms)
✔ busReader takes --business, else the live bus host, else refuses (188.376745ms)
✔ humanCall kills a transport that runs past its timeout (527.833381ms)
✔ serve refuses a --business value that is not a business id (331.000231ms)
ℹ tests 28
ℹ suites 0
ℹ pass 28
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 17747.118568
@@ -1,22 +0,0 @@
# grant sage-4286; nice 19; TMPDIR /mnt/storage/scratch/tmp/g49X; MOSAIC_REQUIRE_EDITOR_BUILD 1
✔ wikilink round-trip: plain (5.135516ms)
✔ wikilink round-trip: labelled (0.532763ms)
✔ wikilink round-trip: heading target (0.47601ms)
✔ wikilink round-trip: list item (0.674052ms)
✔ wikilink round-trip: next to front matter (0.620153ms)
✔ wikilink round-trip: heading block and combined label (0.718517ms)
✔ wikilink round-trip: spaces, Unicode and adjacent links (0.543729ms)
✔ wikilink round-trip: reference-definition collision (1.267657ms)
✔ wikilink round-trip: inside an ordinary link (0.565479ms)
✔ wikilink round-trip: inert embed (0.373131ms)
✔ wikilinks are inline atoms and survive a neighbouring edit in every required context (773.2922ms)
✔ code, escaped openers, malformed syntax and HTML-like targets stay outside wikilink atoms (686.084659ms)
✔ wikilink DOM paste accepts only bounded single-line inert syntax; malformed attrs serialise as escaped text (675.536422ms)
ℹ tests 13
ℹ suites 0
ℹ pass 13
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2305.540306
-27
View File
@@ -1,27 +0,0 @@
// Run only under Sage's explicit CLEAR. Reads the pinned baseline Git objects,
// not the edited model or rebuilt candidate bundle. Never writes product files.
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import vm from 'node:vm';
import { WIKILINK_CASES } from '../../../../packages/webui/tests/helpers/wikilink-cases.mjs';
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..');
const base = '487500a16f4ab5902c318509fdec6acc56a543ca';
const git = (...args) => execFileSync('git', args, { cwd: repo, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
const read = path => git('show', `${base}:${path}`);
const source = read('packages/webui/src/public/editor.js');
const build = JSON.parse(read('packages/webui/editor/BUILD.json'));
const sha256 = createHash('sha256').update(source).digest('hex');
if (sha256 !== build.outputs['src/public/editor.js'].sha256) throw Error('baseline bundle differs from its recorded build');
const context = { console }; context.window = context; context.globalThis = context;
vm.createContext(context);
for (const [name, text] of [['markdown-preview.js', read('packages/webui/src/public/markdown-preview.js')], ['editor.js', source]])
vm.runInContext(text, context, { filename: name });
const results = WIKILINK_CASES.map(({ name, text }) => {
const output = context.MosaicEditor.roundTrip(text);
return { name, input: text, output, equal: output === text };
});
console.log(JSON.stringify({ base: git('rev-parse', base).trim(), bundleSha256: sha256,
measuredAt: new Date().toISOString(), allEqual: results.every(r => r.equal), results }, null, 2));
@@ -1,68 +0,0 @@
{
"base": "487500a16f4ab5902c318509fdec6acc56a543ca",
"bundleSha256": "9c881b13740fae2408717ad75fa1d85bfcfe3fa8b3af0f1b3f2e706c90e80fee",
"measuredAt": "2026-10-24T22:07:22.210Z",
"allEqual": true,
"results": [
{
"name": "plain",
"input": "[[a]]\n",
"output": "[[a]]\n",
"equal": true
},
{
"name": "labelled",
"input": "[[a|b]]\n",
"output": "[[a|b]]\n",
"equal": true
},
{
"name": "heading target",
"input": "[[a#h]]\n",
"output": "[[a#h]]\n",
"equal": true
},
{
"name": "list item",
"input": "- [[a]]\n",
"output": "- [[a]]\n",
"equal": true
},
{
"name": "next to front matter",
"input": "---\nrelated: \"[[metadata]]\"\n---\n\n[[a]]\n",
"output": "---\nrelated: \"[[metadata]]\"\n---\n\n[[a]]\n",
"equal": true
},
{
"name": "heading block and combined label",
"input": "# See [[a#h|Title]]\n",
"output": "# See [[a#h|Title]]\n",
"equal": true
},
{
"name": "spaces, Unicode and adjacent links",
"input": "See [[ Héllo there # A heading | a label ]][[next]]\n",
"output": "See [[ Héllo there # A heading | a label ]][[next]]\n",
"equal": true
},
{
"name": "reference-definition collision",
"input": "[[a]]\n\n[a]: u\n",
"output": "[[a]]\n\n[a]: u\n",
"equal": true
},
{
"name": "inside an ordinary link",
"input": "[x [[a]]](u)\n",
"output": "[x [[a]]](u)\n",
"equal": true
},
{
"name": "inert embed",
"input": "![[cover.png]]\n",
"output": "![[cover.png]]\n",
"equal": true
}
]
}
-402
View File
@@ -1,402 +0,0 @@
# Row 255 design: a bundled Pocket ID for users with no OIDC provider (Q203, Gitea #1760, Vikunja 458)
Launchpad, draft 1, 2026-10-25, on `launchpad/row255` off refactor 78b69e1e6. Brief
`docs/plans/2026-10-21_bundled-pocket-id.md` (blob 26f34400). Reviewer Webby. Sage checks it against Q203, O6,
REQ-WEB-2 and REQ-INST-1. REQ-INST-2 (PRD 1.10) is post-v1 (mos-1204), and so is everything here.
This is a design only. Nothing here installs, downloads or starts Pocket ID. It changes nothing on .53, .54 or
dragon-lin, which keeps Authentik.
## Sources
- Q203 and O6 as the brief quotes them. PRD `docs/prd/mosaic-stack.md` REQ-WEB-2 (:902), REQ-INST-1 (:1509),
REQ-INST-2 (:1513) and REQ-INST-3 (:1528). Lead decision 44 items 5 and 6
(`docs/plans/2026-09-26_lead-decisions.md:655-663`).
- The Q175 design `agents/sage/work/q175-sso/DESIGN.md` sections 4 and 10, and the code row 192 landed:
`packages/webui/src/sso-config.mjs` (the `console.sso` shape), `packages/webui/src/sso.mjs` (the slot read,
the discovery checks at :105-118, the client at :147-148, the callback at :133, the post-logout URI at :332).
- Researcher's note `agents/researcher/work/2026-10-04_vikunja-pocketid.md` part 2 (v2.17.0).
- A fresh read of upstream on 2026-10-25, from release metadata and source text only: no binary was fetched or
run. The latest stable release is **v2.19.1** (2026-10-22). Source paths below are under
`backend/internal/` at tag v2.19.1 in `github.com/pocket-id/pocket-id`, marked "P". Releases v2.18.0,
v2.19.0 and v2.19.1 are not marked breaking.
## What the Console already needs from any provider
This design keeps the Console's sign-in and start checks exactly as row 192 built them. Pocket ID becomes one
`console.sso.providers` entry, the same shape any compliant OP uses (Q175 section 4, "Several providers"). The
Console gets no new sign-in path, no password and no proxy-header identity (O6, REQ-WEB-2, REQ-INST-2). The
facts that drive the rest of the design:
- `publicOrigin` and every `issuer` must be https (`sso-config.mjs` `httpsURL`). A plain-http Pocket ID can't
be a provider at all.
- Each provider needs 1 to 64 `subjects`. A `console.sso` with no subject refuses to load. So the first
person's subject has to exist before the Console starts with `--sso` (section 5).
- The client authenticates with `client_secret_basic` (`sso.mjs:148`), so the client is confidential.
- The slot is `<configDir>/secrets/mosaic-stack/<slot>`: a regular file, owned by the Console's uid, mode
with no group or other bits, and outside the repo and every stack copy (`readSlot`).
- `config.json` is user-authored. bootstrap.sh creates it and nothing overwrites it (canon invariant 2, Q175
section 4). This matters for sections 1 and 5.
## 1. The choice
The installer is REQ-INST-3's install command (Vikunja #432; today `stack-upgrade.sh` and the per-case
install scripts). It takes one sign-in option per instance:
- `--oidc existing`. The user points the Console at one or more OIDC providers they already have. Nothing
is installed. The user writes `console.sso` themselves, as Q175 step 4 does today. The installer writes
nothing into `console.sso`; it prints the entry shape and the two URIs to register
(`<publicOrigin>/auth/callback`, `<publicOrigin>/auth/signed-out`).
- `--oidc bundled`. The stack installs Pocket ID as an install component (section 2), and
`console.sso.providers` holds exactly one entry, `id: "pocket-id"`, whose `issuer` is the component's
origin.
- No option keeps today's behaviour: no `console.sso`, and the Console runs without `--sso`.
How the bundled choice is recorded. REQ-INST-1 lets a component be switched on "in the organization file
or system config". It has to be the system config. The organization file can be written by agents through
`organizationWriter`, which is the reason Q175 B1 put `console.sso` in config.json, and the same reason
holds for a component that issues the Console's identities. So config.json gets one new optional key:
```json
"components": {
"pocket-id": {
"release": "v2.19.1",
"origin": "https://id.example.internal",
"port": 1411
}
}
```
The loader (`scripts/mosaic-config.mjs`) checks the shape: `release` is a `vX.Y.Z` tag, and the component script refuses one that
differs from the pin file (section 2), `origin` is an https origin, `port` is 1024 to 65535. It also cross-checks: when
`components["pocket-id"]` is present, `console.sso` must have exactly one provider, `id` `pocket-id`,
`issuer` equal to `origin`. A config with `console.sso` providers and no `components` is the existing
choice and loads as today.
**M1, approved by Mos with three conditions (mos-1666 item 3).** `components` is a new top-level config key.
The loader refuses unknown keys today, so this is a config schema change. The conditions:
1. **Rollback trap.** A copy older than P1 refuses a config that has the key, the same trap Q175 section 4
names for `console.sso`. P1 documents the order. On a rollback past P1, first take `components` out of
config.json (restore the backup made before the key went in), then roll back the pointer. The STATE check
after the rollback reads config.json and confirms the key is absent.
2. **Strict inside the key.** The loader refuses any name under `components` other than `pocket-id`, and any
field under `pocket-id` other than `release`, `origin` and `port`. Each refusal names the key path and
quotes no value, as `checkConsole` does.
3. **test-config.sh cases:**
- a valid `components` loads;
- a malformed one fails, naming the bad key path;
- a config without `components` loads;
- the cross-check refuses a `console.sso` whose provider doesn't match `origin`.
What each choice writes into `console.sso` is in section 5. For the bundled choice, Pocket ID is the only
providers entry. The design keeps it that way because REQ-INST-2 says "the Console's one providers entry".
A user who later adds an existing provider moves to `--oidc existing` and removes the component (section 2).
## 2. The component, against REQ-INST-1
**Pinned.** A Pocket ID release is a set of raw binaries plus `checksums.txt` (sha256). From v2.16.0 there is
an SPDX SBOM per binary, and the release workflow attests `checksums.txt` through Sigstore (`actions/attest`
in `.github/workflows/release.yml`). The repo holds one pin file,
`scripts/pocket-id-pins.sh`, sourced the way `scripts/case1-pins.sh` is: the tag, and for each supported platform (linux amd64 and arm64 to
start) the asset name and its sha256. The build row copies the sha256 from `checksums.txt` at the tag and
records the `gh attestation verify` output for each asset as its evidence. The pin is checked by hand at
build time, not trusted at install time. No Pocket ID source and no binary goes in the repo. The binary is
not in any image either, since the container install comes after v1 (REQ-INST-3).
Recommended pin: v2.19.1, the current release. The build row checks it again, because Pocket ID ships a
minor every one to four weeks. Upgrading is a pin file change in a reviewed commit.
**Fetched and checked.** `scripts/component-pocket-id.sh fetch` downloads the pinned asset for the host's
platform over https into a new 0700 staging directory, computes its sha256, and compares it with the pin.
On a mismatch it deletes the file and refuses with `pocket-id-digest-mismatch`, naming the expected value
and the computed one. On a match it installs the binary read-only (0555) at
`<dataRoot>/components/pocket-id/bin/pocket-id-<tag>`. The fetch is the only network step, and the user
runs it at install. Tests never run it against the network (section 9).
**Runs.** A systemd user unit, `mosaic-pocket-id.service` (`mosaic-pocket-id-NAME.service` for a named
instance, as row 190 names the bus and webui units), as the instance's user:
- `ExecStart=<dataRoot>/components/pocket-id/bin/pocket-id-<tag>`.
- `WorkingDirectory=<dataRoot>/components/pocket-id/run`, a directory the unit owns, so Pocket ID's habit of
reading `.env` from the working directory reads nothing we didn't write. The build row checks there is no
`.env` there at start.
- Environment, all from the rendered unit:
- `APP_URL=<origin>` and `HOST=127.0.0.1` (P `common/env_config.go`: HOST defaults to `0.0.0.0`).
- `PORT=<port>`.
- `DB_CONNECTION_STRING` and `UPLOAD_PATH` as absolute paths under `<dataRoot>/components/pocket-id/data/`
(P defaults are `data/pocket-id.db` and `data/uploads`, relative to the working directory).
- `ALLOW_USER_SIGNUPS=disabled` and `ALLOW_INSECURE_CALLBACK_URLS=false`.
- `EMAIL_ONE_TIME_ACCESS_AS_UNAUTHENTICATED_ENABLED=false` and `..._AS_ADMIN_ENABLED=false`.
- The `OUTBOUND_ALLOWED_HOSTS_*` allowlists set empty (added in 2.19.0).
- No `TRUST_PROXY` unless section 6's proxy step sets it to the proxy's address.
- `ENCRYPTION_KEY` (mandatory since v2.0, at least 16 bytes) never goes in the unit text. It lives in slot
`<configDir>/secrets/mosaic-stack/pocket-id-encryption-key`, 0600. The component script generates it from
`/dev/urandom` straight into the file and never prints it. The unit passes it with
`LoadCredential=` and `ENCRYPTION_KEY_FILE=%d/...`. The build row confirms `ENCRYPTION_KEY_FILE` at the pinned
tag; if it isn't there, it refuses rather than putting the key in `Environment=`.
- The unit never carries `STATIC_API_KEY`. That key exists only during the enroll run (section 4).
The data directory holds the SQLite database, Pocket ID's signing key (stored encrypted with
`ENCRYPTION_KEY`), and the passkey registrations. It is 0700. Agent sandboxes bind only an allowlist
(`packages/seat/src/sandbox.mjs`), and `<dataRoot>/components/` is not on it. The build row adds a sandbox
test that pins that.
**Health-checked.** `GET http://127.0.0.1:<port>/healthz` returns 204 (P `controller/healthz_controller.go`).
`component-pocket-id.sh status` reports the unit state, the binary tag against the pin, the binary's sha256
against the pin, and the health result. The Console's own `/healthz` already shows `sso: { "pocket-id": ready
| unreachable | refused }` from its discovery retries, so an operator sees the provider from either side.
**Removable.** `component-pocket-id.sh remove` refuses while `console.sso` still names `pocket-id`, so the
Console can't be left pointing at a provider that's gone. Once the user has taken the entry out (or moved to
`--oidc existing`), the script stops and removes the unit, then moves the data directory to
`<dataRoot>/components/pocket-id.removed-<utc>` with a receipt line in `<dataRoot>/state/components.log`
(append-only). It never deletes the passkey database outright. Deleting the moved directory and the
encryption key slot is a separate `purge` verb that names both paths and needs `--yes`. Removing the
`components` key from config.json is the user's edit.
## 3. The Q175 start checks against the bundled instance
The Console checks are unchanged. Here is each one against Pocket ID, and the setting it needs:
| Check (row 192) | Pocket ID v2.19.1 | Default that fails | Fix |
|---|---|---|---|
| `issuer` is https (loader) | issuer = `APP_URL` exactly (P `controller/well_known_controller.go`) | `APP_URL` defaults to `http://localhost:1411` | `APP_URL=<https origin>`, section 6 |
| discovery `issuer` equals config `issuer` | equal when `APP_URL` has no trailing slash; Pocket ID refuses a path | none | config `issuer` = `origin`, no trailing slash; the loader cross-check compares them |
| `id_token_signing_alg_values_supported` overlaps `algs` | advertises only the current key's alg. RS256 (RSA-2048) by default (P `service/jwt_service.go`) | none | `algs: ["RS256"]` |
| `code_challenge_methods_supported` has `S256` | advertises `plain` and `S256` | none | none. The Console always sends S256 (`sso.mjs:221`) |
| JWKS has a usable key for `algs` | one key at `/.well-known/jwks.json` | none | none. The build row's fake OP pins `use`/`alg`/`kid` as Pocket ID emits them |
| `end_session_endpoint` is https | `<APP_URL>/api/oidc/end-session` | http, if `APP_URL` is http | same fix as row 1 |
| `redirect_uri` is registered | exact match first, then wildcard patterns; loopback callbacks match any port (P `utils/callback_url_util.go`) | `ALLOW_INSECURE_CALLBACK_URLS` defaults to true | register exactly `<publicOrigin>/auth/callback`, with no `*`; set `ALLOW_INSECURE_CALLBACK_URLS=false` |
| post-logout URI | per-client `logoutCallbackURLs`, same matching | none | register exactly `<publicOrigin>/auth/signed-out` |
| ID token alg in `algs`, `iss` match | as above | none | none |
Two points the Console doesn't check, set on the client anyway:
- `pkceEnabled: true`. Then Pocket ID refuses an authorize request that has no `code_challenge`. Pocket ID has
no per-client S256-only setting (P `oidc/provider.go` turns on `plain` for the server). That doesn't relax
anything: the Console sends S256 every time, and `plain` stays available only to other clients.
- `isPublic: false`, which matches `client_secret_basic`.
Key rotation. `pocket-id key-rotate --alg ...` replaces the one signing key (P `cmds/key_rotate.go`). After a
rotation to another alg, the Console's discovery check refuses on the next restart (no overlap with
`algs`), and that is the intended fail-closed result. A rotation is a user step that updates `algs` in the
same edit. The component script never rotates.
## 4. The client, and where its secret goes
The person running the install creates the client through `component-pocket-id.sh enroll`. No agent runs
this verb. It refuses when any agent marker is in its environment: the `MARKERS` list in
`packages/bus/src/human.mjs` (`MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP`, `CLAUDECODE` and the rest) that the human
boundary uses. A test pins the refusal.
Enroll needs admin rights before any person has a passkey. Pocket ID offers two ways to get them, and one of
them is a hazard:
- `/setup` and its backing `POST /api/signup/setup` are unauthenticated: whoever calls first becomes admin
(P `usersignup/service.go`). Sandboxed agents share the host's network namespace (`sandbox.mjs` unshares
user, pid and ipc, not net), so any agent could reach a loopback Pocket ID and take that first admin.
- `STATIC_API_KEY` is a fixed admin key with no expiry (P `apikey/service.go`). It doesn't count as
completing setup, so `/setup` stays open while it's set.
So enroll never lets the real unit serve before an admin person exists. It runs the pinned binary once in a
**private network namespace** (`unshare --user --net`, or `bwrap --unshare-net` with the binary and data
directory bound), where only enroll's own loopback can reach it:
1. Generate a one-run static key into a 0600 temp file in a new 0700 directory, and start Pocket ID inside
the namespace with `STATIC_API_KEY_FILE` pointing at it, plus the unit's environment.
2. Wait for `/healthz` 204.
3. Create the admin user, `POST /api/users` with `isAdmin: true` and the username and email given with
`--admin-user` and `--admin-email`. Keep the returned `id`. That is the OIDC `sub` (P
`oidc/claims_service.go` uses `user.ID`, a UUIDv4 set at creation, so it's stable).
4. Create the Console client: client id `mosaic-console-<instance>`, callback and logout URLs exactly as in
section 3, `pkceEnabled: true`, `isPublic: false`. Supply the secret instead of letting Pocket ID
generate it. Enroll generates 48 random bytes, base64url, and writes them straight to slot
`<configDir>/secrets/mosaic-stack/console-sso-pocket-id` (0600, `O_CREAT|O_EXCL`, not a symlink). It sends
the same bytes in the create body over the namespace's loopback (`dto/oidc_dto.go` accepts a supplied
secret of 16 or more characters). The value is never printed, logged or put in an argv, and a test pins
that it appears in no output.
5. Mint a one-time access token for the admin, `POST /api/users/<id>/one-time-access-token`.
6. Stop the namespaced process, then shred and remove the static key file. The real unit has never had a
static key, so no admin key survives enroll.
7. Start the real unit and wait for `/healthz` 204.
8. Print the sign-in link `<origin>/lc/<token>` to the user's terminal (section 5), and the `console.sso`
entry (section 5).
The build row confirms each route and body against the pinned tag's source before it writes the client (the
upstream read for this design didn't trace the create-client route). If any of steps 1 to 7 fails, enroll
stops the process, removes the static key, leaves no slot file it created, and refuses with the step name.
The secret never enters the repo or an image. It sits in two places only: the slot file and Pocket ID's
database (stored as a hash). The Console's `readSlot` reads it as it does any provider's.
## 5. The first person, and their subject in `console.sso`
Pocket ID is passkey-only for people (REQ-INST-2). The installing user enrolls like this:
1. Enroll prints `<origin>/lc/<token>`. The token is valid for one hour and is a credential, so it goes only
to the terminal of the person who ran enroll. It goes in no log and no file.
2. The user opens it in a browser that can reach `origin` (section 6). Pocket ID signs them in and asks them
to add a passkey. Without one they can't sign in again. If the hour passes, `pocket-id
one-time-access-token <username>` (P `cmds/one_time_access_token.go`), run by the user against the data
directory, prints a new link. `component-pocket-id.sh relink` wraps that call.
3. Enroll already has the subject, the `id` from step 3 of section 4. It doesn't wait for a sign-in or read a
`subject-unknown` log line.
How the subject gets into `console.sso`. config.json is user-authored and never auto-written. Two ways
to respect that; I recommend the first:
- **R1 (recommended).** Enroll prints the full entry and writes nothing:
```json
"console": { "sso": { "publicOrigin": "<publicOrigin>", "providers": [ {
"id": "pocket-id", "issuer": "<origin>", "clientId": "mosaic-console-<instance>",
"clientSecret": { "slot": "console-sso-pocket-id" }, "algs": ["RS256"],
"subjects": [ { "sub": "<id>", "name": "<admin-user>" } ] } ] } },
"components": { "pocket-id": { "release": "<tag>", "origin": "<origin>", "port": <port> } }
```
The user pastes it into config.json, as with Q175 step 4. Rerunning `scripts/bootstrap.sh` (or
`--instance NAME`) then validates it through the loader, including the section 1 cross-check, and writes
nothing. Nothing changes in invariant 2.
- **R2 (rejected, Sage sage-4401: R1 chosen).** `enroll --write-config` would write those two keys itself, with a backup of config.json first, and
refuse if `console` or `components` were already present. That would be the first tool to modify an
existing config.json, against canon invariant 2's "never overwritten" practice. Not built.
Whose subject. Enroll creates one person, the one running it, and only that subject goes in the entry. On
Jason's instances, only his subject goes into `console.sso` (mos-1197). Further people are added with
Pocket ID's admin UI (a user plus a one-time link), and their subjects go into `console.sso` by the user's
edit, never by an agent. Pocket ID users who aren't in `subjects` can sign in to Pocket ID, but the Console
refuses them `subject-unknown`, so REQ-WEB-2's "must map to an authorized human" holds.
## 6. Reach: origin, TLS and proxy
WebAuthn takes its RP id from `APP_URL`'s host name, and its allowed origin is `APP_URL`
(P `webauthn/module.go`). Browsers allow passkeys only on a secure origin. The Console also needs https for
both `publicOrigin` and `issuer`. Pocket ID serves plain http, and I found no TLS listener setting in its
environment config. So:
- Pocket ID always binds `127.0.0.1:<port>`. That is the default, and the component never binds anything
else (`HOST=127.0.0.1`; the status verb refuses a unit whose `HOST` differs).
- `origin` is an https origin served by a TLS front that the user already has for the Console. The Console
needs one anyway, since `publicOrigin` is https. Pocket ID goes behind the same front under **its own host
name**, for example `id.<console host>`. Pocket ID doesn't support a path prefix (`APP_URL` must have no
path). A separate host name also keeps Pocket ID's cookies apart from the Console's
`__Host-mosaic_*` cookies; the two share cookies if they sit on one host name on different ports.
- The front reaches Pocket ID on loopback, so the front has to run on the same host. If it doesn't, Pocket
ID would need a non-loopback bind, and that is a `network.expose` hard wait (REQ-WAIT-1), not a component
option.
- `TRUST_PROXY` is set to the front's address only, never `true`.
- The Console fetches discovery and JWKS from `issuer` over https from the server side, so the Console's
node has to trust the front's certificate. A public CA does that. A private CA needs
`NODE_EXTRA_CA_CERTS` in the Console unit, which the build row adds as a render flag.
These steps are the user's, and on Jason's hosts they are Jason's, because each one changes who can reach
the Console or Pocket ID, or who the host trusts:
- J1: the DNS name for `origin`;
- J2: the certificate for it, or a private CA and its trust in the browser and in `NODE_EXTRA_CA_CERTS`;
- J3: the TLS front's route from `origin` to `127.0.0.1:<port>` (a Traefik change on Jason's hosts);
- J4: any listener off loopback.
The component checks and refuses; it never does them itself. With none of them done, Pocket ID runs on
loopback with an https `APP_URL` that nothing serves yet. Enroll's step 2 health probe goes straight to
`127.0.0.1`, so enroll still works. The person can't add a passkey until J1 to J3 are done.
## 7. Agents
Unchanged. Agents keep their per-role service tokens (lead decision 44 item 6, REQ-INST-2). Pocket ID issues
nothing an agent uses. The Console client is the only client enroll creates. No API, no client-credentials
grant and no admin API key exist after enroll. Residuals, stated plainly:
- Agents can reach `127.0.0.1:<port>`, because the sandbox shares the host network. What they can reach there
is the public sign-in pages, discovery and JWKS. Signup is disabled, email codes are off, `/setup` is
closed because an admin exists, and there is no static key. A test of the rendered unit pins all four
settings.
- The data directory and the two slots are outside every sandbox bind (section 2).
- A seat that ran as the same OS user outside a sandbox could read the data directory. The Console's
existing slot has the same exposure; this design adds no new class of it.
## 8. Instances
One Pocket ID per named instance, never shared:
- Each instance has one system config (row 190), and `components` and `console.sso` live in it. A shared
Pocket ID would make one instance's config depend on a unit, a data directory and an encryption key that
belong to another instance's config and data root.
- Unit names, ports, data directory, slots and removal are all per instance (`mosaic-pocket-id-NAME`,
`<dataRoot of NAME>/components/pocket-id`), so removing one instance's component can't break another's
sign-in.
- Each instance needs its own `origin` (section 6), so passkeys are per instance too. A person enrolls once
per instance.
- An instance that wants to use another instance's Pocket ID can still do it with `--oidc existing`. Then it
is just an OIDC provider, with its own client registered by that instance's owner. Nothing special-cases it.
Two instances on one host each need their own `port`. The loader can't see other instances' configs, so
`component-pocket-id.sh` refuses at fetch and enroll when the port is already bound on loopback.
## 9. Tests, stand-ins only
No test downloads or starts a real Pocket ID, and no test touches auth.diversecanvas.com,
mosaic.woltje.com or 127.0.0.1:7332. The stand-ins:
- **Fake Pocket ID OP**, a node server like row 192's fake OP, shaped from v2.19.1:
- the discovery fields `well_known_controller.go` emits (issuer = origin, `plain` and `S256`,
`end_session_endpoint` at `/api/oidc/end-session`, the three auth methods);
- one RS256 JWK at `/.well-known/jwks.json`;
- a token endpoint that takes only `client_secret_basic`;
- `/healthz` 204.
The Console starts against it under `--sso` with the section 5 entry, with no check changed. Negative
cases:
- an http issuer refuses at load;
- an ES256-only JWKS against `algs: ["RS256"]` refuses at discovery;
- an issuer with a trailing slash refuses on the cross-check.
- **Fake admin API** on the same stand-in: `POST /api/users`, the client create, the one-time token, and a
static-key check. Enroll runs against it with the binary replaced by a stub script that serves the stand-in.
The tests pin:
- the namespace argv;
- the slot file's mode and `O_EXCL`;
- that the secret appears in no stdout, stderr or argv;
- the static key file gone after success and after a failure at each step;
- the refusal under an agent marker in the environment.
- **Fetch**: a local file served by a test https server (self-signed, trusted only by the test) with a
matching pin, a mismatched pin and a truncated file.
- **Unit render**: the environment table in section 2. `HOST=127.0.0.1`, no `STATIC_API_KEY`, no
`ENCRYPTION_KEY` value in the text, and `ALLOW_USER_SIGNUPS=disabled`.
- **Loader**: test-config.sh cases for the `components` key and the cross-check, and the copy-rollback note.
- **Sandbox**: `<dataRoot>/components/` is in no bind list.
## 10. Build rows, in landing order
All are post-v1 (REQ-INST-2). Each row has Webby or Sage's named reviewer and its own gate. None of them
runs on .53, .54 or dragon-lin, and none installs Pocket ID on any host.
1. **P1, loader key.** `components["pocket-id"]` in `mosaic-config.mjs`, strict inside the key, and the
cross-check against `console.sso` (section 1, M1 conditions from mos-1666 item 3). P1 also writes the
rollback order into `docs/TOOLS.md` beside Q175's: remove `components` (restore the backup) before the
pointer goes back, and the post-rollback STATE check confirms the key is absent from config.json. Gate:
test-config.sh green with the four M1 cases.
2. **P2, pin and fetch.** `scripts/pocket-id-pins.sh` with the build-time sha256 and the
attestation evidence, and `component-pocket-id.sh fetch|verify`. Gate: the fetch tests against the local
stand-in; the evidence file shows `gh attestation verify` output for each pinned asset.
3. **P3, unit.** The unit template, `render|install|status|remove|purge`, and the encryption key slot. Gate:
the render and remove tests, and the sandbox bind test.
4. **P4, enroll.** The namespaced bootstrap, the client, the subject, `relink`, and printing the entry (R1),
(R2 rejected). Gate: the enroll tests in section 9, and a secret-leak grep over every test log.
5. **P5, Console against the fake Pocket ID.** The section 9 fake-OP start tests, and the
`NODE_EXTRA_CA_CERTS` render flag for the Console unit. Gate: the webui suite, with no change to
`sso.mjs` checks (a diff of `sso.mjs` and `sso-config.mjs` against the base shows none).
6. **P6, installer option.** `--oidc existing|bundled` on REQ-INST-3's install command, calling P2 to P4 in
order, and the docs: `docs/TOOLS.md`, the host run sheet's J1 to J4 steps. Gate: the installer's own
suite against the stubs; the docs list J1 to J4 as the user's steps.
## Open items for review
- M1 approved by Mos with three conditions (mos-1666 item 3), written into section 1 and P1.
- M2 dropped: Sage chose R1 and rejected R2 (sage-4401).
- **Q1 (Webby):** the private network namespace for enroll (section 4). The other option is to start the
real unit and race `/setup`, which I think is unsafe while agents share the host network.
- **Q2 (Webby):** one-providers-entry for the bundled choice (section 1). REQ-INST-2's wording reads that
way. Mixing a bundled Pocket ID with existing providers would need Sage's ruling.
- Not covered: Pocket ID for Vikunja or Gitea people sign-in (the Researcher note's part 3), and
client-credentials between Mosaic services. Neither is in Q203.
+374
View File
@@ -0,0 +1,374 @@
# Row 331 / P2 / #1829
## 2026-10-24 — design before implementation
Base: 78b69e1e6, branch magica/row331. Brief blob fd4325c05a7f,
`2026-10-24_model-proxy-rows.md` P2 and Rules for every row; gate rules in BRIEF-TEMPLATE.
Root `npm ci --ignore-scripts --no-audit --no-fund` passed (128 packages).
No tests run: Sage holds the slot until CLEAR. Test-driven development applies;
write tests before fixes and observe the first RED only after CLEAR.
### Integration points (line numbers at base)
- `packages/model-proxy/src/client.mjs`: management path :6; getKeys/putKeys :63-64;
trusted loader :68. GET/PUT already redact receipts and pin numeric loopback.
- `packages/model-proxy/src/settings.mjs`: instance tag :77 (sha256(configDir), first 12 hex),
sentinel :78. Reuse that tag, never inspect proxy/config.yaml.
- `packages/bus/src/credentials.mjs`: issue :291, revoke :383, remove :400,
useFor :417, status :474. Store root comes from runtime :178.
- `packages/bus/src/runtime.mjs`: writer lock precedes management client :179-182;
Credentials construction :201; revoke :213-240; instance writer :243;
settings callback :390-415; credentialStatus :633; returned modelProxy :686.
- `packages/business/src/instance-settings.mjs`: hire :354-383; fire :386-405;
commit :431-466. `packages/business/src/organization.mjs`: writer :226,
beforeWrite :252-255, lock release :170-175.
- `packages/cli/src/launcher.mjs`: common launch :439, credentialStatus :453,
spawn :595, binding :616, registry :622, exit :635 onward.
- `packages/bus/src/process.mjs`: trusted launch IPC allowlist :5 and dispatch :78.
- Shared predicate already exported: model-proxy/src/common.mjs :32;
missing callers broker.mjs :4827 and cli/src/model-proxy.mjs :57.
### Planned behavior
Store: `<configDir>/secrets/agents/<business>/<agent>/model`, mode 0600;
all directories 0700. Implicit model kind in proxy mode only, no role-file or
business credentials.model reference. Persist key id, issue time, rotation due
and retiring-key metadata durably so restart cannot forget an overlap window.
Values stay in private credential storage; public status and receipts expose ids only.
Key format is msk-<sha256(configDir)[0:12]>-<32 random bytes base64url>.
Management API for all mutations: `GET /v8/management/config/access/api-keys`,
`PUT /v8/management/config/access/api-keys` with a JSON string array, confirming GET.
Never DELETE. Serialize read/modify/write operations inside the one locked broker;
Store's data-root writer lock precedes creation of the management client.
Issue: require own sentinel, GET/add/PUT/confirm GET before any credential-file write;
any failure => proxy-key-issue-failed, no issued file. Used at hire and first launch.
Rotate: same sequence adding new beside old, then durable credential update;
new launches take new key, runs retain key ids. Remove old when its runs exit or
120-minute grace expires, stopping surviving runs first. Default rotateBy 30 days.
Revoke: GET/filter exact key/PUT/confirm GET absence, then poll
`GET /v1/models` with removed key until 401. Proposed bound: 5 seconds total,
25 ms spacing, each HTTP request bounded by remaining deadline. Neither management
absence alone nor one successful PUT proves revocation. Delete credential file only
after 401; failure => credential-revoke-failed and no delisting.
Reconcile: initial GET once, require own sentinel, add recorded missing keys and
remove own-prefixed unknown keys in one PUT, confirm GET; poll each removed key to
401 with the same deadline. Preserve sentinel and every foreign/manual key.
Receipt contains added/removed key ids only. Reconcile failure blocks launches.
Preflight: exactly one `GET /v1/models` using agent key per launch; 401 =>
proxy-key-refused, connection failure => proxy-down; missing own sentinel => proxy-open.
Other HTTP failures must refuse too (code to be fixed before tests are finalized).
No first output within default 120 seconds => stop and log proxy-stall.
Trusted harness answer keeps `{ baseUrl, key }`; P3 owns harness config/env.
Important integration constraint: existing organization beforeWrite is synchronous.
Preserve the revision check, validation and writer lock across HTTP confirmation;
never move revoke ahead of validation or release the lock before 401. Investigating
an async-capable writer path that leaves existing synchronous callers unchanged.
### P1 deferrals
N2: choose outside-agent guard for fetch, before config/network/disk access, matching
init's refusalInsideAgent + tester rejection. Fetch does not need a management
credential prompt or bus human proof: its sole effects are verified pinned download
and install. This prevents managed agents causing unasked fetches.
R2-N1: import validManagementKey into broker and CLI, replacing local predicates;
retain initializeProxy and ManagementClient uses. Tests at each caller reject
Latin-1, leading/trailing/internal spaces before callbacks/writes and never echo input.
Item 7: 8.0.42 rejects per-key model objects with 422 (P1 probe, carried by Sage).
No per-key model restriction exists in the tested API; bundle model choice remains
the guard. No policy expansion or invented model filtering.
### Acceptance tests and named mutants
Fake loopback management/client server in fresh temp roots; never real instance
config, management key, dataRoot/proxy or port 8317. Generated test key values never
appear in assertion diffs, logs or snapshots. Literal fake credentials <24 characters.
- model store mode, private directories, implicit registration, status ids/issue/state,
reload persistence, no-proxy regression, no credential.model role requirement;
- issue exact GET/PUT/GET order, no file on each failure, hire and first-launch paths;
- rotate overlap/new-session selection, old-run exit cleanup, grace deadline stop,
restart during grace and human-only rotation setting;
- revoke confirming GET and delayed 401 (first response 200), deadline failure keeps
record and fire membership, successful fire observes 401 before delisting;
- reconcile missing recorded key, own unknown removal, foreign/manual preservation,
sentinel preserved, proxy-open test, receipts contain ids and no values;
- preflight accepted key, proxy-key-refused (401), proxy-down (connection failure),
unexpected status fails closed; first output cancels stall, silence stops proxy-stall;
- fetch outside-agent/tester guard before any fetch; predicate test at broker, CLI and
model-proxy initializer; secret-free errors/status/receipts across failure paths.
Required mutants: (1) revoke skips confirming GET; (2) reconcile removes foreign key;
(3) preflight treats 401 as success. Also test delayed revocation to kill
management-list-only confirmation, fire-before-revoke, wrong agent key and leaked values.
Whole-file run list will be computed from parsed imports (including transitive local
imports and literal dynamic imports), reported to Sage before any run, with explicit
exclusion of packages/cli/tests/case1-accept.test.mjs unless Mos approves. No text grep
will select files. Exact list pending final touched-module set.
## 2026-10-24 — Sage accepted design, writer hold
Sage sage-4345 accepted the design. Poll constants are **REVOKE_TIMEOUT_MS = 5000**
and **REVOKE_POLL_MS = 25**. Tests will pin the observable 5-second total bound and
25ms interval; per-request timeout cannot extend the total bound.
Hold: no edits to organization.mjs until Sage sees the options below.
### Writer options
A. Make existing write/withLock asynchronous throughout. Reject: touches every
synchronous caller and changes their return contract. Direct static importers,
parsed with vm.SourceTextModule.dependencySpecifiers (not grep):
`bus/src/runtime.mjs`, `business/src/instance-settings.mjs`,
`business/src/launch-settings.mjs`, `business/src/plugin-settings.mjs`, plus tests
`bus/tests/citation-guard`, `business/tests/library-projects-protected`,
`business/tests/lifecycle-settings`, `business/tests/plugin-settings`,
`business/tests/reply-limits`. Dynamic-import callers also include
bus/tests/limits and business/tests/launch-settings. This is unnecessarily broad.
B. Add an optional asynchronous write entry point sharing validation/publication
and retaining writer lock through HTTP; old write contract stays synchronous.
Correct ordering but edits shared writer internals, so a larger review and regression
surface even when existing caller code remains unchanged. Slow HTTP holds the lock
for up to 5s per bounded revoke; failure keeps revision and model file unchanged.
C. Recommended: prepare/revoke HTTP outside the existing writer lock, leave
organization.mjs and every existing synchronous caller unchanged. Add proxy-mode
preparation only in bus orchestration. Capture exact organization revision and
agent/model generation after current eligibility checks. Mark target unavailable
for launches while preparation runs. Remote revoke confirms GET and 401 but does
NOT remove the credential record yet. Then call the current synchronous writer;
its existing beforeWrite callback checks that exact captured revision and model
generation, then removes the private file synchronously and performs existing
credential revokes. The ordinary writer rechecks revision under lock before
beforeWrite as today. On slow/failed HTTP: revision/membership/store are unchanged,
launches held. On revision change: refuse revision-conflict, keep private record,
remote key may already be revoked; receipt marks remote revocation, and retry can
complete or reconciliation can restore it only under an explicit lifecycle-state
rule. This side effect must be visible; never silently report a clean no-op.
C's tradeoff: remote revocation can precede a later revision-conflict refusal;
local file removal and delisting still cannot precede proof of remote refusal.
Need to ensure reconciliation cannot restore a key during pending fire and that a
restart resumes/refuses pending revocation rather than undoing it. A durable
revocation intent keyed by business/agent/generation resolves that window.
Existing synchronous APIs stay byte-for-byte unchanged. Only bus proxy-mode
branches become promise-aware, including chief lifecycle and its grant job path.
No writer edit made. Await Sage's choice before implementing the integration.
Prepared RED tests (unrun): new CLI model-proxy-guards.test.mjs and added broker
predicate cases in bus/tests/model-proxy.test.mjs. These expose old behavior before
any implementation change. Existing initializer tests cover the third caller.
## 2026-10-24 — writer option C approved (sage-4354)
Writer and existing synchronous callers remain unchanged. Revoke intent belongs to
model credential's metadata status only: state `revoking`, key id and time, never
value in status/receipt. No new store, file kind or public schema. The model store
file may hold its own private model record (value + metadata); only sanitized
metadata is exposed. No extra intent file.
Boot reconcile must finish `revoking` with confirm GET and poll-to-401, never PUT
that key back. Add named mutant: **reconcile ignores revoking**.
Launch hold refusal: **proxy-key-revoking**, before any preflight HTTP call.
Revision race after successful remote revoke: **proxy-revoke-revision-conflict**;
receipt carries keyIds, remoteState `revoked`, localState `kept`. Local metadata
remains revoking; next reconcile completes cleanup, without restoring access.
Each code has its own test. Call this section out explicitly to Boyd.
### Initial RED run request (magica-331-0005, unrun)
Parsed static imports with `vm.SourceTextModule.dependencySpecifiers`:
- packages/model-proxy/tests/client-probe.test.mjs → ../src/client.mjs
- packages/bus/tests/model-credential-store.test.mjs → ../src/credentials.mjs
- packages/cli/tests/model-proxy-guards.test.mjs → ../src/model-proxy.mjs, ./helpers.mjs
- packages/bus/tests/model-proxy.test.mjs → ../src/broker.mjs, ../src/store.mjs
These four whole files only, serially. Missing probe/model-store APIs, missing fetch
guard and divergent CLI/broker predicates should cause new-case RED. Old-case RED
must be reported before changing anything. case1-accept.test.mjs excluded.
Request sent before execution; waiting for CLEAR, not yet granted.
## RED run — sage-4374, 2026-10-24 04:11–04:14Z
Four whole files, serial absolute paths, short TMPDIR (red-tmpdir.txt), each nice19.
- client-probe: rc1, 0 pass / 6 fail: probeKey is absent in every new case
(200,401,503,connection,timeout,redirect).
- model-credential-store: rc1, 1 pass / 4 fail: implicit status absent; writeModel
absent for persistence, revoking persistence and wrong-mode checks. Nonproxy passes.
- CLI model-proxy-guards: rc1, 1 pass / 6 fail: agent fetch reaches config rather than
human-required (two cases); four invalid management-key inputs accepted. Tester passes.
- bus model-proxy: rc1, 8 pass / 4 fail: every original test passes; four new bad-key
cases fail missing synchronous rejection. Their unconsumed init promises outlive
teardown and cause storage-unavailable rejections. Consume those promises in cleanup
while retaining the synchronous-rejection assertion before the GREEN run.
No old-test failures, no skips, no reruns. Logs red-*.log. Slot returned in
magica-331-0007. GREEN/mutants require separate CLEAR.
Correction: initial probe run started immediately after a busy check showing queue
writes, contrary to wait instruction. Paused remaining runs and waited for clear
before continuing; reported promptly in magica-331-0006. No live proxy access.
No production implementation was written before these RED results.
## 2026-10-24 — integration rulings and candidate implementation
Sage sage-4381 explicitly authorizes candidate commits and pushes on magica/row331
before GREEN: the seat branch is not a landing. GREEN and mutants remain held.
Only syntax parsing and git diff whitespace checks have run since RED; no test rerun.
New lifecycle/settings/stop/marker tests were authored before their corresponding
integration changes; their first execution is held with GREEN per the slot ruling.
Sage sage-4388 approved:
- stopSession only; MODEL_STOP_TIMEOUT_MS = 5000, identity checked against agent,
run, key id, pid/start time before signalling, then stopSession verifies the runner.
Failed/timed-out stop => proxy-stop-failed and keeps old key + retiring record;
the next sweep retries. Proxy receipt reason is proxy-rotation or proxy-stall,
with key ids. The session's normal launcher close/endRun path still records
session.ended and the normal exit/signal result; no run evidence is rewritten.
- proxy.json optional rotateGraceMinutes (1..1440) and firstOutputSeconds (1..3600),
absent defaults 120/120. Own refusal codes: proxy-rotate-grace-invalid and
proxy-first-output-invalid. Human-only init flags --rotate-grace-minutes and
--first-output-seconds. Existing sidecars keep their two-field shape.
- No per-agent override verb/setter in P2. Thirty-day default only. DEFERRED entry
'Row 331: human-only per-agent model rotation override — P7' binds the follow-up.
- Marker file `<run>/proxy-first-output`: fixed `output` token + newline, mode0600,
emitted from runner.mjs's child.stdout data listener through first-output.mjs.
No stdout contents or key values enter the marker or proxy receipts. Collection
of stdout for RESULT is unchanged. No P3 env/config change.
P3 handoff contract: trusted launcher IPC op `modelPreflight` takes business/role
and returns exactly `{baseUrl,key}` (null outside proxy mode). The launcher passes
that object as `proxy` into buildBundle; P3 owns consuming it. modelKeyId is separate
metadata in the host's session registry/bind message, stripped before Broker.bindLaunch
so the broker's public launch-record schema is unchanged. No socket verb returns keys.
Organization option C is implemented as modelOrganization's adapter in model-keys.mjs.
Existing business/src/organization.mjs and business settings callers are unmodified.
The adapter supplies captured revision to the existing locked synchronous write.
Runtime's revoke callback requires matching pending intent and revision before local
cleanup. A race after remote success produces proxy-revoke-revision-conflict and keeps
revoking metadata, for next boot reconcile. Generic Credentials.remove cannot bypass
remote confirmation for a live model record.
### Candidate validation plan (awaiting separate CLEAR)
Root npm ci and packages/webui npm ci both ran in this worktree (128 and 2 packages,
respectively); canonical node_modules was not read/linked/modified. Static syntax
checks parsed all 26 changed JavaScript modules; git diff --check and bash -n passed.
These are static checks, not GREEN evidence. No tests have run since the RED slot.
`import-list.mjs` generated green-files.txt: 262 whole files selected from actual
parsed imports/transitive edges of 15 changed production modules, plus changed tests.
It uses Node's module parser for static imports and tokenizes literal dynamic imports
outside comments/string/template/regex text. No fixture-text grep. The JSON manifest
records edges and has zero parse failures. Explicit exclusions: queue and
packages/cli/tests/case1-accept.test.mjs (mos-1612; no Mos approval). The additional
shell run is scripts/test-config.sh because mosaic-config.mjs changed; timeout -k10
1800 and absolute nice19. green.sh executes whole files serially, one short TMPDIR
per run with fixture HOME/config, logs the observed shell nice, and stops at first RED.
Mutants (mutants.mjs; separate CLEAR only), each with the entire model-keys.test file:
1. revoke skips confirming GET: mutate removeKeys only, retain PUT + client 401 poll;
the delayed-revoke test must fail its GET/PUT/GET ordering assertion.
2. reconcile removes foreign key: remove the own-prefix condition; the foreign/manual
preservation test must fail.
3. preflight treats401 as success: bypass401 refusal; the HTTP401 preflight test must fail.
4. reconcile ignores revoking (sage-4354): classify revoking records as retained and
omit their cleanup; the restart/revoking test must fail.
A kill requires nonzero status AND the corresponding named test's not-ok TAP record;
loader/syntax failures cannot count as kills. Original production bytes restore in
finally. These mutants have not run.
First-output emission site: packages/harness/src/runner.mjs:351 calls the marker in
its existing stdout listener, before the unchanged RESULT accumulator. File content
is fixed by packages/harness/src/first-output.mjs, never by the stdout chunk.
Remaining gates: GREEN, four mutants, Boyd first review, Dewey landing review,
Sage's full landing gate. No landing/completion claim.
### Evidence whitespace correction
The first candidate commit f83206473 preserved TAP formatter whitespace. The staged
whitespace check warned about space-only diagnostic lines in three RED logs; the
commit proceeded because that shell was not fail-fast. Normalize trailing spaces
only, without changing the recorded tests/results. No test rerun. Original raw bytes
remain in f83206473; hashes below make the presentation change reviewable.
- red-bus-proxy.log: original SHA256 b8eacf3c025c63289702a8a2a5630f694f74e9cc8f02dfa04b983e22f40466ae; normalized SHA256 b8eacf3c025c63289702a8a2a5630f694f74e9cc8f02dfa04b983e22f40466ae
- red-cli-guards.log: original SHA256 45d6f847556259d00511904dbe933272adcae251e6d0bf539d19eb03f93bfcad; normalized SHA256 9645a7c408249ecbacbfaa7d631a5b98c380916a2aa37985061b27a6ed05e1b0
- red-client-probe.log: original SHA256 3c3bc989a00e2d3860a44712b31a93d80cf8395c9260777f5a94c09ff45f42e1; normalized SHA256 e72318cbbc3fb0803770e4bb8e8ded9725fb4c4ebee6ea4bec365bceaa68250a
- red-model-store.log: original SHA256 5d46867eacee5ed014ed71cc45120de9c79167fc08c8583276eaec5fc5e5bfcc; normalized SHA256 1797d611f72f417612e6b7b24f3119ae0f079dc0b9a12acba087ab5c8b2cec59
### Base comparison and direct handoffs — sage-4409
Compared `git diff --name-only 78b69e1e6 775bbe236` against all 262 GREEN files and
all 26 changed .mjs modules. Both intersections are empty (base-overlap.json).
BUILD-LOG.md and docs/SESSIONS.md overlap only as append-only tracking files;
no test/source rebase trigger under Sage's condition. Published implementation
candidate remains fb484acb7. This follow-up records metadata only.
Fethry (14f80e3c-ace7-4793-a577-e1275ca09b0b) received the exact trusted P3 interface
in magica-331-0010. Boyd (48a3ae38-83aa-4d3f-9064-64e911ec4547) received the build
notice with option C review flag in magica-331-0011. Sage copied in -0012.
Both notices say GREEN/mutants are pending and do not request a test slot.
Sage ordered 331 GREEN/mutants after Bubba's 288 and before198; awaiting CLEAR.
## 2026-10-25 — GREEN attempt stopped on new fixture RED (sage-4420)
Candidate a86a881e1. Ran approved whole files serially, each with a fresh short TMPDIR
and a nice19 wrapper, no test/source/timeout edits and no reruns. Driver stopped on
file55, model-keys.test.mjs: 30 tests,24pass,6fail,0skip/cancel,rc1. All54 preceding
files passed, including the seven model-credential-store cases. Aggregate attempted:
592tests/586pass/6fail/0skip/0cancel. The remaining207 files, scripts/test-config.sh,
all4 mutants and restored whole-file positive controls did not run.
All6 failures share one fixture defect: bootBusiness never creates `<temp>/data`
before startBroker; Store at store.mjs:163 refuses its missing dataRoot with ENOENT.
Affected new cases: fire-before-delist, failed-PUT fire, revision-race fire, hire issue,
failed-issue hire and newly-hired preflight. None reached the intended real-bus
behavior. The24 controller cases passed; no old test failed in the attempted prefix.
Recommended correction (not made): create that fixture data directory at0700 before
startBroker, preserving all assertions. Await Sage before any test edit or rerun.
Reported first RED in magica-331-0014. Slot returned in -0015 at05:10:48Z, with no
gate process remaining; Sage's288 landing gate can proceed. Do not move in-review
on this RED result. Complete per-run rc/counts/nice/hash receipts are green-receipts.json.
Raw text logs are retained off-repo under /mnt/storage/scratch/tmp/JT6SN5/ with SHA256
pins in that receipt. Archiving copies verified bytes across filesystems before
removing the untracked worktree copies; it did not change any log contents.
## 2026-10-25 — r2 fixture-only correction authorized (sage-4422)
Added exactly `fs.mkdirSync(join(f.root, 'data'), { mode: 0o700 })` inside
bootBusiness before startBroker in packages/bus/tests/model-keys.test.mjs. No
production code, assertion, timeout or other test file changed. No test run.
There was no build-time whole-file run of model-keys.test.mjs. The original RED
slot (sage-4374) covered only client-probe, model-credential-store, CLI guards and
bus model-proxy; this integration file was authored afterward, with execution
held for separate GREEN CLEAR. Its first whole-file run was file55 of sage-4420
on a86a881e1:24 controller cases passed;6 integration fixtures failed in Store's
dataRoot lstat before their intended assertions. That explains why the six errors
were absent from the build-time evidence. It was not a prior passing run.
No intended fail-closed refusal path is removed by this correction. None of the
six cases asserted ENOENT or deliberately omitted dataRoot: each failed during
shared fixture setup. Store's missing-directory refusal is unchanged production
code. The credential-revoke-failed, proxy-revoke-revision-conflict and
proxy-key-issue-failed cases retain their original assertions and remote fault
injection. They can first reach those paths in r2. The successful fire/hire and
newly-hired-preflight assertions also first become reachable in r2; another RED
is possible. No separate test by a new refusal code applies to this fixture
precondition repair (no refusal code or production guard was added/changed).
r2 request: restart full GREEN from file1, all262 whole files, test-config.sh,
all4 mutants, each restored with a whole-file positive control; stop first RED.
No tests until Sage CLEAR after288 landing and Poe's330 focused block.
Frozen run-list SHA256:512d0c1c8823ff32a1c3308bd08950595e19ac87668fef3569be9c7aac3db674.
Frozen import-manifest SHA256:a026f5637caf13bfac121b3fd6637e7052d1f90fa1a4114a94e59edf60344875.
The import edges and262 file list are unchanged by this mkdir; case1-accept remains excluded.
@@ -0,0 +1,10 @@
{
"from": "78b69e1e6",
"to": "775bbe236",
"greenFiles": [],
"changedModules": [],
"ownDocs": [
"BUILD-LOG.md",
"docs/SESSIONS.md"
]
}
@@ -0,0 +1,262 @@
packages/bus/tests/access-revoke.test.mjs
packages/bus/tests/agents.test.mjs
packages/bus/tests/approval-binding.test.mjs
packages/bus/tests/broker.test.mjs
packages/bus/tests/business.test.mjs
packages/bus/tests/channel-proof.test.mjs
packages/bus/tests/chief-of-staff.test.mjs
packages/bus/tests/citation-availability.test.mjs
packages/bus/tests/citation-guard.test.mjs
packages/bus/tests/citation-runtime.test.mjs
packages/bus/tests/comment-cursors.test.mjs
packages/bus/tests/console-decide.test.mjs
packages/bus/tests/creative.test.mjs
packages/bus/tests/credential-claim.test.mjs
packages/bus/tests/credential-status.test.mjs
packages/bus/tests/credential-store.test.mjs
packages/bus/tests/credential-verbs.test.mjs
packages/bus/tests/credentials.test.mjs
packages/bus/tests/csuite-messaging.test.mjs
packages/bus/tests/daily-quota-runtime.test.mjs
packages/bus/tests/decision-close.test.mjs
packages/bus/tests/decision-route.test.mjs
packages/bus/tests/decisions.test.mjs
packages/bus/tests/end-launch.test.mjs
packages/bus/tests/fire-revokes-grants.test.mjs
packages/bus/tests/gitea-grant-docker.test.mjs
packages/bus/tests/gitea-grant.test.mjs
packages/bus/tests/grant-enroll.test.mjs
packages/bus/tests/grant-executor.test.mjs
packages/bus/tests/grant-host.test.mjs
packages/bus/tests/grant-migration.test.mjs
packages/bus/tests/grant-scope-runtime.test.mjs
packages/bus/tests/grant-scope.test.mjs
packages/bus/tests/hard-waits.test.mjs
packages/bus/tests/host-run.test.mjs
packages/bus/tests/human.test.mjs
packages/bus/tests/instances-verb.test.mjs
packages/bus/tests/land.test.mjs
packages/bus/tests/launch-notice.test.mjs
packages/bus/tests/launcher-released.test.mjs
packages/bus/tests/library-credential.test.mjs
packages/bus/tests/library-lookups.test.mjs
packages/bus/tests/library-sync.test.mjs
packages/bus/tests/library.test.mjs
packages/bus/tests/lifecycle-revoke.test.mjs
packages/bus/tests/lifecycle-verbs.test.mjs
packages/bus/tests/limit-stop.test.mjs
packages/bus/tests/limits-runtime.test.mjs
packages/bus/tests/limits.test.mjs
packages/bus/tests/message-citation.test.mjs
packages/bus/tests/message-filter.test.mjs
packages/bus/tests/messages.test.mjs
packages/bus/tests/messaging.test.mjs
packages/bus/tests/model-credential-store.test.mjs
packages/bus/tests/model-keys.test.mjs
packages/bus/tests/model-proxy.test.mjs
packages/bus/tests/model-stop.test.mjs
packages/bus/tests/names.test.mjs
packages/bus/tests/peer-transport.test.mjs
packages/bus/tests/peercred.test.mjs
packages/bus/tests/plugins-verb.test.mjs
packages/bus/tests/principal.test.mjs
packages/bus/tests/process.test.mjs
packages/bus/tests/processing-runtime.test.mjs
packages/bus/tests/processing-timeout.test.mjs
packages/bus/tests/processing.test.mjs
packages/bus/tests/project-await.test.mjs
packages/bus/tests/project-files.test.mjs
packages/bus/tests/project-runtime.test.mjs
packages/bus/tests/publish.test.mjs
packages/bus/tests/reload.test.mjs
packages/bus/tests/replies.test.mjs
packages/bus/tests/requirements.test.mjs
packages/bus/tests/review.test.mjs
packages/bus/tests/revoke-window.test.mjs
packages/bus/tests/room-check.test.mjs
packages/bus/tests/rooms.test.mjs
packages/bus/tests/routes-verb.test.mjs
packages/bus/tests/session-clear.test.mjs
packages/bus/tests/session-compactions.test.mjs
packages/bus/tests/sessions.test.mjs
packages/bus/tests/single-use.test.mjs
packages/bus/tests/spec-review.test.mjs
packages/bus/tests/spec-socket.test.mjs
packages/bus/tests/spec.test.mjs
packages/bus/tests/staff-request.test.mjs
packages/bus/tests/state.test.mjs
packages/bus/tests/task-page.test.mjs
packages/bus/tests/task-read.test.mjs
packages/bus/tests/tasks.test.mjs
packages/bus/tests/tester-threads.test.mjs
packages/bus/tests/tester-writes.test.mjs
packages/bus/tests/tester.test.mjs
packages/bus/tests/thread-claims.test.mjs
packages/bus/tests/thread-migration.test.mjs
packages/bus/tests/thread-shares.test.mjs
packages/bus/tests/threads.test.mjs
packages/bus/tests/transport.test.mjs
packages/bus/tests/usage-broker.test.mjs
packages/bus/tests/usage-runtime.test.mjs
packages/bus/tests/user-principal.test.mjs
packages/bus/tests/viewer.test.mjs
packages/bus/tests/web.test.mjs
packages/bus/tests/workflow-roles.test.mjs
packages/business/tests/case1-business-file.test.mjs
packages/cli/tests/access-boot.test.mjs
packages/cli/tests/access.test.mjs
packages/cli/tests/app.test.mjs
packages/cli/tests/boot-status.test.mjs
packages/cli/tests/case1-hostcheck.test.mjs
packages/cli/tests/case2-install.test.mjs
packages/cli/tests/case2-persona-import.test.mjs
packages/cli/tests/case2-unit.test.mjs
packages/cli/tests/channel-cli.test.mjs
packages/cli/tests/channel-recovery.test.mjs
packages/cli/tests/cli.test.mjs
packages/cli/tests/compactions-launch.test.mjs
packages/cli/tests/config.test.mjs
packages/cli/tests/credentials.test.mjs
packages/cli/tests/federation-acceptance.test.mjs
packages/cli/tests/follow.test.mjs
packages/cli/tests/git-socket.test.mjs
packages/cli/tests/grants-scope.test.mjs
packages/cli/tests/grants.test.mjs
packages/cli/tests/host.test.mjs
packages/cli/tests/hosts.test.mjs
packages/cli/tests/idle-stop-race.test.mjs
packages/cli/tests/instances.test.mjs
packages/cli/tests/launch-account.test.mjs
packages/cli/tests/launch-limit-checks.test.mjs
packages/cli/tests/launch-limits.test.mjs
packages/cli/tests/launch-routes.test.mjs
packages/cli/tests/launcher.test.mjs
packages/cli/tests/lifecycle.test.mjs
packages/cli/tests/limits-tokens.test.mjs
packages/cli/tests/limits.test.mjs
packages/cli/tests/mark-read.test.mjs
packages/cli/tests/model-proxy-guards.test.mjs
packages/cli/tests/model-proxy.test.mjs
packages/cli/tests/model-stall.test.mjs
packages/cli/tests/names.test.mjs
packages/cli/tests/notifier.test.mjs
packages/cli/tests/optional-tracker.test.mjs
packages/cli/tests/other-tree.test.mjs
packages/cli/tests/processing-cli.test.mjs
packages/cli/tests/project-name-repo.test.mjs
packages/cli/tests/project.test.mjs
packages/cli/tests/publish-socket.test.mjs
packages/cli/tests/reply-limits.test.mjs
packages/cli/tests/revoke-window.test.mjs
packages/cli/tests/session-clear.test.mjs
packages/cli/tests/session-cli.test.mjs
packages/cli/tests/session-launch.test.mjs
packages/cli/tests/stack-copy.test.mjs
packages/cli/tests/stack-instances.test.mjs
packages/cli/tests/tester-http.test.mjs
packages/cli/tests/tester-reads.test.mjs
packages/cli/tests/tester-threads.test.mjs
packages/cli/tests/tester.test.mjs
packages/cli/tests/thread-cli.test.mjs
packages/cli/tests/thread-launch.test.mjs
packages/cli/tests/thread-revoke.test.mjs
packages/cli/tests/trackers-boot.test.mjs
packages/cli/tests/transport.test.mjs
packages/cli/tests/usage.test.mjs
packages/cli/tests/verbs.test.mjs
packages/harness/tests/citation-runner.test.mjs
packages/harness/tests/claude-session.test.mjs
packages/harness/tests/creative.test.mjs
packages/harness/tests/first-output.test.mjs
packages/harness/tests/pi-session.test.mjs
packages/harness/tests/raise-domain.test.mjs
packages/harness/tests/runner.test.mjs
packages/harness/tests/skills.test.mjs
packages/harness/tests/spec-tools.test.mjs
packages/harness/tests/tools.test.mjs
packages/model-proxy/tests/client-probe.test.mjs
packages/model-proxy/tests/client.test.mjs
packages/model-proxy/tests/config-unit.test.mjs
packages/model-proxy/tests/fetch.test.mjs
packages/model-proxy/tests/management-key-encoding.test.mjs
packages/model-proxy/tests/p2-settings.test.mjs
packages/model-proxy/tests/runner.test.mjs
packages/model-proxy/tests/settings.test.mjs
packages/processing-control/tests/task.test.mjs
packages/seat/tests/sandbox.test.mjs
packages/seat/tests/session.test.mjs
packages/tasks/tests/adapter.test.mjs
packages/tasks/tests/comment-restart.test.mjs
packages/tasks/tests/fields.test.mjs
packages/tasks/tests/processing.test.mjs
packages/tasks/tests/shapes.test.mjs
packages/tasks/tests/startup.test.mjs
packages/tasks/tests/sync.test.mjs
packages/tasks/tests/tree.test.mjs
packages/tasks/tests/verbs.test.mjs
packages/webui/tests/agents-browser.test.mjs
packages/webui/tests/agents.test.mjs
packages/webui/tests/assistant.test.mjs
packages/webui/tests/board-held.test.mjs
packages/webui/tests/browser-edge.test.mjs
packages/webui/tests/browser.test.mjs
packages/webui/tests/bus-browser.test.mjs
packages/webui/tests/bus.test.mjs
packages/webui/tests/channel-chat.test.mjs
packages/webui/tests/chat-browser.test.mjs
packages/webui/tests/chat-instances.test.mjs
packages/webui/tests/chat-replies-browser.test.mjs
packages/webui/tests/chat-sessions-access.test.mjs
packages/webui/tests/chat.test.mjs
packages/webui/tests/conversation.test.mjs
packages/webui/tests/decide-browser.test.mjs
packages/webui/tests/decide-sso.test.mjs
packages/webui/tests/decision-http.test.mjs
packages/webui/tests/discord.test.mjs
packages/webui/tests/docs-browser.test.mjs
packages/webui/tests/editor-browser.test.mjs
packages/webui/tests/editor.test.mjs
packages/webui/tests/granted-user.test.mjs
packages/webui/tests/history-return-flow.test.mjs
packages/webui/tests/instances-browser.test.mjs
packages/webui/tests/instances.test.mjs
packages/webui/tests/library-cli.test.mjs
packages/webui/tests/library-routes.test.mjs
packages/webui/tests/library-status.test.mjs
packages/webui/tests/limits-browser.test.mjs
packages/webui/tests/limits-route.test.mjs
packages/webui/tests/messages.test.mjs
packages/webui/tests/pinned-copy.test.mjs
packages/webui/tests/preview.test.mjs
packages/webui/tests/processing-browser.test.mjs
packages/webui/tests/processing.test.mjs
packages/webui/tests/project-file.test.mjs
packages/webui/tests/project-name-repo.test.mjs
packages/webui/tests/proof-cli.test.mjs
packages/webui/tests/proof.test.mjs
packages/webui/tests/reads.test.mjs
packages/webui/tests/relaunch.test.mjs
packages/webui/tests/return-flow.test.mjs
packages/webui/tests/room-browser.test.mjs
packages/webui/tests/room-http.test.mjs
packages/webui/tests/routes-browser.test.mjs
packages/webui/tests/routes.test.mjs
packages/webui/tests/send-browser.test.mjs
packages/webui/tests/send.test.mjs
packages/webui/tests/serve.test.mjs
packages/webui/tests/settings-form.test.mjs
packages/webui/tests/settings-write.test.mjs
packages/webui/tests/shell.test.mjs
packages/webui/tests/sso-browser.test.mjs
packages/webui/tests/sso.test.mjs
packages/webui/tests/tester-browser.test.mjs
packages/webui/tests/tester-cli.test.mjs
packages/webui/tests/tester-listener.test.mjs
packages/webui/tests/tester-socket.test.mjs
packages/webui/tests/thread-claims-browser.test.mjs
packages/webui/tests/thread-claims.test.mjs
packages/webui/tests/thread-sharing.test.mjs
packages/webui/tests/usage-browser.test.mjs
packages/webui/tests/usage.test.mjs
packages/webui/tests/user-option-swap.test.mjs
packages/webui/tests/views.test.mjs
@@ -0,0 +1,929 @@
{
"candidate": "a86a881e1b2eb38bd0f1dd25fa094f1ea60df2ca",
"clear": "sage-4420",
"driverRc": 1,
"runs": [
{
"file": "packages/bus/tests/access-revoke.test.mjs",
"rc": 0,
"nice": 19,
"tests": 10,
"pass": 10,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_access-revoke.test.mjs.log",
"sha256": "7185f6dd41807d8f9644ce60eb7c92d551d053e82ba7600ee57d3145df1a24a1",
"bytes": 2680
}
},
{
"file": "packages/bus/tests/agents.test.mjs",
"rc": 0,
"nice": 19,
"tests": 11,
"pass": 11,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_agents.test.mjs.log",
"sha256": "25e44548c0d06bdf3ee0d6fad285e2ca9b647f9f76e74a48953373fef0ad0b60",
"bytes": 2726
}
},
{
"file": "packages/bus/tests/approval-binding.test.mjs",
"rc": 0,
"nice": 19,
"tests": 6,
"pass": 6,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_approval-binding.test.mjs.log",
"sha256": "ded96e9c324eba0ef99e8786c63dc11318948ee05ee89ce4d30eb5a73d11dd23",
"bytes": 1728
}
},
{
"file": "packages/bus/tests/broker.test.mjs",
"rc": 0,
"nice": 19,
"tests": 16,
"pass": 16,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_broker.test.mjs.log",
"sha256": "7df7961cd813cb4d6dcf58cde1cb646f4171ff10cea666ca9f387a35c048cbb0",
"bytes": 4146
}
},
{
"file": "packages/bus/tests/business.test.mjs",
"rc": 0,
"nice": 19,
"tests": 1,
"pass": 1,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_business.test.mjs.log",
"sha256": "a868ae6324a84660a3481dcfe6cb4baa0093c95385a6e3622b4a3c0d23f274bd",
"bytes": 379
}
},
{
"file": "packages/bus/tests/channel-proof.test.mjs",
"rc": 0,
"nice": 19,
"tests": 2,
"pass": 2,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_channel-proof.test.mjs.log",
"sha256": "42f6b4ef333e32f3a0cf7e4270a2725ab0841023c5c1708d3cb5d8ceb6dd5b34",
"bytes": 665
}
},
{
"file": "packages/bus/tests/chief-of-staff.test.mjs",
"rc": 0,
"nice": 19,
"tests": 42,
"pass": 42,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_chief-of-staff.test.mjs.log",
"sha256": "821cb17676c594edfc80be45f7a2de7ef561483d54f46d7567c0afc6820547e8",
"bytes": 13022
}
},
{
"file": "packages/bus/tests/citation-availability.test.mjs",
"rc": 0,
"nice": 19,
"tests": 3,
"pass": 3,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_citation-availability.test.mjs.log",
"sha256": "b916b86842f9813af41986b7ecc18d81ada8dd9e90e1966eb63d51bcebab10bd",
"bytes": 838
}
},
{
"file": "packages/bus/tests/citation-guard.test.mjs",
"rc": 0,
"nice": 19,
"tests": 13,
"pass": 13,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_citation-guard.test.mjs.log",
"sha256": "574e56ff2094b869b1b595a88a2ddf2a4076357808b530d8a50f05ea6cf8b6e0",
"bytes": 3139
}
},
{
"file": "packages/bus/tests/citation-runtime.test.mjs",
"rc": 0,
"nice": 19,
"tests": 1,
"pass": 1,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_citation-runtime.test.mjs.log",
"sha256": "7181a9160812df7ba580e7dd0daa1a7e56dd410d2dcc6b76643d9109a1de77bc",
"bytes": 348
}
},
{
"file": "packages/bus/tests/comment-cursors.test.mjs",
"rc": 0,
"nice": 19,
"tests": 2,
"pass": 2,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_comment-cursors.test.mjs.log",
"sha256": "0c23b7caf9b593f9081f09ad753faba419ff0812e3c99e0fe695ad8d3f15f86c",
"bytes": 575
}
},
{
"file": "packages/bus/tests/console-decide.test.mjs",
"rc": 0,
"nice": 19,
"tests": 12,
"pass": 12,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_console-decide.test.mjs.log",
"sha256": "66862154416e869dcc9651335ddc8c8a9c2b6703da6d602b76353a8a848f6d97",
"bytes": 3057
}
},
{
"file": "packages/bus/tests/creative.test.mjs",
"rc": 0,
"nice": 19,
"tests": 4,
"pass": 4,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_creative.test.mjs.log",
"sha256": "3ff582af06543dd057416c374a9f1f607028ccbae6cb8ba7d6c2c2b4557a8dae",
"bytes": 1394
}
},
{
"file": "packages/bus/tests/credential-claim.test.mjs",
"rc": 0,
"nice": 19,
"tests": 8,
"pass": 8,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_credential-claim.test.mjs.log",
"sha256": "c8061939ad10d2762466434e3bc223f1114b70e79bb2a0d0ba1c856bd6a1e029",
"bytes": 1916
}
},
{
"file": "packages/bus/tests/credential-status.test.mjs",
"rc": 0,
"nice": 19,
"tests": 4,
"pass": 4,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_credential-status.test.mjs.log",
"sha256": "27204c46e50b28ec847f5e17dd3ecbed6f55a200a37c7fd97a3606d3ab459523",
"bytes": 1076
}
},
{
"file": "packages/bus/tests/credential-store.test.mjs",
"rc": 0,
"nice": 19,
"tests": 12,
"pass": 12,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_credential-store.test.mjs.log",
"sha256": "5b51807f0dcc87fe16a172f2960a273fc7321c450ce4070d3d683975f5dd1528",
"bytes": 2845
}
},
{
"file": "packages/bus/tests/credential-verbs.test.mjs",
"rc": 0,
"nice": 19,
"tests": 2,
"pass": 2,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_credential-verbs.test.mjs.log",
"sha256": "059f81fc3d74a0628b6b92c2c2eaee7b73268c39a5ca4b412a53755dfe023ce4",
"bytes": 701
}
},
{
"file": "packages/bus/tests/credentials.test.mjs",
"rc": 0,
"nice": 19,
"tests": 5,
"pass": 5,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_credentials.test.mjs.log",
"sha256": "18041b027496a1a21bd8e849abba5c41e6c9b5073788cd105f3d0505461faf0c",
"bytes": 1275
}
},
{
"file": "packages/bus/tests/csuite-messaging.test.mjs",
"rc": 0,
"nice": 19,
"tests": 16,
"pass": 16,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_csuite-messaging.test.mjs.log",
"sha256": "cdc3a39827c6e5d47722b5b4b3fe440458280335c5a3c773cf8b664f067a95c9",
"bytes": 4336
}
},
{
"file": "packages/bus/tests/daily-quota-runtime.test.mjs",
"rc": 0,
"nice": 19,
"tests": 4,
"pass": 4,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_daily-quota-runtime.test.mjs.log",
"sha256": "7c235a488ec9fb43dc3dec36b8c9ef1638dfae0c726ac11552dc638d0afb961f",
"bytes": 2132
}
},
{
"file": "packages/bus/tests/decision-close.test.mjs",
"rc": 0,
"nice": 19,
"tests": 8,
"pass": 8,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_decision-close.test.mjs.log",
"sha256": "f5451dee1427c69c965bd062a90a2db382f5ae1241e9e06b375d33bddb712441",
"bytes": 1970
}
},
{
"file": "packages/bus/tests/decision-route.test.mjs",
"rc": 0,
"nice": 19,
"tests": 5,
"pass": 5,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_decision-route.test.mjs.log",
"sha256": "f47709376bbc68c82037bee60e7b58fa3d99d807341716fc4ecc958517babade",
"bytes": 1184
}
},
{
"file": "packages/bus/tests/decisions.test.mjs",
"rc": 0,
"nice": 19,
"tests": 7,
"pass": 7,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_decisions.test.mjs.log",
"sha256": "2e0f2cc696b3db69fec6341134fc6568e9767eb52712f7e7eaeb8fb8841a7022",
"bytes": 1576
}
},
{
"file": "packages/bus/tests/end-launch.test.mjs",
"rc": 0,
"nice": 19,
"tests": 7,
"pass": 7,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_end-launch.test.mjs.log",
"sha256": "3e03479aaad4bd7d7cd792bb2497d22e975cf664dc7c521818e133c2c347b1ad",
"bytes": 1648
}
},
{
"file": "packages/bus/tests/fire-revokes-grants.test.mjs",
"rc": 0,
"nice": 19,
"tests": 24,
"pass": 24,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_fire-revokes-grants.test.mjs.log",
"sha256": "289a308d5e7e2cd47ed7409f3ac670263712f33e430a4b508af85a21010db810",
"bytes": 7270
}
},
{
"file": "packages/bus/tests/gitea-grant-docker.test.mjs",
"rc": 0,
"nice": 19,
"tests": 2,
"pass": 2,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_gitea-grant-docker.test.mjs.log",
"sha256": "4aaf1b93ccb237997cc08cb4ccfe5e1513cfdc546a9780035ec38e19fc7888d0",
"bytes": 1460
}
},
{
"file": "packages/bus/tests/gitea-grant.test.mjs",
"rc": 0,
"nice": 19,
"tests": 66,
"pass": 66,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_gitea-grant.test.mjs.log",
"sha256": "efe0b05e956ebc132ea23371fb051e78901515710724482c3242c86ab60aa88c",
"bytes": 16582
}
},
{
"file": "packages/bus/tests/grant-enroll.test.mjs",
"rc": 0,
"nice": 19,
"tests": 24,
"pass": 24,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_grant-enroll.test.mjs.log",
"sha256": "47ee11ac91ce1ccb33c81c96cec299d67651d77f374d32823650d062d41e51c5",
"bytes": 6364
}
},
{
"file": "packages/bus/tests/grant-executor.test.mjs",
"rc": 0,
"nice": 19,
"tests": 42,
"pass": 42,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_grant-executor.test.mjs.log",
"sha256": "f99eabf74f2225504c93e9e1c604f553ab7f5429c45c1da38987bc52589db208",
"bytes": 11755
}
},
{
"file": "packages/bus/tests/grant-host.test.mjs",
"rc": 0,
"nice": 19,
"tests": 10,
"pass": 10,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_grant-host.test.mjs.log",
"sha256": "a852e12076fabea11eba97121036cbfb2d445a66fc75317db81f6f9a7330ac93",
"bytes": 2850
}
},
{
"file": "packages/bus/tests/grant-migration.test.mjs",
"rc": 0,
"nice": 19,
"tests": 6,
"pass": 6,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_grant-migration.test.mjs.log",
"sha256": "9c6fc5c4e8c70171b6e14247c45f2bfd2bb36e3194b27b09e37e8b6d157e74ab",
"bytes": 1730
}
},
{
"file": "packages/bus/tests/grant-scope-runtime.test.mjs",
"rc": 0,
"nice": 19,
"tests": 1,
"pass": 1,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_grant-scope-runtime.test.mjs.log",
"sha256": "ce14284e1b8084ce976033c4ee820fc64d7124785a2a842079565a4b71902b0a",
"bytes": 472
}
},
{
"file": "packages/bus/tests/grant-scope.test.mjs",
"rc": 0,
"nice": 19,
"tests": 14,
"pass": 14,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_grant-scope.test.mjs.log",
"sha256": "3c788044749f391c7f804f4c58879f0a0b6d168466a6a17af9d8ee3f36f648e1",
"bytes": 3755
}
},
{
"file": "packages/bus/tests/hard-waits.test.mjs",
"rc": 0,
"nice": 19,
"tests": 13,
"pass": 13,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_hard-waits.test.mjs.log",
"sha256": "d0ba56411e81744c5f00e42f4dad73038fe5dd131a88d26114b147f2baafe3ea",
"bytes": 3755
}
},
{
"file": "packages/bus/tests/host-run.test.mjs",
"rc": 0,
"nice": 19,
"tests": 26,
"pass": 26,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_host-run.test.mjs.log",
"sha256": "8a8e8bc2f8fa4c2bf8f85bf17cee17526c4b7206f7569f69093d730cbaa626c8",
"bytes": 6222
}
},
{
"file": "packages/bus/tests/human.test.mjs",
"rc": 0,
"nice": 19,
"tests": 4,
"pass": 4,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_human.test.mjs.log",
"sha256": "d03a9c57f3905bd79875038e9fd55151e8d5721f8962ea3da2bcb2814fbeb5f4",
"bytes": 1055
}
},
{
"file": "packages/bus/tests/instances-verb.test.mjs",
"rc": 0,
"nice": 19,
"tests": 3,
"pass": 3,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_instances-verb.test.mjs.log",
"sha256": "89303a2faae0d17d4a3caa064c1270a520c2f3efbb472cc3637e8151ca1b4456",
"bytes": 1350
}
},
{
"file": "packages/bus/tests/land.test.mjs",
"rc": 0,
"nice": 19,
"tests": 18,
"pass": 18,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_land.test.mjs.log",
"sha256": "a8abb96b31ec84c7552994568e96ef6a782843e4790972b89c5f73d9a9420494",
"bytes": 5269
}
},
{
"file": "packages/bus/tests/launch-notice.test.mjs",
"rc": 0,
"nice": 19,
"tests": 8,
"pass": 8,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_launch-notice.test.mjs.log",
"sha256": "c3083725d9c5189ded3a35f64f54e084a2e4e7c425ebce834b039a597fbbc194",
"bytes": 2033
}
},
{
"file": "packages/bus/tests/launcher-released.test.mjs",
"rc": 0,
"nice": 19,
"tests": 3,
"pass": 3,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_launcher-released.test.mjs.log",
"sha256": "2ee2f6121f7bf0e6895f22758fbddf180107525915dfbc1e6b2ec1cacbc3755f",
"bytes": 973
}
},
{
"file": "packages/bus/tests/library-credential.test.mjs",
"rc": 0,
"nice": 19,
"tests": 6,
"pass": 6,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_library-credential.test.mjs.log",
"sha256": "ff6c80cb96a3c014b603dc977818e4ba9ce5fbd72216073fafe64bae6b72c177",
"bytes": 1778
}
},
{
"file": "packages/bus/tests/library-lookups.test.mjs",
"rc": 0,
"nice": 19,
"tests": 12,
"pass": 12,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_library-lookups.test.mjs.log",
"sha256": "de782424a01ef8eff1cc3984ea337a27674e56d768e2d3fe9934263746c6078e",
"bytes": 2868
}
},
{
"file": "packages/bus/tests/library-sync.test.mjs",
"rc": 0,
"nice": 19,
"tests": 6,
"pass": 6,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_library-sync.test.mjs.log",
"sha256": "5db561e52f7eb383be8fe2cce00e4fb87ee38baea85fc2e7f896b7aeefe498ba",
"bytes": 1705
}
},
{
"file": "packages/bus/tests/library.test.mjs",
"rc": 0,
"nice": 19,
"tests": 6,
"pass": 6,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_library.test.mjs.log",
"sha256": "de4d1a49b3cfd8eae0f541c22f19e6cdefe86832ac31b0146d0ca9441758fd40",
"bytes": 1588
}
},
{
"file": "packages/bus/tests/lifecycle-revoke.test.mjs",
"rc": 0,
"nice": 19,
"tests": 8,
"pass": 8,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_lifecycle-revoke.test.mjs.log",
"sha256": "9ee07e863a8cd32aa798f2e4da196679d2d9e5c9875df63668b4bfa8c8bbe527",
"bytes": 2584
}
},
{
"file": "packages/bus/tests/lifecycle-verbs.test.mjs",
"rc": 0,
"nice": 19,
"tests": 13,
"pass": 13,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_lifecycle-verbs.test.mjs.log",
"sha256": "afecc3369f572380277b67b6721a59b2e782ff692423d1163e73980fd44cd514",
"bytes": 4523
}
},
{
"file": "packages/bus/tests/limit-stop.test.mjs",
"rc": 0,
"nice": 19,
"tests": 5,
"pass": 5,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_limit-stop.test.mjs.log",
"sha256": "ea5396ffabfc01eedbae049c2f7f6fc38ab3fd49a8b09466bd44888d285ae039",
"bytes": 1461
}
},
{
"file": "packages/bus/tests/limits-runtime.test.mjs",
"rc": 0,
"nice": 19,
"tests": 5,
"pass": 5,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_limits-runtime.test.mjs.log",
"sha256": "3d7eeed3cc199962d0d03e97205c5a5f8abfcc2ad726514d9666a4bd1d0cd208",
"bytes": 2607
}
},
{
"file": "packages/bus/tests/limits.test.mjs",
"rc": 0,
"nice": 19,
"tests": 3,
"pass": 3,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_limits.test.mjs.log",
"sha256": "117bbb47d775f74796ec9bde6c961b0750816da4f5eaf9d25c465821ab47a8d5",
"bytes": 1033
}
},
{
"file": "packages/bus/tests/message-citation.test.mjs",
"rc": 0,
"nice": 19,
"tests": 3,
"pass": 3,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_message-citation.test.mjs.log",
"sha256": "deecc45e1f633aaeb881c75ef26887e03f7c95acf174951c168776dbe884f042",
"bytes": 838
}
},
{
"file": "packages/bus/tests/message-filter.test.mjs",
"rc": 0,
"nice": 19,
"tests": 6,
"pass": 6,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_message-filter.test.mjs.log",
"sha256": "9aa7fc406400e8ceac0b12990350d51c921a349a72f59b4412c437e17516d222",
"bytes": 1732
}
},
{
"file": "packages/bus/tests/messages.test.mjs",
"rc": 0,
"nice": 19,
"tests": 4,
"pass": 4,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_messages.test.mjs.log",
"sha256": "f226ddef0348557b84e0235a91d72533950fb7967d3db979ef22be674f0882a3",
"bytes": 977
}
},
{
"file": "packages/bus/tests/messaging.test.mjs",
"rc": 0,
"nice": 19,
"tests": 13,
"pass": 13,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_messaging.test.mjs.log",
"sha256": "9f71d7d8f9dad4bd1142dd64e3e96aa5cfa4c92de013af62c7f4cc3eb81ac2ff",
"bytes": 2951
}
},
{
"file": "packages/bus/tests/model-credential-store.test.mjs",
"rc": 0,
"nice": 19,
"tests": 7,
"pass": 7,
"fail": 0,
"cancelled": 0,
"skipped": 0,
"failedNames": [],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_model-credential-store.test.mjs.log",
"sha256": "edd92bfb70c55f3f67acd783f793914f0f3a017bca5f54c656c50190936864c5",
"bytes": 1673
}
},
{
"file": "packages/bus/tests/model-keys.test.mjs",
"rc": 1,
"nice": 19,
"tests": 30,
"pass": 24,
"fail": 6,
"cancelled": 0,
"skipped": 0,
"failedNames": [
"bus fire proves remote 401 before delisting the agent and deleting model file",
"credential-revoke-failed: bus fire on failed remote PUT keeps membership and model record",
"proxy-revoke-revision-conflict: racing organization write keeps record, next boot finishes intent",
"bus hire issues and confirms implicit model key before publishing new membership",
"proxy-key-issue-failed: bus hire failure publishes neither membership nor model file",
"newly hired role can use runtime preflight without a broker restart"
],
"log": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-packages_bus_tests_model-keys.test.mjs.log",
"sha256": "e664374e7c66183d0b8eb723eb7f27c7c49c344548f7e44dbe8f1c28d01c0c33",
"bytes": 13026
}
}
],
"totals": {
"tests": 592,
"pass": 586,
"fail": 6,
"cancelled": 0,
"skipped": 0
},
"notRun": {
"remainingWholeFiles": 207,
"testConfig": true,
"mutants": [
"revoke-skips-confirming-GET",
"reconcile-removes-foreign",
"preflight-accepts-401",
"reconcile-ignores-revoking"
],
"positiveControls": true
},
"archive": "/mnt/storage/scratch/tmp/JT6SN5",
"driverLog": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-driver.log",
"sha256": "ff6366c16fd081250434875f6740492ec2a359e9c9a2565dfad2e4f88149280b",
"bytes": 2613
},
"runManifest": {
"path": "/mnt/storage/scratch/tmp/JT6SN5/green-run-manifest.txt",
"sha256": "31fb96199ad87d10abc1ada4104cbb3a1db1c493a5282a3a6db0d717cdcc9b94",
"bytes": 4454
},
"rootCause": {
"code": "ENOENT",
"component": "Store dataRoot lstat",
"fixture": "packages/bus/tests/model-keys.test.mjs:175",
"summary": "bootBusiness does not create its fresh data directory before startBroker"
}
}
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Run only after Sage CLEAR. Every selected file runs whole and serially.
set -euo pipefail
repo=$(git rev-parse --show-toplevel)
evidence="$repo/agents/magica/work/queue-331"
while IFS= read -r test_file; do
[[ -n "$test_file" ]] || continue
[[ "$test_file" != packages/cli/tests/case1-accept.test.mjs ]] || exit 2
case_tmp=$(mktemp -d /mnt/storage/scratch/tmp/XXXXXX)
mkdir -p "$case_tmp/home" "$case_tmp/config"
name=${test_file//\//_}
printf '%s TMPDIR=%s\n' "$test_file" "$case_tmp" >> "$evidence/green-run-manifest.txt"
nice -n $((19 - $(nice))) bash -c '
printf "nice="; nice
exec env -u OPENAI_API_KEY -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN -u CLAUDE_CODE_OAUTH_TOKEN -u OPENROUTER_API_KEY -u ZAI_API_KEY -u MOSAIC_BUS_CAP TMPDIR="$1" HOME="$1/home" MOSAIC_CONFIG="$1/config/config.json" node --test --test-reporter=tap "$2"
' _ "$case_tmp" "$repo/$test_file" > "$evidence/green-$name.log" 2>&1 || {
printf 'RED %s; stop before rerun or changes\n' "$test_file"
exit 1
}
printf 'PASS %s\n' "$test_file"
done < "$evidence/green-files.txt"
export TMPDIR=$(mktemp -d /mnt/storage/scratch/tmp/XXXXXX)
nice -n $((19 - $(nice))) timeout -k 10 1800 "$repo/scripts/test-config.sh" > "$evidence/green-test-config.log" 2>&1
@@ -0,0 +1,70 @@
// Read-only import manifest; parse static requests with Node's module parser.
// Literal dynamic imports are tokenized outside comments/strings/regex/template text.
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import { execFileSync } from 'node:child_process';
const root = process.cwd();
const files = dir => fs.readdirSync(dir, { withFileTypes: true }).flatMap(e => e.isDirectory() && !['node_modules', '.git', 'dist', 'build'].includes(e.name)
? files(path.join(dir, e.name)) : e.isFile() && e.name.endsWith('.mjs') ? [path.join(dir, e.name)] : []);
const changed = [...new Set(execFileSync('git', ['ls-files', '--modified', '--others', '--exclude-standard'], { encoding: 'utf8' }).trim().split('\n'))];
const sources = new Set(changed.filter(p => /^(packages\/[^/]+\/src\/|scripts\/).*\.mjs$/.test(p)));
function dynamicImports(source) {
const tokens = []; let i = 0, previous = '';
while (i < source.length) {
const c = source[i];
if (/\s/.test(c)) { i++; continue; }
if (source.startsWith('//', i)) { i = source.indexOf('\n', i); if (i < 0) break; continue; }
if (source.startsWith('/*', i)) { const end = source.indexOf('*/', i + 2); i = end < 0 ? source.length : end + 2; continue; }
if (c === '"' || c === "'" || c === '`') {
const quote = c; let text = '', literal = true; i++;
while (i < source.length && source[i] !== quote) {
if (source[i] === '\\') { text += source[i++] + (source[i++] ?? ''); continue; }
if (quote === '`' && source.startsWith('${', i)) literal = false;
text += source[i++];
}
i++; tokens.push({ kind: literal ? 'string' : 'template', value: text }); previous = 'value'; continue;
}
if (c === '/' && !/^(value|\)|\])$/.test(previous)) {
i++; let inClass = false;
while (i < source.length) { if (source[i] === '\\') { i += 2; continue; } if (source[i] === '[') inClass = true; if (source[i] === ']') inClass = false; if (source[i++] === '/' && !inClass) break; }
while (/[A-Za-z]/.test(source[i] ?? '')) i++;
tokens.push({ kind: 'regex' }); previous = 'value'; continue;
}
if (/[A-Za-z_$]/.test(c)) {
const start = i++; while (/[A-Za-z0-9_$]/.test(source[i] ?? '')) i++;
const value = source.slice(start, i); tokens.push({ kind: 'id', value }); previous = ['return', 'throw', 'case', 'typeof', 'void', 'new', 'yield', 'await'].includes(value) ? value : 'value'; continue;
}
tokens.push({ kind: 'punct', value: c }); previous = c; i++;
}
const imports = [];
for (let n = 0; n < tokens.length - 3; n++) if (tokens[n].kind === 'id' && tokens[n].value === 'import'
&& tokens[n + 1].value === '(' && tokens[n + 2].kind === 'string' && [')', ','].includes(tokens[n + 3].value)) imports.push(tokens[n + 2].value);
return imports;
}
const graph = new Map(), parseFailures = [];
function imports(file) {
if (graph.has(file)) return graph.get(file);
if (!fs.existsSync(file)) return [];
const source = fs.readFileSync(file, 'utf8'); let requests;
try { requests = new vm.SourceTextModule(source).dependencySpecifiers; }
catch { parseFailures.push(path.relative(root, file)); return []; }
const dependencies = [...new Set([...requests, ...dynamicImports(source)])].filter(s => s.startsWith('.')).map(s => path.resolve(path.dirname(file), s));
graph.set(file, dependencies); return dependencies;
}
const tests = files(path.join(root, 'packages')).filter(p => p.endsWith('.test.mjs'));
function reaches(file, seen = new Set()) {
if (seen.has(file)) return false; seen.add(file);
if (sources.has(path.relative(root, file))) return true;
return imports(file).some(dependency => reaches(dependency, seen));
}
const excluded = 'packages/cli/tests/case1-accept.test.mjs';
const selected = tests.filter(file => {
const relative = path.relative(root, file);
return relative !== excluded && !relative.startsWith('packages/queue/') && (changed.includes(relative) || reaches(file));
}).map(p => path.relative(root, p)).sort();
const home = 'agents/magica/work/queue-331/';
fs.writeFileSync(home + 'green-files.txt', selected.join('\n') + '\n');
fs.writeFileSync(home + 'import-manifest.json', JSON.stringify({ sources: [...sources].sort(), tests: selected, excluded, parseFailures,
edges: Object.fromEntries([...graph].map(([file, deps]) => [path.relative(root, file), deps.map(p => path.relative(root, p))])) }, null, 2) + '\n');
process.stdout.write(JSON.stringify({ tests: selected.length, sources: sources.size, excluded, parseFailures }) + '\n');
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
// Run only with Sage's separate mutant CLEAR. Each source mutation restores in finally.
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { spawnSync } from 'node:child_process';
const root = process.cwd(), home = path.join(root, 'agents/magica/work/queue-331');
const file = path.join(root, 'packages/bus/src/model-keys.mjs'), original = fs.readFileSync(file, 'utf8');
const cases = [
{ name: 'revoke-skips-confirming-GET', from: "await this.#replace(before, before.filter(k => !keys.includes(k)));", to: "this.#sentinel(before); await this.#client.putKeys(before.filter(k => !keys.includes(k)));", failure: 'revoke confirms absence then polls delayed client refusal before local removal' },
{ name: 'reconcile-removes-foreign', from: "const removed = before.filter(k => k.startsWith(this.#prefix) && !k.startsWith(this.#prefix + 'sentinel-') && !retained.includes(k));", to: "const removed = before.filter(k => !k.startsWith(this.#prefix + 'sentinel-') && !retained.includes(k));", failure: 'reconcile restores missing recorded keys, removes own unknown, and preserves foreign and sentinel' },
{ name: 'preflight-accepts-401', from: "if (status < 200 || status >= 300) fail('proxy-key-refused');", to: "if (status !== 401 && (status < 200 || status >= 300)) fail('proxy-key-refused');", failure: 'proxy-key-refused: preflight rejects HTTP 401 with exactly one probe' },
{ name: 'reconcile-ignores-revoking', from: "const retained = records.filter(e => e.record.state !== 'revoking').flatMap(e => [e.record.key, ...e.record.retiring.map(r => r.key)]);\n const revoking = records.filter(e => e.record.state === 'revoking');", to: "const retained = records.flatMap(e => [e.record.key, ...e.record.retiring.map(r => r.key)]);\n const revoking = [];", failure: 'reconcile revoking never restores key and completes delayed revocation after restart' },
];
os.setPriority(0, 19);
const baseEnv = { ...process.env };
for (const name of Object.keys(baseEnv)) if (/(TOKEN|SECRET|PASSWORD|API_KEY|AUTH_SOCK|BUS_CAP)/.test(name)) delete baseEnv[name];
try {
for (const mutant of cases) {
if (original.split(mutant.from).length !== 2) throw Error('mutation anchor not unique: ' + mutant.name);
fs.writeFileSync(file, original.replace(mutant.from, mutant.to));
const scratch = spawnSync('mktemp', ['-d', '/mnt/storage/scratch/tmp/XXXXXX'], { encoding: 'utf8' }).stdout.trim();
if (!/^\/mnt\/storage\/scratch\/tmp\/[A-Za-z0-9]{6}$/.test(scratch)) throw Error('short scratch creation failed');
fs.mkdirSync(path.join(scratch, 'home')); fs.mkdirSync(path.join(scratch, 'config'));
const log = spawnSync(process.execPath, ['--test', '--test-reporter=tap', path.join(root, 'packages/bus/tests/model-keys.test.mjs')], {
cwd: root, encoding: 'utf8', timeout: 180000, maxBuffer: 8 * 1024 * 1024,
env: { ...baseEnv, TMPDIR: scratch, HOME: path.join(scratch, 'home'), MOSAIC_CONFIG: path.join(scratch, 'config/config.json') },
});
const output = (log.stdout ?? '') + (log.stderr ?? '');
fs.writeFileSync(path.join(home, 'mutant-' + mutant.name + '.log'), output);
const namedFailure = output.split('\n').some(line => /^not ok /.test(line) && line.includes(mutant.failure));
if (log.status === 0 || !namedFailure || log.error) throw Error('mutant did not fail its named behavior: ' + mutant.name);
process.stdout.write(mutant.name + ' killed; nice=' + os.getPriority(0) + '\n');
fs.writeFileSync(file, original);
}
} finally { fs.writeFileSync(file, original); }
@@ -0,0 +1,99 @@
nice=19
✔ proxy.init only accepts path-bound outside-agent human; refuses agent/tester/reader (21.226848ms)
✔ proxy.init proxy-key-invalid (13.453809ms)
✔ proxy.init proxy-config-invalid (12.565655ms)
✔ proxy.init invalid-request (12.106821ms)
✔ proxy.init not-configured (12.315024ms)
✔ proxy.init callback error redacts key and maps outcome to named refusal (11.717939ms)
✔ socket awaits proxy.init and does not return a Promise object or retain human cap (32.004316ms)
✔ proxy-init-failed: extra callback fields cannot return a management key (12.922711ms)
✖ proxy-key-invalid: broker rejects Latin-1 before initializer (11.977229ms)
✖ proxy-key-invalid: broker rejects leading space before initializer (11.442742ms)
✖ proxy-key-invalid: broker rejects trailing space before initializer (11.788604ms)
✖ proxy-key-invalid: broker rejects internal space before initializer (11.359554ms)
ℹ Error: Test "proxy-key-invalid: broker rejects Latin-1 before initializer" at packages/bus/tests/model-proxy.test.mjs:20:3 generated asynchronous activity after the test ended. This activity created the error "Error: storage-unavailable" and would have caused the test to fail, but instead triggered an unhandledRejection event.
ℹ Error: Test "proxy-key-invalid: broker rejects leading space before initializer" at packages/bus/tests/model-proxy.test.mjs:20:3 generated asynchronous activity after the test ended. This activity created the error "Error: storage-unavailable" and would have caused the test to fail, but instead triggered an unhandledRejection event.
ℹ Error: Test "proxy-key-invalid: broker rejects trailing space before initializer" at packages/bus/tests/model-proxy.test.mjs:20:3 generated asynchronous activity after the test ended. This activity created the error "Error: storage-unavailable" and would have caused the test to fail, but instead triggered an unhandledRejection event.
ℹ Error: Test "proxy-key-invalid: broker rejects internal space before initializer" at packages/bus/tests/model-proxy.test.mjs:20:3 generated asynchronous activity after the test ended. This activity created the error "Error: storage-unavailable" and would have caused the test to fail, but instead triggered an unhandledRejection event.
ℹ tests 12
ℹ suites 0
ℹ pass 8
ℹ fail 4
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 256.218396
✖ failing tests:
test at packages/bus/tests/model-proxy.test.mjs:20:3
✖ proxy-key-invalid: broker rejects Latin-1 before initializer (11.977229ms)
AssertionError [ERR_ASSERTION]: Missing expected exception.
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/bus/tests/model-proxy.test.mjs:23:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
operator: 'throws',
diff: 'simple'
}
test at packages/bus/tests/model-proxy.test.mjs:20:3
✖ proxy-key-invalid: broker rejects leading space before initializer (11.442742ms)
AssertionError [ERR_ASSERTION]: Missing expected exception.
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/bus/tests/model-proxy.test.mjs:23:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
operator: 'throws',
diff: 'simple'
}
test at packages/bus/tests/model-proxy.test.mjs:20:3
✖ proxy-key-invalid: broker rejects trailing space before initializer (11.788604ms)
AssertionError [ERR_ASSERTION]: Missing expected exception.
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/bus/tests/model-proxy.test.mjs:23:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
operator: 'throws',
diff: 'simple'
}
test at packages/bus/tests/model-proxy.test.mjs:20:3
✖ proxy-key-invalid: broker rejects internal space before initializer (11.359554ms)
AssertionError [ERR_ASSERTION]: Missing expected exception.
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/bus/tests/model-proxy.test.mjs:23:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,120 @@
nice=19
✖ human-required: proxy fetch refuses CODEX_THREAD_ID before config or fetch (1.450492ms)
✖ human-required: proxy fetch refuses MOSAIC_AGENT_RUN before config or fetch (0.227362ms)
✔ human-required: proxy fetch tester refuses before config or fetch (0.461104ms)
✖ proxy-key-invalid: CLI rejects non-management-key input (12) (0.486647ms)
✖ proxy-key-invalid: CLI rejects non-management-key input (12) (0.3288ms)
✖ proxy-key-invalid: CLI rejects non-management-key input (12) (0.281426ms)
✖ proxy-key-invalid: CLI rejects non-management-key input (11) (0.321288ms)
ℹ tests 7
ℹ suites 0
ℹ pass 1
ℹ fail 6
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 120.753461
✖ failing tests:
test at packages/cli/tests/model-proxy-guards.test.mjs:8:3
✖ human-required: proxy fetch refuses CODEX_THREAD_ID before config or fetch (1.450492ms)
AssertionError [ERR_ASSERTION]: The validation function is expected to return "true". Received false
Caught error:
CliError: proxy-fetch-failed
at async TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/cli/tests/model-proxy-guards.test.mjs:11:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: CliError: proxy-fetch-failed
at failure (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/cli/src/model-proxy.mjs:4:55)
at proxyCommand (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/cli/src/model-proxy.mjs:62:11)
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/cli/tests/model-proxy-guards.test.mjs:11:26)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.start (node:internal/test_runner/test:1262:17)
at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17),
operator: 'rejects',
diff: 'simple'
}
test at packages/cli/tests/model-proxy-guards.test.mjs:8:3
✖ human-required: proxy fetch refuses MOSAIC_AGENT_RUN before config or fetch (0.227362ms)
AssertionError [ERR_ASSERTION]: The validation function is expected to return "true". Received false
Caught error:
CliError: proxy-fetch-failed
at async TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/cli/tests/model-proxy-guards.test.mjs:11:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: CliError: proxy-fetch-failed
at failure (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/cli/src/model-proxy.mjs:4:55)
at proxyCommand (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/cli/src/model-proxy.mjs:62:11)
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/cli/tests/model-proxy-guards.test.mjs:11:26)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3),
operator: 'rejects',
diff: 'simple'
}
test at packages/cli/tests/model-proxy-guards.test.mjs:22:3
✖ proxy-key-invalid: CLI rejects non-management-key input (12) (0.486647ms)
AssertionError [ERR_ASSERTION]: Missing expected rejection.
at async TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/cli/tests/model-proxy-guards.test.mjs:25:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
operator: 'rejects',
diff: 'simple'
}
test at packages/cli/tests/model-proxy-guards.test.mjs:22:3
✖ proxy-key-invalid: CLI rejects non-management-key input (12) (0.3288ms)
AssertionError [ERR_ASSERTION]: Missing expected rejection.
at async TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/cli/tests/model-proxy-guards.test.mjs:25:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
operator: 'rejects',
diff: 'simple'
}
test at packages/cli/tests/model-proxy-guards.test.mjs:22:3
✖ proxy-key-invalid: CLI rejects non-management-key input (12) (0.281426ms)
AssertionError [ERR_ASSERTION]: Missing expected rejection.
at async TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/cli/tests/model-proxy-guards.test.mjs:25:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
operator: 'rejects',
diff: 'simple'
}
test at packages/cli/tests/model-proxy-guards.test.mjs:22:3
✖ proxy-key-invalid: CLI rejects non-management-key input (11) (0.321288ms)
AssertionError [ERR_ASSERTION]: Missing expected rejection.
at async TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/cli/tests/model-proxy-guards.test.mjs:25:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
operator: 'rejects',
diff: 'simple'
}
@@ -0,0 +1,137 @@
nice=19
✖ probeKey observes 200 with one agent-authenticated GET /v1/models (9.151708ms)
✖ probeKey observes 401 with one agent-authenticated GET /v1/models (1.636636ms)
✖ probeKey observes 503 with one agent-authenticated GET /v1/models (0.696746ms)
✖ proxy-down: agent-key probe connection failure is sanitized (1.651552ms)
✖ proxy-down: silent listener cannot extend probe request deadline (0.813308ms)
✖ agent-key probe returns redirect status without following Location or exposing key (0.763418ms)
ℹ tests 6
ℹ suites 0
ℹ pass 0
ℹ fail 6
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 99.280118
✖ failing tests:
test at packages/model-proxy/tests/client-probe.test.mjs:16:3
✖ probeKey observes 200 with one agent-authenticated GET /v1/models (9.151708ms)
AssertionError [ERR_ASSERTION]: client must support bounded agent-key probe
+ actual - expected
+ 'undefined'
- 'function'
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/model-proxy/tests/client-probe.test.mjs:22:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 'undefined',
expected: 'function',
operator: 'strictEqual',
diff: 'simple'
}
test at packages/model-proxy/tests/client-probe.test.mjs:16:3
✖ probeKey observes 401 with one agent-authenticated GET /v1/models (1.636636ms)
AssertionError [ERR_ASSERTION]: client must support bounded agent-key probe
+ actual - expected
+ 'undefined'
- 'function'
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/model-proxy/tests/client-probe.test.mjs:22:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 'undefined',
expected: 'function',
operator: 'strictEqual',
diff: 'simple'
}
test at packages/model-proxy/tests/client-probe.test.mjs:16:3
✖ probeKey observes 503 with one agent-authenticated GET /v1/models (0.696746ms)
AssertionError [ERR_ASSERTION]: client must support bounded agent-key probe
+ actual - expected
+ 'undefined'
- 'function'
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/model-proxy/tests/client-probe.test.mjs:22:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 'undefined',
expected: 'function',
operator: 'strictEqual',
diff: 'simple'
}
test at packages/model-proxy/tests/client-probe.test.mjs:30:1
✖ proxy-down: agent-key probe connection failure is sanitized (1.651552ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ 'undefined'
- 'function'
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/model-proxy/tests/client-probe.test.mjs:33:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'undefined',
expected: 'function',
operator: 'strictEqual',
diff: 'simple'
}
test at packages/model-proxy/tests/client-probe.test.mjs:36:1
✖ proxy-down: silent listener cannot extend probe request deadline (0.813308ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ 'undefined'
- 'function'
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/model-proxy/tests/client-probe.test.mjs:38:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'undefined',
expected: 'function',
operator: 'strictEqual',
diff: 'simple'
}
test at packages/model-proxy/tests/client-probe.test.mjs:43:1
✖ agent-key probe returns redirect status without following Location or exposing key (0.763418ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ 'undefined'
- 'function'
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/model-proxy/tests/client-probe.test.mjs:46:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'undefined',
expected: 'function',
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,106 @@
nice=19
✖ implicit model kind starts not-issued without a business or role reference (2.1449ms)
✖ model record persists at 0600 in 0700 dirs and reloads ids/issue time/state only in status (0.854684ms)
✖ revoking model metadata survives restart without losing the private record (0.575987ms)
✖ model record wrong file mode refuses startup rather than inventing a replacement (0.540917ms)
✔ non-proxy Credentials has no implicit model kind (0.805283ms)
ℹ tests 5
ℹ suites 0
ℹ pass 1
ℹ fail 4
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 69.204058
✖ failing tests:
test at packages/bus/tests/model-credential-store.test.mjs:23:1
✖ implicit model kind starts not-issued without a business or role reference (2.1449ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ undefined
- 'not-issued'
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/bus/tests/model-credential-store.test.mjs:26:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.start (node:internal/test_runner/test:1262:17)
at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: undefined,
expected: 'not-issued',
operator: 'strictEqual',
diff: 'simple'
}
test at packages/bus/tests/model-credential-store.test.mjs:29:1
✖ model record persists at 0600 in 0700 dirs and reloads ids/issue time/state only in status (0.854684ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ 'undefined'
- 'function'
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/bus/tests/model-credential-store.test.mjs:31:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'undefined',
expected: 'function',
operator: 'strictEqual',
diff: 'simple'
}
test at packages/bus/tests/model-credential-store.test.mjs:44:1
✖ revoking model metadata survives restart without losing the private record (0.575987ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ 'undefined'
- 'function'
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/bus/tests/model-credential-store.test.mjs:46:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'undefined',
expected: 'function',
operator: 'strictEqual',
diff: 'simple'
}
test at packages/bus/tests/model-credential-store.test.mjs:53:1
✖ model record wrong file mode refuses startup rather than inventing a replacement (0.540917ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ 'undefined'
- 'function'
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/magica-row331/packages/bus/tests/model-credential-store.test.mjs:55:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'undefined',
expected: 'function',
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1 @@
/mnt/storage/scratch/tmp/X0S193
-482
View File
@@ -1,482 +0,0 @@
# Row 193 design: many humans per business, human roles, private and shared threads
Morgana (Opus 5.5), 2026-10-20. Revision 5: sage-1373 item 1 (mos-1023 item 2), the user sentence names every record the owner sees. Revision 4: duckworth-0011 B3 and N5. Revision 3: duckworth-0008 B1, B2 and N1-N4, sage-1368. Revision 2: sage-1354 (5.7 private from the owner, 193b split) and gizmoduck-0018 (coverage and the R4 predicate in FED r4.1). Gitea #1691, queue row 193, brief `docs/plans/2026-10-20_human-users.md`
(blob f7472d1e47b1). Reviewer Duckworth, gate owner Sage. Base: `refactor` at 53bb59462.
Read for this design: COMMON.md (53bb59462), the brief, Gizmoduck's federation design r4.4 (b67bc6de9 on
gizmoduck/federation-design, cited as FED; r3.1 a2840a6be was read first), Duckworth's federation reviews (b402197ff, 03a663cd7,
45cbe5896 on duckworth/reviews), Sage's Q175 SSO design (`agents/sage/work/q175-sso/DESIGN.md`), the row 192
and row 196 briefs, Darkwing's row 190 packet (uncommitted, sha256 5493c9df3ec0...), and the bus, business,
cli and webui code at the lines cited. Messages: morgana-0001 to 0003, sage-1338, gizmoduck-0009,
gander-0002 and gander-0005.
## 1. Summary
- Three human roles: **owner**, **collaborator**, **viewer**. The owner is the business file's one `human`,
as today. Collaborators and viewers are access.json users, the same store federation uses (FED 3.5).
Their role is the `level` of a grant, so a role is per project and instance, not per person.
- Every human principal is minted by one bus verifier, owned by row 196 (Gander). Row 193 states what it
needs from that verifier (section 6) and adds no second mechanism.
- Threads stay private to their owner by default. A thread owner may share a thread, read-only, with a
named user, with the owner, or with everyone granted on it. Shares live in a new append-only bus table,
`thread_shares`. This is a deviation from brief point 4, agreed by Sage (sage-1338), for Duckworth to rule
on (section 5.1).
- A share never widens a grant. It counts only while the reader's live grant covers the thread's project
and instance, and while the thread's owner still has coverage. Revocation, expiry and unsharing take
effect on the next request.
- Existing businesses keep working unchanged: every existing thread is already owned by the business
human, and an instance without access.json has exactly one human, the owner.
- The build splits so the part without a schema change can land first. `thread_shares` needs schema 3j and
a `--migrate` window with Jason.
## 2. What exists today (measured at 53bb59462)
- One `human` per business (`packages/business/src/business.mjs:160`). `bindHuman` refuses any other name
(`packages/bus/src/broker.mjs:532-538`).
- The owner's CLI proof is path-bound (#225): `verifyHuman` checks uid, start time, nonce
(`MOSAIC_BUS_CLI_NONCE`), `argv[1]` equal to the tree's `human-cli.mjs`, then walks parents and refuses
agent markers, launch records and agent binaries (`packages/bus/src/human.mjs:64-96`).
`authenticateHuman` binds the proof to `businesses[business].human` (`packages/bus/src/runtime.mjs:175-182`).
- Threads: `threads(seq,id,at,business,project,instance,owner,name)`, append-only
(`packages/bus/schema.sql:247`, triggers at :310-312). `#owner(s)` returns `tester` or `s.human`
(broker.mjs:364-366). `#thread` refuses a non-owner with `thread-not-owned` (:357-362).
- Thread verbs are gated `if (!s.tester) this.#human(s)`: `project.list` (:2318), `thread.create` (:2325),
`thread.list` (:2351, owner filter), `thread.open` (:2365), `thread.send` (:2374, writes `human.input`
for the human only), `thread.messages` (:2401).
- Board reads are business-wide and human or reader only: `inbox`, `agents`, `tasks`, `trail` (:2143-2167),
`decisions` (:2174), `credentials` (:2195), `messages` (:2217, `to_role='human'`), `replies` (:2422).
`trail` and `replies` return any message in the business by id (:2679-2700, :2422-2432).
- The Console reads threads by polling the same verbs (`packages/webui/src/chat.mjs:22-26`). It has no
stream, so a refusal on the next poll ends access.
- The launcher restarts a thread with pending messages as the business human
(`packages/cli/src/launcher.mjs:533-542`).
## 3. The human model (brief point 1)
### 3.1 Who the humans are
| Human | Where it is declared | How many | Identity |
|---|---|---|---|
| owner | business file `human` (unchanged) | exactly one per business | the business human's name |
| user (collaborator or viewer) | access.json `users[]` with `home: "local"` (or a peer id under federation) | any number | `user:<id>` |
| tester | not a human; row 177 test principal | one | `tester` |
The owner stays outside access.json (FED 9 Q2). A business has no second owner and no admin level. Adding
one is out of scope; section 9 lists it as a follow-up for Jason.
### 3.2 How a sign-in or a call becomes exactly one human
| Entry | Becomes | Checked by |
|---|---|---|
| Console SSO session whose `(provider, sub)` is in `console.sso.providers[].subjects` (row 192) | the owner | the Console after OIDC checks, then the bus verifier (row 196) |
| Console SSO session whose `(provider, sub)` is a `users[].subjects` entry in access.json with a live grant | that one user | same |
| Console SSO session matching neither | nobody: refused | the Console, and the bus refuses any later claim |
| CLI call through `human-cli.mjs` that passes the human proof | the owner | the bus (`verifyHuman`) |
| Any other process, including an agent | nobody | the bus |
Why each is exactly one:
- The R1 loader refuses a `(provider, sub)` pair that appears twice in access.json or also in
`console.sso.subjects` (FED 3.5). So one subject maps to at most one human.
- A user id can't be `human`, `tester` or any business's human (FED 3.5). So a user can't be named as the
owner.
- A terminal maps only to the owner. The only identity the bus can verify for a local process is the
instance's OS user, and that is the owner's. A user has no CLI path in row 193. This follows Mos's B1 rule
(mos-0985): the principal comes from what the bus verifies, never the process's own claim.
- The bus never reads a user name from a request body as identity. The verifier returns the principal
(section 6).
## 4. Human roles (brief point 2)
### 4.1 The three roles
- **owner**: everything the business human does today. Only the owner restructures the organization
(business file writer, project and role changes) and only the owner manages users and grants.
- **collaborator**: FED 3.5's level, unchanged. Creates threads in granted projects on granted instances,
sends into and opens its own threads, and in row 193 also shares its own threads.
- **viewer**: new. Reads threads shared with it, within its grant. It creates, opens and sends nothing, so
it never drives an instance. FED 3.5 notes that a collaborator's message drives the instance with the
owner's role authority (F10). A viewer has none of that.
The word "collaborator" stays, so FED's tests and help text need no rename. The brief's example "member" is
this level.
A role is the `level` on a grant, and a grant is per business, project set and instance set. A user may
hold a collaborator grant on one project and a viewer grant on another. Every check asks "does a live
grant of a level that allows this verb cover this project and instance". A user has no single role.
### 4.2 R1 widening
FED R1 refuses any level but `collaborator`, because the 10-27 slice ships only that (FED 9 Q2).
Row 193 widens R1's level check to `collaborator` or `viewer`, with its own test that each is accepted and
anything else refuses. Nothing else in the access file changes. If R1 has not landed when row 193 builds,
this one-line widening goes into the row 193a candidate chained on R1's candidate (Q177).
### 4.3 Bus verbs per role
Every verb not in a user's column fails `access-refused` before its own checks, as FED 3.5 requires for
collaborators. That covers every owner verb, every board read, `message.*`, `decision.*`, project files,
`spec.read`, `assistant.stop`, `launch.*`, `name.set`, `usage.*` and `access.reload`.
| Verb | owner | collaborator | viewer |
|---|---|---|---|
| `project.list` | all | projects covered by any live grant | projects covered by any live grant |
| `thread.create` | yes | collaborator grant covers project and instance | refused |
| `thread.list` | own threads | own threads still covered | own threads still covered (from an earlier collaborator grant) |
| `thread.list {shared: true}` (new) | threads shared with the owner | threads shared with me and readable (5.4) | same |
| `thread.open` | own | own, collaborator grant still covers | refused |
| `thread.send` | own | own, collaborator grant still covers, no `human.input` | refused |
| `thread.messages` | own, or shared with the owner | own (any level still covering), or shared and readable | same as collaborator |
| `thread.share`, `thread.unshare` (new) | own threads | own threads, collaborator grant still covers | refused |
| `thread.shares` (new) | own threads | own threads | refused |
| `names` | all | instances covered by any live grant | instances covered by any live grant |
| every other verb | as today | refused | refused |
Notes:
- `thread.list` keeps its response shape exactly. Shared threads come only from `thread.list {shared: true}`,
whose rows add `owner`. The Console and existing tests read the plain list unchanged.
- A viewer can still read its own threads when a collaborator grant is lowered to viewer, but can't send
or open them. This is the downgrade path.
- The tester keeps its row 177 verbs. It can't share or be shared with: `thread.share`, `thread.unshare`
and `thread.shares` fail `tester-refused`, and a share target is never `tester`. Agent testers (Q173) test
sharing through the bus suites, not the live 7333 listener.
- `reader` sessions (Console read caps, broker.mjs:1303) are the owner's board view and stay owner-only.
A user never gets a reader session.
### 4.4 Console per role
- owner: as today, plus a share control on each own thread and a "Shared with me" list.
- collaborator: the chat view only, for granted projects (FED R6's granted-user sign-in), plus share
controls on its own threads and "Shared with me". No board.
- viewer: "Shared with me" and its own old threads, read-only. No composer, no open, no share control.
- The Console hides controls a role can't use, but the bus check is authoritative. A Console bug can't
widen a grant (FED 3.6). Each role gets a webui test with the UI check removed (mutant) that the bus still
refuses.
### 4.5 Who manages humans and roles
Only the owner, through `mosaic access` (FED R5), which checks the human proof and refuses tester and agent
callers. Row 193 needs from R5:
- `mosaic access user add <id> --home local --subject <provider>:<sub>` and `user remove <id>`;
- `--level viewer` on `grant`;
- the receipt line in `<dataRoot>/state/access-log.jsonl` and the `access.reload` call, as FED R5 has.
If R5 has no `user` verbs when row 193 builds, row 193c adds them on R5's candidate. The Console gets no
user or grant writes in row 193: a write from the Console would need a new owner verb that writes
access.json from the bus, and access.json is a config file only the owner's CLI writes. A read-only users
page for the owner is a follow-up (section 9).
The owner keeps every restructuring power (TARGET Case 1). Users can't touch the business file, projects,
roles or access.json.
## 5. Private and shared threads (brief point 3)
### 5.1 Deviation from brief point 4, agreed by Sage (sage-1338)
Brief point 4 says access.json is the one store. Users, roles and grants live there. Per-thread shares do
not, for three reasons (morgana-0002):
- F1. Thread ids are bus-generated UUIDs. A hand-typed share with a typo would fail the loader, and the
loader refuses the whole file on any failure, which revokes every user.
- F2. Only the owner writes access.json. A collaborator could never share its own thread.
- F3. Threads are append-only bus rows, and a share is a runtime act by a principal that wants a trail.
So shares go in an append-only bus table, gated by the grant. Sage agreed (sage-1338). Gizmoduck had no
objection (gizmoduck-0009) and recorded it as FED 9 Q2. Duckworth rules on it in this review.
### 5.2 Schema 3j
```sql
CREATE TABLE thread_shares (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('share','unshare')),
target TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_shares_thread ON thread_shares (thread, target, seq);
-- no_update, no_delete and no_replace triggers, as thread_opens has (schema.sql:319-321).
```
- `target` is an owner string: `user:<id>`, the business human's name, or `*`. A share whose target is a
name that is no longer the business's `human` (after a rename) is void: rule 1 in 5.4 compares against
the current owner string, so it matches nobody (Duckworth N4).
- `by` is the sharer's owner string, which is always the thread's owner.
- A share's state is its latest row per `(thread, target)` (Duckworth N12, REVIEW-r3.md:74-76). Shares
are not assumed to only grow.
- No existing row changes. Existing threads are already owned by the business human (`#owner` at
broker.mjs:364-366), so they are the owner's and private. The brief's "all become the current human's"
is already true.
The migration `3i-3j.sql` creates the table, index and triggers. `thread_shares` joins every table list that
holds timed append-only tables: `timedTables` in store.mjs (:60-74), the broker's clock list
(broker.mjs:223-235), the restore guard's counted tables (row 181) and `COUNTED` in
`scripts/stack-release.sh:315`. The schema digest for 3j is pinned as the other versions are.
### 5.3 Verbs
- `thread.share {thread, to}`: the caller must own the thread (`thread-not-owned`). For a user, a
collaborator grant must still cover the thread. `to` is `user:<id>`, the business human's name or `*`.
A `user:<id>` target must have a live grant, of any level, covering the thread's project and instance,
else `share-not-covered`. Sharing with yourself fails `invalid-request`. A share that is already in force
fails `share-exists`. Writes one `share` row.
- `thread.unshare {thread, to}`: the caller must own the thread. The share must be in force, else
`share-not-found`. Writes one `unshare` row. Unsharing the owner's or `*` shares works the same way.
- `thread.shares {thread}`: the caller must own the thread. Returns the shares in force, latest row each.
- Both writes go through `refuse()` on failure, so each refusal records `action.refused` with `by` set
(FED 3.6 item 6).
### 5.4 The read check, one function
`#readable(s, t)` decides whether a session may read thread `t` it doesn't own. True only when all hold:
1. a share for `t` is in force whose target is the caller's owner string, or `*`;
2. the caller is the owner, or the caller has a live grant of any level covering `t.project` and
`t.instance`;
3. if `t.owner` is a user, that user still has a live grant covering `t` (a revoked owner hides the
thread, sage-1338);
4. `t.owner` is not `tester`.
`#thread(s, id)` keeps its owner check for `thread.open`, `thread.send`, `thread.share`, `thread.unshare`
and `thread.shares`. A new `#threadRead(s, id)` admits the owner or `#readable`, and serves only
`thread.messages`. A sharee fails `thread-not-owned` on every write verb.
### 5.5 Every read path and its filter
| Read path | Today | After row 193 |
|---|---|---|
| `thread.list` | `owner=?` | unchanged for the owner; for users also each row's thread still covered |
| `thread.list {shared: true}` | none | rows where `#readable` holds |
| `thread.messages` | `#thread` | `#threadRead` |
| `thread.open`, `thread.send` | `#thread` | `#thread`, plus a collaborator grant for users |
| `project.list`, `names` | business-wide | per FED 3.5 for users, unchanged for the owner |
| `trail`, `replies` | human, reader or tester; any message by id | users refused (`access-refused`); for the others, messages in other users' unshared threads hidden (5.7) |
| `messages`, `inbox`, `agents`, `tasks`, `decisions`, `credentials` | human or reader | users refused (`access-refused`); owner unchanged. A user-thread run can't send to the human (`user-thread-human`, FED 3.6), so `messages` holds none of a user's thread |
| `message.receive` by a run | its own thread (:1933) | unchanged: runs read their own thread, never a share |
| `threadsState` (launcher) | all threads | adds `revoked` per 5.6 |
### 5.6 Revocation, expiry and unsharing
Gizmoduck asked that a grant loss end access to threads shared with that user (gizmoduck-0009 point 2).
- A sharee holds no claim and no run. Its access is only reads, and every read runs `#readable` against the
access state the broker already refreshes on each request (mtime, size, inode), on its 30 s timer, and on
`access.reload` (FED 3.7). So a removed, expired or invalid grant ends share reads on the next request,
with nothing to tear down. The Console polls, so a revoked sharee's next poll fails.
- An unshare row ends access on the next request in the same way.
- A user who loses coverage of their own thread also loses its shares: rule 3 in 5.4.
The downgrade case. With viewer, "no live grant covers" would be wrong for runs: a viewer grant covers the
thread but must not let a run continue. FED puts this in R4: `revoked`, claim end and the launch check call
`covers` with level `collaborator`, so a downgrade to viewer ends the run within one launcher tick. FED r4.4
(b67bc6de9, mos-1020) chains R4 after row 193's viewer level (193a) and keeps the level-loss test and its
mutant in R4. Row 193 carries neither (Duckworth N3), so the mutant has one owner.
### 5.7 What "private" means, stated plainly
Sage ruled (sage-1354): a user's thread is private from the owner on the bus too. Jason's words are "private
agent sessions per user", and in Case 2 the owner of Melanie's hub is not the user whose thread it is.
Duckworth's B1 asked where the line sits, since a user-thread run acts with the owner's role authority (F10).
I took Duckworth's option (a): **the user's messages are private, and the run's governance is not.** The
run spends the owner's authority, so its decisions, task writes and claims are the owner's to see and
answer. Filtering them too (option b) would need a thread link on `decisions`, more schema, and would hide
from the owner what is done in the owner's name.
Every read an owner or reader session has, and its side of the line:
| Read | Shows | Side |
|---|---|---|
| `thread.list`, `thread.messages`, `thread.open` | own threads only, plus shares to the owner from 193b2 | private: other users' threads never appear |
| `trail` | events, messages, decisions and deliveries by subject | messages filtered (below); decision rows and their events stay visible |
| `replies` | messages replying to an id | filtered (below) |
| `messages`, `message.receive`, `message.read` | messages `to_role='human'` (broker.mjs:1931-1936, :1953) | private by construction: a user-thread run can't send to the human (`user-thread-human`, FED 3.6 item 7) |
| `inbox` | open decisions routed to the human | governance, visible |
| `decisions {list: roles \| recent}` | decision rows with question, options and recommendation | governance, visible |
| `agents` | role claims and each held role's state | governance, visible |
| `tasks`, `state` | tracker tasks and launch state | governance, visible |
| `credentials`, `usage` | credential state, usage records | governance, visible; neither holds message text |
| `project.files`, `project.file` | project files, including records a run writes with `record.write` (broker.mjs:1395-1407, :1441-1450) | governance, visible |
| `project.list`, `names` | projects and instance names | business-wide for the owner, visible |
So a decision a user-thread run raises shows in the owner's `inbox` or `decisions`, with the free text the
run wrote. Its role claims show in `agents`, its task writes in `tasks`, and a record it writes in
`project.file`. Any of these can quote the user, and 5.7 doesn't call them private. The users' help text
(R5) and the Console's share and thread pages say so in one sentence, which FED R6 reuses (mos-1023 item 2,
duckworth-0012 N21):
> The business owner sees your thread's agent's decisions, inbox entries, claims, tasks and project files,
> but not your messages.
The gated-raise route. A user-thread run's uncited gated raise or hard wait routes to the human (broker.mjs:1677-1680).
FED r4.5 (acd5f1f42, B9) settled this with no guard: the tester guard at :1683 (`tester-thread-human`) is not
copied, and the decision reaches the owner's inbox and no other (sage-1368, mos-1021, mos-1022). FED R3 tests it.
Row 193 adds nothing here.
The message filter. A message is hidden from a session when it is in a thread (`message_threads`) whose owner
is a `user:<id>` other than the caller, and `#readable(s, t)` is false. Threads owned by the business human or
by `tester` are not affected, so row 177's behaviour is unchanged.
- `trail`: drops hidden messages, their `deliveries` rows, and `events` rows whose `subject` is a hidden
message id. A `subject` that is itself a hidden message returns `message-not-found`, as an unknown id does,
so the reply doesn't confirm the id exists.
- `replies {id}`: a hidden `id` fails `message-not-found`, and hidden messages are dropped from the result.
- It applies to every session that can call these verbs: the owner, reader sessions and the tester.
- It lands in 193b1, with no schema (Duckworth B2). Before 193b2 there are no shares, so `#readable` is false
for anyone but the thread's owner and b1 hides every message in another user's thread. 193b2 adds only the
share exception. So no build order can put a user thread live without the filter.
What it does not do. The owner holds the store as the instance's OS user and can read it with sqlite. That is
the same-uid limit in section 6, not a bus path. An owner audit verb, explicit and logged, is a follow-up row
only if Jason asks for one (sage-1354).
## 6. The bus human boundary (brief point 5)
Row 196 (Gander, #1694, brief `docs/plans/2026-10-20_human-boundary.md` at 96f243faf) owns P19 333 and the
verifier. Row 193 cites it and designs no boundary of its own (sage-1338: one mechanism).
Agreed with Gander (gander-0002, morgana-0003, gander-0005):
- One bus verifier, `verifyPrincipal(request, connection) -> {kind, business, id}`, kind `owner`, `user` or
`tester`. It mints one transient principal per request. It returns no role. The broker reads the level
from the live grant on every request, so a grant change acts without a new principal.
- Gander recommends FED 3.6's shape for users (path-bound `user-cli.mjs`, direct parent the bus tree's
`packages/webui/src/cli.mjs serve`) plus kernel peer PID binding, and weighs bus-side ID-token
verification as the larger alternative.
- Correction from Gander, accepted: the tester proof doesn't run the full agent walk, because agents bind
tester by design (Q173). Managed runs still fail it. The tester listener moves from TCP 7333 to a private
Unix socket, with `bus.tester` unchanged (agreed by Sage with Gander).
What row 193 needs from row 196:
- H1. Owner on the CLI keeps today's proof. Owner in the Console is a `console.sso.subjects` entry.
- H2. A user principal comes only from an OIDC sign-in, through a path the bus verifies. No CLI path for
users.
- H3. The user binding fails closed on no live grant, an unknown subject, a subject also in
`console.sso.subjects`, or a missing or invalid access.json. A broken access.json never stops the owner.
- H4. Agent processes never get an owner or user principal.
- H5. Revocation ends a bound user within 30 s and at once on `access.reload`, from the same access state
row 193's share check reads.
The same-uid limit. Within one instance, a non-agent process on the instance's uid can claim the owner, and
can start its own Console copy and claim any granted user (FED 3.6). An unsandboxed same-uid agent that
detaches from its ancestry can do the same. The walk doesn't stop that, and Gander's design won't claim it
does (gander-0005). Several humans don't make this worse: a user principal is no stronger than the owner
principal that the same process could already take. Users themselves are not on the uid. They reach the
bus only through the Console, so the limit is about local processes, not other humans.
Does row 190 narrow it? Row 190 runs several instances under one uid, so no. Row 190b puts an instance (the
hub) under its own Linux user (mos-0986). That narrows reach across instances: another instance's uid fails
the uid check (human.mjs:67) and can't read the hub's store or access.json. It does not narrow reach within
one instance. Private threads are private from other humans using the bus, not from processes running as
the instance's uid.
## 7. Migration and rollback (brief point 6)
- No access.json: the business has exactly its owner, every verb behaves as today, and the share verbs
work for the owner only (target `*` or none). A broken access.json revokes users and leaves the owner.
- Existing threads: already owned by the business human. Nothing is rewritten.
- Schema: 3i to 3j adds `thread_shares` only. A re-pin past a schema bump needs a `--migrate` window with
Jason, and row 185's backup before activate covers it.
- Rollback before any share is written: activate the previous copy with the store restored from the
pre-activate backup. A 3i copy refuses a 3j store, as it does for every bump.
- Rollback after shares are written: the same restore loses shares made since the window. Grants are in
access.json and survive. Users re-share. Row 181's restore guard counts `thread_shares`, so the loss is
shown, not silent.
- To keep that window small, the build splits (section 9): 193a, 193b1, 193c and 193d have no schema
change and can go live in any window. 193b2 carries 3j.
## 8. Tests and mutants
Bus, each in `packages/bus/tests/` (193b1: viewer, mixed grants, board reads, the 5.7 message filter and governance pin; 193b2: shares, the share exception to the filter, migration):
- Viewer: each allowed verb works, every other verb refuses `access-refused`, including `thread.create`,
`thread.open`, `thread.send` and `thread.share`.
- Mixed grants: collaborator on project A and viewer on B. Create in A works, create in B refuses.
- Downgrade: not here. FED R4 owns the level-loss test and its mutant (r4.4, mos-1020, N3).
- Shares: owner shares with `user:<id>`, the user reads `thread.messages` and gets `thread-not-owned` on
`thread.send` and `thread.open`. Share with `*` reaches every covered user and the owner. Share with a user
whose grant doesn't cover the project or instance refuses `share-not-covered`.
- A share never widens: the target's grant drops the project, the next read refuses.
- Revoke, expiry and an invalid file each end share reads on the next request. The 30 s timer and
`access.reload` paths each have a case.
- A revoked thread owner hides the thread from sharees.
- Unshare ends access. Share, unshare, share again leaves it shared (latest row wins).
- Non-owner `thread.share`, `thread.unshare`, `thread.shares` refuse. Tester refuses on all three.
- `thread.list` plain shape unchanged. `thread.list {shared: true}` returns only readable threads.
- Board reads refuse users and viewers.
- Governance pin (B1, sage-1368): a user-thread run raises a decision; it shows in the owner's
`decisions recent` with its question text, and `inbox` lists it when routed to the human; the run's
role claim shows in the owner's `agents`, its task write in `tasks`, and its `record.write` in
`project.file` (B3, sage-1373); the user's message bodies stay hidden from the owner's `trail` and
`replies`. The test names each item of the 5.7 user sentence, so the sentence and the pin match.
- `trail`, for the owner, a reader session and the tester (193b1): a user's thread message as `subject`
fails `message-not-found`; a decision trail that reaches such a message omits it, its deliveries and its
events. Once the thread is shared with the owner, the owner's trail shows it.
- `replies`, same three sessions (193b1): a hidden `id` fails `message-not-found`; hidden replies are dropped. The
owner's own and the tester's threads are unchanged.
- Migration: a 3i store with threads migrates to 3j, row counts unchanged, `thread_shares` empty and
append-only (update, delete, replace each abort). Restore-guard and `COUNTED` include it.
Business (193a): R1 accepts `viewer`, refuses any other new level.
CLI (193c): `mosaic access user add/remove` and `grant --level viewer` refuse tester and agent callers,
write a receipt line and call `access.reload`.
Webui (193d): a viewer has no composer or share control. With each UI check removed, the bus still refuses.
Mutants, each must be killed:
1. `#readable` rule 1 dropped (any covered user reads any thread).
2. Rule 2 dropped (a share widens the grant).
3. Rule 3 dropped (a revoked owner's thread stays visible).
4. Latest-row lookup replaced by "any share row" (unshare ignored).
5. (Moved to FED R4, N3: the level-loss mutant is R4's.)
6. Viewer allowed `thread.send`.
7. `#threadRead` used for `thread.send` (sharee writes).
8. `share-not-covered` check removed.
9. `thread.share` owner check removed (sharee re-shares).
10. A user allowed `trail`.
11. The `trail` filter removed (the owner reads a user's unshared message by id).
12. The `replies` filter removed (the owner reads replies in a user's unshared thread).
## 9. Build rows and order
| Row | Work | Chains on | Schema |
|---|---|---|---|
| 193a | R1 level check widened to viewer | FED R1 candidate | none |
| 193b1 | Bus: viewer verb list, per-grant level checks, users refused on board reads, the 5.7 `trail` and `replies` filter with `#readable` as owner-only | FED R3, R4 candidates, row 196 verifier | none |
| 193b2 | Bus: `thread_shares`, share verbs, the share rules of `#readable`, `#threadRead`, the 5.5 share filters | 193b1 candidate | 3j |
| 193c | `mosaic access` user verbs and `--level viewer`, if R5 lacks them | FED R5 candidate | none |
| 193d | Console: shared list, share controls, viewer read-only | FED R6, row 192 candidates, 193b2 for share controls | none |
Sage ruled the split (sage-1354): 193b1 has no schema change and can go live first. 193b2 carries 3j, and
Sage takes its migrate window to Mos when 193b2 is near. The 5.7 message filter is in 193b1 (Duckworth B2,
sage-1368), so it is live before any user thread can be, whatever order FED R8 and 193b2 land in. 193b2 adds
only the share exception. FED R4 chains on 193a (viewer level), and 193b1 chains on R4.
Coverage, settled in FED r4.1 (73677b87d, gizmoduck-0018, still under Duckworth's review). A grant's
`projects` is `["*"]` or bus project UUIDs (`projects.id`, broker.mjs:2306; `threads.project` references it).
R1 exports one pure `covers(grant, {business, project, instance}, now, {level})`. Row 193 uses only that
function and adds no second one (Sage confirmed, sage-1354):
- share reads (5.4 rules 2 and 3) and `share-not-covered`: `covers(g, thread, now, {})`, any level;
- `thread.create`, `thread.open`, `thread.send`, `thread.share`: `covers(g, thread, now, {level: 'collaborator'})`;
- `project.list` and `names` for users: any level, as FED 3.5.
`covers` needs no further argument for row 193. If r4.1 changes in review, 193b1 follows it.
Follow-ups, not in this row:
- A read-only users and grants page for the owner in the Console.
- A second owner or admin level, if Jason wants one.
- An explicit, logged owner audit verb for user threads, only if Jason asks for one (sage-1354).
## 10. Gate
Per the brief: Duckworth approves this design, then the manifest. The bus, business, cli and webui suites and
every `scripts/test-*.sh` pass on Sage's gate, and the section 8 mutants are killed. No test touches the live
directories. No product code before Duckworth approves.
+2 -2
View File
@@ -1694,5 +1694,5 @@ are never rewritten or removed; corrections are new entries.
2026-10-24 | magica | row329 R2 #1827 | ASCII management-key fix and docs verified: package66/66,bus readers261/261,CLI readers726/726,config55/55; no required failures/skips, slot returned. Preparing authorized ff review candidate; old tests unchanged.
2026-10-24 | Sage 1ef1e4f8 | row329 landing #1827 (mos-1634) | Gate GREEN 23:29:14Z unchanged, landed 61ebf2c34 pushed 23:31:27Z; #1827 closed c31650; queue note 4063, done 4064 (b24d917ec); DEFERRED N2/R2-N1 to 331, N1 to P7; Vikunja 631 filed, 237 c1109; 284 and 157 must rebase.
2026-10-25 | Sage 1ef1e4f8 | row284 landing #1789 | Gate GREEN 00:09:04Z unchanged, landed ce0b6ed41 pushed 00:09:52Z; #1789 closed c31655; queue note 4071, done 4072 (7c8dc2033); DEFERRED Webby N1 to the next library read-path row; row 332 PROXY_CODEX_API ruled openai-responses (sage-4331), P3 holds for row 331.
2026-10-25 | Sage 1ef1e4f8 | rows 214 and 193 landing (#1715, #1691) | 214 gate GREEN 04:10:34Z, landed 3a8e4abad, #1715 closed c31680, queue 4094/4095 (ce8bb4d8b); 193 doc-only landed 35f34ad27 after Duckworth approve 4088; 198 group A and 267 assertion diffs to Mos (sage-4397, sage-4403); CLEAR to Bubba for 288.
2026-10-25 | Sage 1ef1e4f8 | row255 landing #1760 | Doc-only design landed de3e23dde after Webby approve 4103 (c31689); 288 r6 gate green from Bubba (2604/0, 12 scripts, 6 mutants), in-review move handed back to the claimant.
2026-10-24 | magica | row 331 P2 #1829, magica/row331 at 78b69e1e6 | registered; design investigation and local npm ci complete; tests held for Sage CLEAR
2026-10-25 | magica | row331 GREEN on a86a881e1 under sage-4420 | stopped first RED:54 files pass,6 new integration cases fail missing fixture dataRoot; no edits/reruns; slot returned to Sage05:10:48Z
-70
View File
@@ -1,70 +0,0 @@
# PRD 1.18 brief: REQ-INST-2 prints the providers entry
Sage, 2026-10-25, for Mos's ruling mos-1671 on sage-4418. This follows
`docs/plans/BRIEF-TEMPLATE.md`. Vikunja 458 (bundled Pocket ID).
## PRD 1.18 REQ-INST-2 prints the providers entry (mos-1671)
### Problem
REQ-INST-2's second bullet says the stack installs the bundled Pocket ID "and
writes it as the Console's one providers entry". That sentence is Mos's reading
(mos-1202), not Jason's words. The row 255 design, landed at de3e23dde, chose R1:
enroll prints the entry and writes nothing, because config.json is user-authored
and never overwritten (canon invariant 2, Q175). Mos approved R1 in mos-1666 and
ruled the amendment in mos-1671. What the requirement allows or forbids is
unchanged.
### Owner and reviewer
- Owner: Gosalyn.
- Reviewer: Webby.
- After: row 255 (landed).
### Files owned
- `docs/prd/mosaic-stack.md`.
- `packages/tasks/requirements.json`: version 1.18 only. No id or title changes,
so the count stays 87.
- `packages/tasks/tests/requirements.test.mjs`: the version pin 1.18 (:24).
### What ships
- In REQ-INST-2's second bullet (PRD :1519-1520), replace
"and writes it as the Console's one providers entry."
with
"and prints the Console's one providers entry for the user to add to
config.json. The stack never writes config.json (canon invariant 2)."
The rest of the bullet stays, and so does Jason's Q203 quote.
- Status line: version 1.18.
- The header gets a 1.18 sentence: Version 1.18 corrects Mos's reading in
REQ-INST-2 to match the approved row 255 design. The stack prints the
providers entry and never writes config.json. What the requirement allows or
forbids is unchanged (mos-1671, sage-4418). Doc-only, so it takes the 1.11
path: one seat drafts, a second reviews, Sage lands.
- A "Version 1.18 source" line, placed before the 1.17 one: Mos's ruling
mos-1671 on 2026-10-25, answering sage-4418, with the old and new text.
### Out of scope
- The P1 to P6 build rows from the row 255 design. Sage files them after this
lands (mos-1671).
- Any other REQ-INST-2 wording, and REQ-INST-1 and REQ-INST-3.
### Gate
- Webby checks that the PRD diff is the replacement, the Status line, the 1.18
sentence and the source line, and nothing else.
- On Sage's gate:
- tasks, run whole;
- bus, run whole, because the broker loads requirements.json;
- cli `stack-copy.test.mjs`;
- every `scripts/test-*.sh`, under `timeout -k 10`.
- Mos gets the landing commit.
### Handoffs
Every seat hands off directly (DECISIONS Q210, mos-1221). When the owner pushes a
round or the reviewer posts a verdict, that seat messages the next seat's T3
thread and Sage's with `mcp__t3__t3_send_message`. Mos has one step here, which
is receiving the landing commit.
+8
View File
@@ -820,3 +820,11 @@ refresh. The proxy kept serving with its fallback version. Evidence: origin/feth
cutover, find a config switch that turns these off and set it in P1's initial config with a test, or
decide on purpose to allow or block that egress and record it in P7's receipt. Tracked as Vikunja 633
(umbrella 237).
## Row 331: human-only per-agent model rotation override — P7
Sage ruling sage-4388 (2026-10-24) limits P2 to the 30-day model-key rotation default,
with no setter and no `proxy.rotate --agent --rotate-by` verb. A human-only
per-agent rotation override is bound to P7, with Jason's cutover/settings review.
It must preserve D20/Q222: no agent or tester can change the period. Row 331 ships
only the default and the approved proxy.json grace/first-output settings.
+9 -9
View File
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
<!-- mosaic-queue:begin -->
Generated from `docs/plans/queue.json` revision 4095 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
Generated from `docs/plans/queue.json` revision 4072 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|---|---|---|---|---|---|---|---|---|
@@ -229,7 +229,7 @@ Generated from `docs/plans/queue.json` revision 4095 by `queue render`. Do not e
| 190 | several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414) | darkwing; reviewers filbert | #1688 | done | — | Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed (sage) | `docs/plans/2026-10-20_multi-instance.md` § Brief: several Mosaic Stack instances on one host | Landed 00d4cc1f6 on refactor (base 99a406ed3). Patch-id 5ec610a7 matches git diff 176a6bf4b..df65a3d4c on the 25 manifest paths. Manifest cc0ea9e8, Filbert approve r3 29713. Gate on d0bbc5c1c: packages 2664/0, scripts 429/0. Mutants 20/20. Unpushed. |
| 191 | one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414) | rocko; reviewers dewey | #1689 | done | — | Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_kill-switch.md` § Brief: one kill switch for the bus, every run, Discord and new launches | Landed 0fa7bf421 fast-forward on 86f95bb27 (10 commits, 89 paths; manifest 0bcbf337 = 88 paths without BUILD.md). Dewey manifest APPROVE c30359 rev 3118. Gate: 77 mutants killed (75 assert + SR08, KS54 timeout with controls), suites green on Sage's window; N7 closed by runs suite 43/0/0 on 0fa7bf421. N6, clock fix, :52 assertion, generic cause go to Vikunja 502 (mos-1267/1268). Rebase next: 205, 236, 194, 253, 274, 268. |
| 192 | Q175 phase 1: the Console verifies SSO itself (--sso), OIDC RP with oauth4webapi 3.8.8, no live change (MVP T1.2, #412) | launchpad; reviewers webby | #1690 | done | — | Webby approves the manifest; webui and cli suites, scripts/test-config.sh and every scripts/test-*.sh green on Sage's gate; design section 9 mutants killed (sage) | `docs/plans/2026-10-20_sso-phase1.md` § Brief: Q175 phase 1, the Console verifies SSO itself (--sso) | Landed 4429a104f on refactor (squash of launchpad/row192 53bb59462..37582e820, round 2 manifest 6f8cee14...7787). patch-id 4a4869026 matches the reviewed diff on all 18 paths. Gate on 088087ac9: package suites 2758/0, scripts 429/0, mutants 22 of 23 killed (#23 equivalent, sage-1407). npm ci in canonical, oauth4webapi 3.8.8. Live re-pin is a window with Mos (mos-1099 item 3). |
| 193 | many humans per business, human roles, private and shared threads, one access.json store (MVP T1.3, Vikunja #414/#415) | morgana; reviewers duckworth | #1691 | in-review, round 1, request posted | — | Duckworth approves DESIGN.md then the manifest; bus, business, cli and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users.md` § Brief: many humans per business, human roles, private and shared threads | — |
| 193 | many humans per business, human roles, private and shared threads, one access.json store (MVP T1.3, Vikunja #414/#415) | morgana; reviewers duckworth | #1691 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, business, cli and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users.md` § Brief: many humans per business, human roles, private and shared threads | — |
| 194 | agent lifecycle from the Console and the CLI (create, launch, terminate, regenerate, delete) with a per-agent hire/fire veto (MVP T1.5, Vikunja #414/#415) | honker; reviewers webby | #1692 | done | — | Webby approves DESIGN.md then the manifest; bus, cli, business and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: agent lifecycle from the Console and the CLI, with a per-agent hire/fire veto | Landed fd6dd327c on refactor, a fast-forward on e122aad8e. b18 is b17 3f451772d rebased: 16 commits =, series patch-id 64303253f8e6, packages equal b17. Webby approve webby-0120 on manifest 29f131ab. Honker gate on fd6dd327c: runb8 39 files 503/0/0, mutants 17/17; gate.sh 18 packages 3668/0, ten scripts rc=0, Docker up, skips environment-only and named. Overlap with 13 and 257 clean. #1692 closed. |
| 195 | row 195: the Case 1 install on 10.1.1.53 as mosaic under /srv/mosaic, reusing C2-13's scripts by input under row 190's canon (Q181, Q182/Q183, Q185; ops-01 root steps under #423 step 1) | darkwing; reviewers duckworth | #1693 | done | 190 done, 226 done, 233 done | Duckworth approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Brief: the Case 1 install on 10.1.1.53 (row 195) | LANDED b65014647 (pushed 361ba2387 tip). Patch-id 656e43ff25de = 4ced5e63d..37171f65b; verify-commit ok, 5 paths. Combined gate with 213 on 38b83ea5c+paths 09:45:52-09:57:52Z: business 200/0 bus 542/0 harness 140/0 cli 663/0, all 10 scripts green, L1 killed. N1-N3 to DEFERRED next; follow-up darkwing/row195-n1 1e3c4767c. |
| 196 | the human boundary across uids and humans: Console, tester listener, broker.sock and the path-bound human proof (P19 333, before federation R3, mos-0989) | gander; reviewers duckworth | #1694 | done | — | Duckworth approves DESIGN.md then the manifest; bus, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-boundary.md` § Brief: the human boundary across uids and humans (P19 333) | Landed 7247d80c7 on 666173292: 39 manifest paths of 527b9a1e6 (CANDIDATE.sha256 537d30ae), patch-id 6f3777d593e6 matches f48725cfc..527b9a1e6 on the manifest, blob diff empty, tree matches merge-tree. Gate gander-0066 2334/0/1, all 10 shells, 38/38 mutants. Approved rev 2973 and rev 2986 (Duckworth, c30156). Overlap: 202 cli README and 194 Honker branches rebase onto it. BUILD-LOG and C1 DEFERRED follow doc-only. |
@@ -250,7 +250,7 @@ Generated from `docs/plans/queue.json` revision 4095 by `queue render`. Do not e
| 211 | Force-push guard: scripts/git-hooks/pre-push refuses non-fast-forward and delete pushes from every worktree, and scripts/slot-busy.sh exits 1 on BUSY (#1709, Vikunja #426, mos-1078/1083) | darkwing; reviewers dewey | #1709 | in-progress | — | Dewey approves the manifest; the new or changed scripts/test-*.sh and every other scripts/test-*.sh green on Sage's gate; the mutants killed; hook installed in the canonical checkout with a scripted refused force-push (sage) | `docs/plans/2026-10-20_force-push-guard.md` § Brief: a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY (#426) | — |
| 212 | Case 2 C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey | gosalyn; reviewers webby | #1713 | done | — | Webby approves the manifest. The library tests and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey | Landed ee7b2f137 (pushed 09:13Z) on f6c70f944, alone per mos-1162: the 21 manifest paths from a2c72ac95. Patch-id ea84d3e35470 equals the candidate's; blobs and modes match; verify-commit ok (21 paths). No 212 path changed after c771a36f4. Gate: Sage's landing gate 08:51-09:03Z on 4ced5e63d plus 212's and 197's paths, GATE_PASSED, node 981/0, selftests 429/0. Gosalyn r3 slot 451/0, test-queue 28/0. Row 197 waits for r6 (lane proof stopped, sage-1881). |
| 213 | Case 2 C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | morgana; reviewers webby | #1714 | done | — | Webby approves the manifest. The business, bus and harness suites, `scripts/test-config.sh` and every `scripts/test-*.sh` are green on Sage's gate, and the `models` mutant is killed. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | LANDED 361ba2387 (39 paths incl. 4 packet files). Patch-id f313e69b6f7e = c771a36f4..2b3e67e36; verify-commit ok, 35 manifest paths. Same combined gate as 195: business 200/0 bus 542/0 harness 140/0 cli 663/0, test-config 31/0 and all 10 scripts green, models mutant L1 killed. Webby N2 is row 235 acceptance (mos-1163). |
| 214 | Case 2 C2-11a editor wikilink node, after measuring whether [[ round-trips | herb; reviewers filbert | #1715 | done | — | Filbert approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11a editor wikilink node, after measuring whether [[ round-trips | LANDED 3a8e4abad (pushed 78b69e1e6..3a8e4abad, 6 commits cherry-picked onto 78b69e1e6). Gate GREEN 04:10:34Z, all package suites and scripts; queue package runs via test-queue.sh (156). Filbert approve c31679 rev 4093. Range-diff equal except BUILD-LOG context. |
| 214 | Case 2 C2-11a editor wikilink node, after measuring whether [[ round-trips | herb; reviewers filbert | #1715 | in-progress | — | Filbert approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11a editor wikilink node, after measuring whether [[ round-trips | — |
| 215 | Case 2 C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | herb; reviewers filbert | #1716 | done | — | Filbert approves the manifest. The business, seat, cli, project, bus and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | LANDED e54e05463 on 6096b051b (sage-2094). Patch-id 2f68188b6c9d equals a36523713..dde3445ab; 118 paths, blobs and modes equal; tree 5b1613245 equals merge-tree. No path overlap with rows 66/243; no hash pins either way. Gate: Filbert APPROVE rev 2924; herb-0095 grant sage-2071 2634 pass 0 fail, 1 webui skip (editor rebuild needs esbuild 0.28.2, env, not a 215 path), accepted. |
| 216 | Case 2 C2-05a lookup index and queries | researcher; reviewers filbert | #1717 | done | 268 done | Filbert approves the manifest. The library suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05a lookup index and queries | LANDED fbe57a05b (replay of r5 dae76ccc2 onto 8e2885d26, 3 commits, 0 patch-id mismatches), pushed 21:09Z with 582b86935. Sage joint gate GREEN 21:05:34Z on fbe57a05b, every package suite but queue and every scripts/test-*.sh green. Filbert APPROVE r2 c31384 rev 3697. N3 (get returns a universe own supporting files) binds the C2-05c row; N1 doc, N2 universeInfo echo and BUILD.md pronoun nit go to a later docs commit. |
| 217 | Case 2 C2-06 the citation guard, the page store, the runner texts and the retry | beakley; reviewers webby | #1718 | done | 268 done, 283 done | Webby approves the manifest. The library, bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-06 the citation guard, the page store, the runner texts and the retry | LANDED d0a6a87cd (pushed 11:15Z 10-24, with 296): 9 cherry-picks on 296, range-diff 5 = and 4 ! (BUILD-LOG/SESSIONS append conflicts kept both), blobs equal 96659ccf4 (79 paths); Webby c31549 rev 3901; gate GREEN 11:14:53Z own npm ci, library 142 bus 974 business 270 cli 988 harness 172 0 fail, all scripts rc 0; PUSH_READY; #1718 closed c31559; Vikunja 533 comment 1054. |
@@ -291,7 +291,7 @@ Generated from `docs/plans/queue.json` revision 4095 by `queue render`. Do not e
| 252 | Console inbox Approve and Deny for grant requests (#435, J2) | scrooge; reviewers webby | #1753 | done | — | Webby approves DESIGN.md then manifest; webui, bus, cli suites and every test-*.sh green on Sage's gate; DESIGN mutants killed; REQ-WEB-2 PRD amendment landed first (sage) | `docs/plans/2026-10-21_console-inbox-approve.md` § Gate | Landed 45ea3fa0d (replay of Scrooge 580037105 onto 30e6bb680, range-diff all =, 10 commits). Webby APPROVE c31229 rev 3612. Sage gate on 45ea3fa0d: every package suite 0 fail 0 skipped (bus 907, cli 943, webui 410), every scripts/test-*.sh rc 0, J1-J19 all killed except J11c and J18 (equivalent per BUILD.md), all restores match. Old-test numstat 1 1 vs fe4ee8c7b held. |
| 253 | F27: case2-unit.sh and case1-accept.sh move to --tester-socket and refuse --tester-port with stack-release's migration wording; no .53/.54 copy with 196 until this lands (mos-1198 item 4) | gander; reviewers duckworth | #1758 | done | 196 done | reviewer approves DESIGN.md then the manifest; cli and webui suites whole and every scripts/test-*.sh on Sage's slot; mutants per DESIGN.md; static check covers scripts and tests (sage) | `docs/plans/2026-10-21_tester-socket-scripts.md` § Brief: case2-unit.sh and case1-accept.sh move to --tester-socket (F27) | Landed 48e450ce8 on refactor (fast-forward from aca16404c, 22 commits; landed sha = candidate, so range-diff and patch-id are identical by construction). Manifest 591020a1 (7 paths) rechecked against the commit: 0 mismatches. Gate: run 5 whole, cli 55 files, webui 58 files, all 10 scripts/test-*.sh green; M1-M40 killed 40/40. Duckworth APPROVE round 1, Gitea #1758 comment 30696. |
| 254 | Library sync follow-up: client timeout, STATUS codes, README (222 deferrals, #1759) | megavolt; reviewers dewey | #1759 | done | 196 done | Dewey approves the manifest; bus, library and webui suites whole and every scripts/test-*.sh green on Sage's slot; the timeout and STATUS mutants killed (sage) | `docs/plans/2026-10-21_library-sync-followup.md` § Gate | Landed 331597709 (candidate b31300d79 blobs and modes on 41b0cf755; patch-id 96216216 equal, range-diff differs only in the message) plus doc eb9672c91 (Dewey N1 README sentence closed; N2 four 502 codes to DEFERRED, binding the Console library routes row). Merged-tree rerun on Sage's slot 23:44-23:55Z: library 77/0, bus 628/0, webui 305/0 with 1 skip, cli 749/0. Megavolt's gate megavolt-0102 covers scripts and both mutants. |
| 255 | Bundled Pocket ID for users with no OIDC provider, design (Q203, #1760) | launchpad; reviewers webby | — | in-progress | 252 done | Webby approves DESIGN.md; design only, build rows follow after Sage checks it against Q203, O6, REQ-WEB-2 and REQ-INST-1 (sage) | `docs/plans/2026-10-21_bundled-pocket-id.md` § Gate | Vikunja 458, Gitea #1760, mos-1202 item 2. REQ-INST-2 lands in PRD 1.10 as post-v1 (mos-1204). Design only: no install, no download, no .53/.54, dragon-lin keeps Authentik. |
| 255 | Bundled Pocket ID for users with no OIDC provider, design (Q203, #1760) | launchpad; reviewers webby | — | queued | 252 done | Webby approves DESIGN.md; design only, build rows follow after Sage checks it against Q203, O6, REQ-WEB-2 and REQ-INST-1 (sage) | `docs/plans/2026-10-21_bundled-pocket-id.md` § Gate | Vikunja 458, Gitea #1760, mos-1202 item 2. REQ-INST-2 lands in PRD 1.10 as post-v1 (mos-1204). Design only: no install, no download, no .53/.54, dragon-lin keeps Authentik. |
| 256 | the human deletes a creative instance; any other actor refused, fire unchanged (REQ-COS-3, before the v1 tag) | honker; reviewers webby | #1761 | done | 194 done | Webby approves the manifest; touched test files whole plus business, bus, cli and webui suites and every scripts/test-*.sh green on Sage's slot; both mutants killed (sage) | `docs/plans/2026-10-21_creative-delete-human.md` § Brief: the human deletes a creative instance (REQ-COS-3, after row 194) | LANDED f12441d4f ff from 0165be266 (4 Honker commits, patch-id b28e6374). Webby APPROVE manifest-r3 01eb75aa (10 paths), request c30518. Gate 10:33-10:52Z: 4 touched files 31/0, 18 packages 3757/0 (bus 788, business 231, cli 832, webui 335), 10 scripts rc=0, mutants M1-M4b killed; skips named, env only (webui esbuild pin, test-queue root, test-task live x2). Overlap 13/257 clean. Lands before the 10-27 v1 tag. |
| 257 | Q198 S1: pressure parser and pure rules evaluator (post-v1) | morgana; reviewers filbert | #1762 | done | — | Reviewer approves the manifest; touched test files whole plus the package suite green on Sage's slot; both brief mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: Q198 S1 pressure parser and pure rules evaluator | Landed 73692e1bb, pushed 17:41:58Z 10-24: Morgana's 4 commits cherry-picked onto 487500a16, patches unchanged; 9 supervisor paths match Filbert r2 manifest b176a88f (APPROVE c30190, rev 3037). Gate RED 17:20Z (webui row278 sign-out timing, old test, row touches no webui), full rerun GREEN 17:41:11Z, cli skip FED R7 allowed. push-check clean. #1762 c31613, closed. Carry 447286192 lands doc-only. |
| 258 | Email E01: mail PRD addendum draft and schemas (post-v1) | ludwig; reviewers della | #1763 | in-progress | — | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E01 mail PRD addendum draft and schemas | — |
@@ -303,7 +303,7 @@ Generated from `docs/plans/queue.json` revision 4095 by `queue render`. Do not e
| 264 | T1 item 7: independent sessions, compaction counts and clear context | gizmoduck; reviewers duckworth | #1769 | done | — | The named reviewer approves the manifest. The design's T1-T7 acceptance tests run whole plus every touched suite on Sage's slot; T3 waits on Vikunja 466 (N2, Claude's compaction count format and the managed dataRoot reader). The design's section 6 mutants are killed. (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | LANDED 84f53540d ff on e479521d3 14:18:23Z, 21 commits = gizmoduck/row264-r13 tip (no rewrite, range-diff identity). Manifest 257408e9 approved Duckworth rev 3214 (c30640). Gate r13 2cc34aef8: 20 files, bus 827, cli 837, webui 345/1 skip, 10 shells; M1-M4 assertion kills (mos-1279 B, M2 4 files). Overlap none (13, 257). N1 actor_run binds row 269; N2 esbuild skip later gate (sage-2695). |
| 265 | Case 2 C2-09b rooms: room.open, room.send with @tags, room.read | goldie; reviewers boyd | #1770 | done | 219 done | Boyd approves the manifest. The brief's tests run whole plus the bus suite and every touched suite on Sage's slot. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-09b rooms: room.open, room.send with @tags, room.read | LANDED 200dd396c (cherry-pick of ef4c93853, 50 blobs equal, log appends equal) 05:13Z on full gate 04:57-05:12Z: all package suites except queue 0 fail 0 skip (bus 957, cli 986, seat 218), every scripts/test-*.sh green. Boyd c31501 incl mos-1567 mount read. 22 gzip logs scanned decompressed, clean. #1770 closed c31510. Bundle 02c3c286. |
| 266 | Case 2 C2-10c Console room view: merged timeline and @name completion | fethry; reviewers boyd, dewey | #1771 | done | 225 done, 265 done | Dewey approves the manifest. The webui suite is green on Sage's gate, along with every touched suite. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10c Console room view: merged timeline and @name completion | LANDED 3e5041b43 18:00:49Z 10-24, patch-ids equal to Fethry b8b26ae4d..6418ec063; gate GREEN 18:00:08Z first run (bus 1000/0, webui 458/0, cli 989+1 FED R7 allowed, 12 scripts rc 0); Boyd c31609, Dewey c31610; #1771 c31615 closed; Dewey N2 T9 race goes to Mos |
| 267 | Case 2 C2-15 hybrid universes: exclusions, pins and the end-to-end crossover | morgana; reviewers filbert | #1772 | in-progress | 216 done, 217 done, 224 done | Filbert approves the manifest. The brief's tests and the end-to-end crossover run whole plus every touched suite on Sage's slot. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-15 hybrid universes: exclusions, pins and the end-to-end crossover | R8 accepted (sage-2218). Webby's R8 noise note (gosalyn-0127) is for the builder. |
| 267 | Case 2 C2-15 hybrid universes: exclusions, pins and the end-to-end crossover | morgana; reviewers filbert | #1772 | queued | 216 done, 217 done, 224 done | Filbert approves the manifest. The brief's tests and the end-to-end crossover run whole plus every touched suite on Sage's slot. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-15 hybrid universes: exclusions, pins and the end-to-end crossover | R8 accepted (sage-2218). Webby's R8 noise note (gosalyn-0127) is for the builder. |
| 268 | Case 2 C2-01c playground levels: universe-level prefixes and draft universes | gizmoduck; reviewers webby | #1773 | done | — | Webby approves the manifest. The brief's tests run whole plus the library suite and every touched suite on Sage's slot; the brief's gate grep over packages and docs/case2 finds no library-playground-outside-book. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01c playground levels: universe-level prefixes and draft universes | Landed c27207f6e fast-forward on ce6a96ad6 (1 commit, 9 paths, digest caa0fa4e). r4 is a carry of r3 aca53c4f0: range-diff =, patch-id 2942acc8 equal, blobs equal. Webby APPROVE round 2 on ecd9b35eb stands; carries webby-0112 (r3) and webby-0113 (r4, notes 34e1ab2bf). Gate: Gizmoduck r4 rerun brief 14/14, library 83/83, business 209/209, 0 skips; gate grep empty. 194 rebases next (business.test.mjs). |
| 269 | 264b T1 item 7: per-session native store, workspace and single-writer fence | gizmoduck; reviewers duckworth | #1774 | done | 264 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T1 run half, T2 workspace and path mutants; whichever of 191 and 264b lands second wires 191's admission checks (Q5). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | Landed efcc9ea8d fast-forward on 94c33a43c (13 commits, 12 paths, manifest 455c12ee). r3 is a carry of r2 aafcc857c, range-diff = (Duckworth 0194, c30756, rev 3307). Gate r3 18:37-18:50Z all green, 0 skips: bus 834, seat 172, cli 869, case2-unit 43, singles 38. M1-M12 kills carry. Re-pin needs the Console restart by Jason or Mos right after the host. A 264a rollback refuses a v2 sessions.json, see BUILD.md Rollback. |
| 270 | 264c T1 item 7: compaction count, exact for Pi | gizmoduck; reviewers duckworth | #1775 | done | 269 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Test T3 Pi half; Claude shows unknown with the Vikunja 466 reason (Q6). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | LANDED 7328da8ca (with 271, mos-1351) 02:13Z 10-23 on 767bb62c7: 33 commits cherry-picked from gizmoduck/row270-271-r4 49cee846e (base 25c09f19f), range-diff all '=', 40 paths identical to 49cee846e. Manifest f4174458 (15 paths) matches the tip. Duckworth APPROVE c30930 rev 3389. C2 run 5 GREEN on 53406f3e4: bus 873, seat 202, cli 905, ten scripts, 38/38 mutants killed. #1775 closes. |
@@ -324,7 +324,7 @@ Generated from `docs/plans/queue.json` revision 4095 by `queue render`. Do not e
| 285 | Case 2 C2-08b role changes for agent writes and the chief's read grant | magica; reviewers poe, webby | #1790 | done | 213 done, 291 done | Webby approves the manifest. Business suite, test-config.sh and every scripts/test-*.sh green on Sage's gate. Grep for CREATIVE_KINDS and 64-hex pins of changed role files. Sage checks it against mos-1309 item 5. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-08b role changes for agent writes and the chief's read grant (mos-1309 item 5) | LANDED 1f3d0350f 13:33:42Z: candidate 2e4503dc7 (14 commits) cherry-picked onto 25874bc86. Logs kept both sides; otherwise the tree equals the candidate except on 7 base paths. Poe c31568 rev 3919, Webby c31569 rev 3920. Sage gate GREEN 13:28:46Z with its own npm ci: 4236 package tests, 0 fail, 1 allowed FED R7 skip; 12 scripts rc 0. No CREATIVE_KINDS, no role-file hash pins. Roles match mos-1309 item 5. 286 rebases as r5. |
| 286 | Case 2 C2-03b push and per-seat credentials for the library mirror (with R4, mos-1323) | rockerduck; reviewers boyd, dewey | #1791 | done | 222 done | Dewey approves the manifest. Library and bus suites and every scripts/test-*.sh green on Sage's gate, mutant killed. A search of the run output for the fixture token finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-03b push and per-seat credentials for the library mirror | LANDED 5d7801dc1 14:37:02Z: r6 75f6d56b1 (19 commits) picked onto d55944edb; logs kept both sides, code equals the candidate. Boyd c31587 rev 3942, Dewey c31588 rev 3943 (r2 c31584/c31585). Sage gate GREEN 14:36:42Z, own npm ci: 4251 tests, 0 fail, 1 allowed FED R7 skip; 12 scripts rc 0; fixture values in 0 of 29 outputs; B285-M1 kills T7/T9. r6 shortened two fixtures after push-check STOP secrets (sage-4112). Chief seed per sage-4100. |
| 287 | Case 2 C2-21 the grant executor for a library project | steelbeak; reviewers boyd, dewey | #1792 | done | 226 done, 285 done | Dewey approves the manifest. Bus suite, Gitea Docker test, install test and every scripts/test-*.sh green on Sage's gate. A search of the run output for the fixture admin and seat tokens finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-21 the grant executor for a library project | LANDED 0e935776e 16:12:35Z 10-24 (cand c2338ed58 + a5370b198 on 9b6345466). Boyd APPROVE c31602 rev 3967, Dewey APPROVE c31603 rev 3968. Sage gate GREEN 16:11:38Z: bus 998/0 (Gitea Docker F-A/F-B + X-G3, minted 4 hits 0), business 283/0, cli 989 +1 allowed FED R7 skip, webui 442/0, 12 scripts rc 0. F-C negative record-only follow-up. Dewey N1-N3 non-blocking. #1792 closed c31606. No P19 task. |
| 288 | Case 2 C2-02 agent writes, the notice, revert and her Console saves | bubba; reviewers webby | #1793 | in-progress | 215 done, 282 done, 283 done, 284 done, 285 done, 286 done | Webby approves the manifest. Library, bus and harness suites and every scripts/test-*.sh green on Sage's gate, mutants killed. A search of the run output for the fixture tokens finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-02 agent writes, the notice, revert and her Console saves | A2 revert contract (morgana-0199, sage-3596): library.revert is a human verb. Args {project, commit}, strict keys; project is the business.projects key (the chat repo link), not a UUID. Result {project, path, reverted, commit}. Refusals: 409 stale-base, and 409 library-revert-not-agent for an agent caller. Owner Bubba under Q245 (mos-1506). |
| 288 | Case 2 C2-02 agent writes, the notice, revert and her Console saves | bubba; reviewers webby | #1793 | briefed | 215 done, 282 done, 283 done, 284 done, 285 done, 286 done | Webby approves the manifest. Library, bus and harness suites and every scripts/test-*.sh green on Sage's gate, mutants killed. A search of the run output for the fixture tokens finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-02 agent writes, the notice, revert and her Console saves | A2 revert contract (morgana-0199, sage-3596): library.revert is a human verb. Args {project, commit}, strict keys; project is the business.projects key (the chat repo link), not a UUID. Result {project, path, reverted, commit}. Refusals: 409 stale-base, and 409 library-revert-not-agent for an agent caller. Owner Bubba under Q245 (mos-1506). |
| 289 | Case 2 C2-11b Console library pages and her saves (Haiku subagent, decision 129) | researcher; reviewers filbert | #1794 | briefed | 214 done, 288 done | Filbert approves the manifest. The webui suite and every scripts/test-*.sh are green on Sage's gate. Grep for hash pins on bus.js and serve.mjs before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11b Console library pages and her saves | — |
| 290 | Case 2 C2-12b the setup sheet rewrite for free chat, supporting folders and saves | louie; reviewers webby | #1795 | done | 217 done, 218 done, 285 done, 291 done | Doc only. Webby approves it against the ruling. Before landing, grep packages/*/tests and scripts/test-*.sh to confirm no test reads the file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12b the setup sheet rewrite for free chat, supporting folders and saves | LANDED 3d7dff345 on refactor, pushed 14:55:11Z: cherry-pick of 963303403..22510988d onto 18334d713, four commits equal by patch-id and range-diff, SETUP-SHEET.md blob 37b451c6 as approved. Gate per brief (doc only): Webby approve c31596 rev 3954; grep of packages/*/tests, scripts/test-*.sh and tests/ for SETUP-SHEET finds nothing (positive control hits). No suite run: no test reads the file (decision 137). #1795 closed c31597. |
| 291 | PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | gosalyn; reviewers webby | #1796 | done | 282 done | Webby approves the text. Jason says yes to the exact text (Mos asks him, mos-1328 step 3); no landing before that. requirements.test.mjs and every scripts/test-*.sh that reads the PRD or requirements.json are green on Sage's gate. Mos gets the landing commit. (sage) | `docs/plans/2026-10-22_prd-1-16-brief.md` § PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | Landed 4874d1e83 on e909ef51a: 482e865ac replayed, patch-id equal (cef7de74), range-diff =. Webby APPROVE c30848 on 482e865ac; Jason approved the text in Q229. Gate 16:15Z on the tip tree: every package suite but queue, 0 fail 0 skipped (tasks 100, bus 891, cli 941, plugins 119, webui 392, business 231), every scripts/test-*.sh rc 0 (queue-root and row 244 skips only). PRD 1.16. 1.17 is row 308. |
@@ -366,11 +366,11 @@ Generated from `docs/plans/queue.json` revision 4095 by `queue render`. Do not e
| 327 | Push check in the repository: scripts/push-check.sh passes a binary only when an approved review candidate lists its path and sha256, plus scripts/test-push-check.sh (#1825, mos-1572 A) | bradford; reviewers dewey, poe | #1825 | done | — | Poe gives the first Codex review, Dewey reviews the landing candidate; scripts/test-push-check.sh and every other scripts/test-*.sh green on Sage's gate; the mutants killed; Sage's next push runs through scripts/push-check.sh (sage) | `docs/plans/2026-10-24_push-guard-binary-manifest.md` § Brief: push-check.sh passes a binary only when the reviewed candidate lists its path and sha256 | LANDED 68c08b4e7 (pushed 07:18:03Z 10-24): 3 commits on 6a5e12511, blobs equal round 3 d858c9f8b; Poe c31525 rev 3882, Dewey c31526 rev 3883; gate GREEN 07:15:49Z with its own npm ci (first run red on empty canonical node_modules, sage-4023); 6 push-check mutants KILLED; next push 42ba0f5bc ran through scripts/push-check.sh --fetch, PUSH_READY; #1825 closed c31530. |
| 328 | T9 library-credential test: the second broker start passes Node's TIMEOUT_MAX interval so no timer tick overlaps the verb sync; mutant fails a 50-run stress loop (#1826, Vikunja 622, mos-1594) | fethry; reviewers boyd, dewey | #1826 | done | — | Boyd gives the first Codex review, Dewey reviews the landing candidate; touched tests run whole, the 50-run stress loop passes with the fix and M1 fails it; bus and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-24_t9-library-sync-race.md` § Brief: the T9 verb sync on the second start runs with no timer tick able to overlap it | LANDED 8bb5b3c73 19:40Z 10-24 (8 Fethry commits on 5e77cdd09). Gate GREEN 19:38:54Z on 8bb5b3c73, no drift. Boyd APPROVE c31621, Dewey APPROVE c31622. #1826 closed c31625; Vikunja 622 done. |
| 329 | Model proxy P1: model-proxy package, pinned fetch with sha256 refusal, mosaic proxy init, proxy unit (237, mos-1600) | magica; reviewers boyd, dewey | #1827 | done | — | Boyd first review, re-measuring both release hashes; Dewey landing review; BUILD.md has the acceptance output. Sage's landing gate: every package suite but queue plus every scripts/test-*.sh green, touched test files whole, refusals each tested, named mutants killed (brief Gate). (sage) | `docs/plans/2026-10-24_model-proxy-rows.md` § Brief: P1 the model-proxy package, pinned fetch, proxy init and the proxy unit | Landed 61ebf2c34 (pushed 23:31:27Z 10-24); Magica r2 commits cherry-picked onto 669cdf42c. Boyd c31642 rev 4048, Dewey c31643 rev 4047. Sage fresh gate GREEN 23:29:14Z (mos-1634). #1827 closed c31650; Vikunja 237 comment 1109, stays open for P2-P7. DEFERRED: N2, R2-N1 to row 331; N1 to P7. |
| 330 | Model proxy P4: Console proxy page (OAuth sign-in, API keys, priority, key list, usage) via bus verbs (Q250, mos-1606/1608) | poe; reviewers filbert, gladstone | #1828 | in-progress | — | Gladstone first review; Filbert landing review; starts now, lands after P1 rebased on it; webui with MOSAIC_REQUIRE_EDITOR_BUILD=1. Sage gate: all package suites but queue and every scripts/test-*.sh green; brief Gate. (sage) | `docs/plans/2026-10-24_proxy-page-brief.md` § Brief: P4 the Console proxy page | — |
| 331 | Model proxy P2: per-agent model keys, reconcile and launch preflight (237, mos-1600) | magica; reviewers boyd, dewey | #1829 | in-progress | 329 done | Boyd first review; Dewey landing review; three named mutants killed. Sage gate: all package suites but queue and every scripts/test-*.sh green; brief Gate. (sage) | `docs/plans/2026-10-24_model-proxy-rows.md` § Brief: P2 per-agent model keys, reconcile and the launch preflight | P2 input from magica-0020 (acceptance-probe-4): 8.0.42 revocation lags ~105ms after v8 PUT (first call 200, then 401); reconcile/revoke must poll to 401, not assume instant. Per-key model objects give 422; no per-key model restriction. |
| 330 | Model proxy P4: Console proxy page (OAuth sign-in, API keys, priority, key list, usage) via bus verbs (Q250, mos-1606/1608) | poe; reviewers filbert, gladstone | #1828 | briefed | — | Gladstone first review; Filbert landing review; starts now, lands after P1 rebased on it; webui with MOSAIC_REQUIRE_EDITOR_BUILD=1. Sage gate: all package suites but queue and every scripts/test-*.sh green; brief Gate. (sage) | `docs/plans/2026-10-24_proxy-page-brief.md` § Brief: P4 the Console proxy page | — |
| 331 | Model proxy P2: per-agent model keys, reconcile and launch preflight (237, mos-1600) | sage; reviewers boyd, dewey | #1829 | briefed | 329 done | Boyd first review; Dewey landing review; three named mutants killed. Sage gate: all package suites but queue and every scripts/test-*.sh green; brief Gate. (sage) | `docs/plans/2026-10-24_model-proxy-rows.md` § Brief: P2 per-agent model keys, reconcile and the launch preflight | P2 input from magica-0020 (acceptance-probe-4): 8.0.42 revocation lags ~105ms after v8 PUT (first call 200, then 401); reconcile/revoke must poll to 401, not assume instant. Per-key model objects give 422; no per-key model restriction. |
| 332 | Model proxy P3: harness config from the proxy (237, mos-1600) | fethry; reviewers dewey, gladstone | #1830 | in-progress | — | Gladstone first review; Dewey landing review; named mutant killed. Sage gate: all package suites but queue and every scripts/test-*.sh green; brief Gate. (sage) | `docs/plans/2026-10-24_model-proxy-rows.md` § Brief: P3 harness config from the proxy | mos-1616: amendment docs/plans/2026-10-24_p3-pi-routes-amendment.md (blob 079debd5605c, c8a9bcf94) replaces P3 item 4's non-Anthropic line and adds tests and a mutant. Starts now on a new Codex thread against P2's {baseUrl, key}; lands only after 329 and 331 land and its own gate (mos-1351). |
| 333 | Model proxy P5: usage plug-in and per-key limits, per-key count only (237, mos-1600) | sage; reviewers boyd, duckworth | #1831 | briefed | 331 done | Boyd first review; Dewey landing review; named mutant killed. Sage gate: all package suites but queue and every scripts/test-*.sh green; brief Gate. (sage) | `docs/plans/2026-10-24_model-proxy-rows.md` § Brief: P5 usage plug-in and per-key limits | — |
| 334 | Case 2 proxy unit: case2-unit.sh renders P1's mosaic-proxy.service and the bus Wants/After under --proxy (237, mos-1616) | rockerduck; reviewers boyd, webby | #1832 | in-progress | 329 done | Codex builder after 329 lands; Boyd first review, Webby Claude review; run list from imports, case1-accept off; mutants: bus loses After= under --proxy, proxy ExecStart skips run.mjs; Sage landing gate: every package suite but queue, every scripts/test-*.sh (sage) | `docs/plans/2026-10-24_case2-proxy-unit-brief.md` § Brief: Case 2 proxy unit in case2-unit.sh and case2-install.sh | — |
| 334 | Case 2 proxy unit: case2-unit.sh renders P1's mosaic-proxy.service and the bus Wants/After under --proxy (237, mos-1616) | sage; reviewers boyd, webby | #1832 | queued | 329 done | Codex builder after 329 lands; Boyd first review, Webby Claude review; run list from imports, case1-accept off; mutants: bus loses After= under --proxy, proxy ExecStart skips run.mjs; Sage landing gate: every package suite but queue, every scripts/test-*.sh (sage) | `docs/plans/2026-10-24_case2-proxy-unit-brief.md` § Brief: Case 2 proxy unit in case2-unit.sh and case2-install.sh | — |
<!-- mosaic-queue:end -->
+38 -724
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"canonicalRoot": "/mnt/storage/src/mosaic-stack",
"revision": 4095,
"revision": 4072,
"rows": [
{
"id": 1,
@@ -20532,7 +20532,7 @@
"closes": [
1691
],
"state": "in-review",
"state": "in-progress",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -20547,72 +20547,7 @@
"reviewers": [
"duckworth"
],
"review": {
"rounds": [
{
"n": 1,
"op": "morgana-193-inreview",
"by": "morgana",
"at": "2026-10-25T04:01:27.379Z",
"issue": 1691,
"candidate": {
"kind": "commit",
"digest": "e2d161e2e6354fe7f7083b95ccae8b202513f5e5",
"text": null
},
"request": "comment",
"attempts": [
{
"op": "morgana-193-inreview",
"by": "morgana",
"at": "2026-10-25T04:01:27.379Z",
"state": "abandoned",
"comment": null,
"transport": null,
"resolutions": [
{
"verb": "abandon",
"op": "sage-4364-ab193",
"by": "sage",
"at": "2026-10-25T04:05:57.752Z",
"comment": null,
"reason": "pre-send credential check failed (morgana has no token file); outcome not logged under lock contention; no comment on #1691 (checked 04:05Z, 0 comments)"
}
]
},
{
"op": "sage-4364-rq193",
"by": "sage",
"at": "2026-10-25T04:08:08.349Z",
"state": "posted",
"comment": 31674,
"transport": null,
"resolutions": [
{
"verb": "resolve",
"op": "sage-4372-rs193",
"by": "sage",
"at": "2026-10-25T04:13:06.534Z",
"comment": 31674,
"reason": null
}
]
}
],
"duplicateRisk": true,
"receipts": [
{
"reviewer": "duckworth",
"op": "duckworth-193-verdict-r1",
"at": "2026-10-25T04:19:54.404Z",
"verdict": "approve",
"comment": 31676,
"candidate": "e2d161e2e6354fe7f7083b95ccae8b202513f5e5"
}
]
}
]
},
"review": null,
"claim": {
"seat": "morgana",
"op": "morgana-193-start"
@@ -20620,8 +20555,8 @@
"note": null,
"blockedReason": null,
"createdAt": "2026-10-20T18:53:02.879Z",
"updatedAt": "2026-10-25T04:19:54.404Z",
"updatedBy": "duckworth"
"updatedAt": "2026-10-20T18:57:44.367Z",
"updatedBy": "morgana"
},
{
"id": 194,
@@ -23197,7 +23132,7 @@
"closes": [
1715
],
"state": "done",
"state": "in-progress",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -23212,73 +23147,15 @@
"reviewers": [
"filbert"
],
"review": {
"rounds": [
{
"n": 1,
"op": "herb-214-inreview",
"by": "herb",
"at": "2026-10-25T04:10:39.673Z",
"issue": 1715,
"candidate": {
"kind": "commit",
"digest": "584ee1e2067982edb71459af7c15e4ed7162279a",
"text": null
},
"request": "comment",
"attempts": [
{
"op": "herb-214-inreview",
"by": "herb",
"at": "2026-10-25T04:10:39.673Z",
"state": "abandoned",
"comment": null,
"transport": null,
"resolutions": [
{
"verb": "abandon",
"op": "sage-4379-ab214",
"by": "sage",
"at": "2026-10-25T04:20:59.781Z",
"comment": null,
"reason": "no comment on #1715 (comments empty at 04:14:08Z); herb has no Gitea token, so the pre-send step could not post; Sage posts the request"
}
]
},
{
"op": "sage-4379-rq214",
"by": "sage",
"at": "2026-10-25T04:22:04.588Z",
"state": "posted",
"comment": 31678,
"transport": {
"outcome": "posted",
"status": 201,
"comment": 31678,
"detail": "created"
},
"resolutions": []
}
],
"duplicateRisk": true,
"receipts": [
{
"reviewer": "filbert",
"op": "filbert-214-r1-record",
"at": "2026-10-25T04:26:01.460Z",
"verdict": "approve",
"comment": 31679,
"candidate": "584ee1e2067982edb71459af7c15e4ed7162279a"
}
]
}
]
"review": null,
"claim": {
"seat": "herb",
"op": "herb-214-start"
},
"claim": null,
"note": "LANDED 3a8e4abad (pushed 78b69e1e6..3a8e4abad, 6 commits cherry-picked onto 78b69e1e6). Gate GREEN 04:10:34Z, all package suites and scripts; queue package runs via test-queue.sh (156). Filbert approve c31679 rev 4093. Range-diff equal except BUILD-LOG context.",
"note": null,
"blockedReason": null,
"createdAt": "2026-10-20T20:45:11.283Z",
"updatedAt": "2026-10-25T04:30:41.788Z",
"updatedAt": "2026-10-24T22:05:24.948Z",
"updatedBy": "sage"
},
{
@@ -27518,7 +27395,7 @@
"owner": "launchpad",
"issues": [],
"closes": [],
"state": "in-progress",
"state": "queued",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -27539,15 +27416,12 @@
"webby"
],
"review": null,
"claim": {
"seat": "launchpad",
"op": "launchpad-255-start"
},
"claim": null,
"note": "Vikunja 458, Gitea #1760, mos-1202 item 2. REQ-INST-2 lands in PRD 1.10 as post-v1 (mos-1204). Design only: no install, no download, no .53/.54, dragon-lin keeps Authentik.",
"blockedReason": null,
"createdAt": "2026-10-21T20:25:14.463Z",
"updatedAt": "2026-10-25T04:24:42.333Z",
"updatedBy": "launchpad"
"updatedAt": "2026-10-21T20:25:14.463Z",
"updatedBy": "sage"
},
{
"id": 256,
@@ -28319,7 +28193,7 @@
"closes": [
1772
],
"state": "in-progress",
"state": "queued",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -28348,15 +28222,12 @@
"filbert"
],
"review": null,
"claim": {
"seat": "morgana",
"op": "morgana-267-start"
},
"claim": null,
"note": "R8 accepted (sage-2218). Webby's R8 noise note (gosalyn-0127) is for the builder.",
"blockedReason": null,
"createdAt": "2026-10-21T22:44:03.355Z",
"updatedAt": "2026-10-25T04:18:49.322Z",
"updatedBy": "morgana"
"updatedAt": "2026-10-24T22:11:36.776Z",
"updatedBy": "sage"
},
{
"id": 268,
@@ -30888,7 +30759,7 @@
"closes": [
1793
],
"state": "in-progress",
"state": "briefed",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -30929,15 +30800,12 @@
"webby"
],
"review": null,
"claim": {
"seat": "bubba",
"op": "bubba-288-start"
},
"claim": null,
"note": "A2 revert contract (morgana-0199, sage-3596): library.revert is a human verb. Args {project, commit}, strict keys; project is the business.projects key (the chat repo link), not a UUID. Result {project, path, reverted, commit}. Refusals: 409 stale-base, and 409 library-revert-not-agent for an agent caller. Owner Bubba under Q245 (mos-1506).",
"blockedReason": null,
"createdAt": "2026-10-22T18:33:12.519Z",
"updatedAt": "2026-10-25T04:04:36.422Z",
"updatedBy": "bubba"
"updatedAt": "2026-10-24T21:59:54.957Z",
"updatedBy": "sage"
},
{
"id": 289,
@@ -34738,7 +34606,7 @@
"closes": [
1828
],
"state": "in-progress",
"state": "briefed",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -34755,27 +34623,24 @@
"gladstone"
],
"review": null,
"claim": {
"seat": "poe",
"op": "poe-330-start"
},
"claim": null,
"note": null,
"blockedReason": null,
"createdAt": "2026-10-24T19:09:06.624Z",
"updatedAt": "2026-10-25T03:59:15.459Z",
"updatedBy": "poe"
"updatedAt": "2026-10-24T21:06:08.273Z",
"updatedBy": "sage"
},
{
"id": 331,
"piece": "Model proxy P2: per-agent model keys, reconcile and launch preflight (237, mos-1600)",
"owner": "magica",
"owner": "sage",
"issues": [
1829
],
"closes": [
1829
],
"state": "in-progress",
"state": "briefed",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -34797,15 +34662,12 @@
"dewey"
],
"review": null,
"claim": {
"seat": "magica",
"op": "magica-331-start"
},
"claim": null,
"note": "P2 input from magica-0020 (acceptance-probe-4): 8.0.42 revocation lags ~105ms after v8 PUT (first call 200, then 401); reconcile/revoke must poll to 401, not assume instant. Per-key model objects give 422; no per-key model restriction.",
"blockedReason": null,
"createdAt": "2026-10-24T19:11:25.454Z",
"updatedAt": "2026-10-25T03:57:40.286Z",
"updatedBy": "magica"
"updatedAt": "2026-10-24T19:28:58.606Z",
"updatedBy": "sage"
},
{
"id": 332,
@@ -34886,14 +34748,14 @@
{
"id": 334,
"piece": "Case 2 proxy unit: case2-unit.sh renders P1's mosaic-proxy.service and the bus Wants/After under --proxy (237, mos-1616)",
"owner": "rockerduck",
"owner": "sage",
"issues": [
1832
],
"closes": [
1832
],
"state": "in-progress",
"state": "queued",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -34915,15 +34777,12 @@
"webby"
],
"review": null,
"claim": {
"seat": "rockerduck",
"op": "rockerduck-334-start"
},
"claim": null,
"note": null,
"blockedReason": null,
"createdAt": "2026-10-24T23:14:14.427Z",
"updatedAt": "2026-10-25T04:02:42.539Z",
"updatedBy": "rockerduck"
"updatedAt": "2026-10-24T23:14:14.427Z",
"updatedBy": "sage"
}
],
"log": [
@@ -141131,551 +140990,6 @@
"receipt": "ok sage-284-done-landed rev 4072 row 284 in-review→done round 2"
},
"viewSha": "546679f234a78b2d4582ed5a50b90c7427bf82cf2bd75243eeccad1f5730fa79"
},
{
"rev": 4073,
"op": "sage-4334-a331",
"verb": "assign",
"args": {
"id": 331,
"seat": "magica"
},
"by": "sage",
"at": "2026-10-25T03:52:17.506Z",
"semantics": 2,
"result": {
"row": 331,
"field": "owner",
"from": "sage",
"to": "magica",
"receipt": "ok sage-4334-a331 rev 4073 row 331 owner: sage→magica"
},
"viewSha": "1e9c8f52178b479578892e5663e3527a9dae656170ed3438cddeca2c78866955"
},
{
"rev": 4074,
"op": "sage-4334-a334",
"verb": "assign",
"args": {
"id": 334,
"seat": "rockerduck"
},
"by": "sage",
"at": "2026-10-25T03:53:46.926Z",
"semantics": 2,
"result": {
"row": 334,
"field": "owner",
"from": "sage",
"to": "rockerduck",
"receipt": "ok sage-4334-a334 rev 4074 row 334 owner: sage→rockerduck"
},
"viewSha": "2d93ddcefd3cf8b25beede74eaba414a8da4edee72a9735413ee095e15435a50"
},
{
"rev": 4075,
"op": "sage-4334-b334",
"verb": "move",
"args": {
"id": 334,
"to": "briefed",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-25T03:55:04.114Z",
"semantics": 2,
"result": {
"row": 334,
"from": "queued",
"to": "briefed",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok sage-4334-b334 rev 4075 row 334 queued→briefed"
},
"viewSha": "8cd19c9b7486a91e219faf7ae2d731bcdafc4a92bb4aaf2422f55ef9edf5ff8f"
},
{
"rev": 4076,
"op": "magica-331-start",
"verb": "move",
"args": {
"id": 331,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "magica",
"at": "2026-10-25T03:57:40.286Z",
"semantics": 2,
"result": {
"row": 331,
"from": "briefed",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok magica-331-start rev 4076 row 331 briefed→in-progress"
},
"viewSha": "9667805c4557587ca1b7071e345711cc5ae97d6e3e41fa73b99dcde3425d6dad"
},
{
"rev": 4077,
"op": "poe-330-start",
"verb": "move",
"args": {
"id": 330,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "poe",
"at": "2026-10-25T03:59:15.459Z",
"semantics": 2,
"result": {
"row": 330,
"from": "briefed",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok poe-330-start rev 4077 row 330 briefed→in-progress"
},
"viewSha": "35201392103cc4340b3b15202e2823900cd14b363d2510fa5bc5ffc1bb29091b"
},
{
"rev": 4078,
"op": "morgana-193-inreview",
"verb": "move",
"args": {
"id": 193,
"to": "in-review",
"reason": null,
"candidate": "e2d161e2e6354fe7f7083b95ccae8b202513f5e5",
"evidence": null,
"issue": null
},
"by": "morgana",
"at": "2026-10-25T04:01:27.379Z",
"semantics": 2,
"result": {
"row": 193,
"from": "in-progress",
"to": "in-review",
"round": 1,
"issue": 1691,
"candidate": {
"kind": "commit",
"digest": "e2d161e2e6354fe7f7083b95ccae8b202513f5e5",
"text": null
},
"receipt": "ok morgana-193-inreview rev 4078 row 193 in-progress→in-review round 1 on #1691; request morgana-193-inreview requesting"
},
"viewSha": "0895b680ea6319a5468aeef70e79d3c349354640c0aac5c70c70cd719d311181"
},
{
"rev": 4079,
"op": "rockerduck-334-start",
"verb": "move",
"args": {
"id": 334,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "rockerduck",
"at": "2026-10-25T04:02:42.539Z",
"semantics": 2,
"result": {
"row": 334,
"from": "briefed",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok rockerduck-334-start rev 4079 row 334 briefed→in-progress"
},
"viewSha": "d555c4376c5ec2157757a0dd3e7348fe712a9b16610a0fec972e8e281b7d9007"
},
{
"rev": 4080,
"op": "bubba-288-start",
"verb": "move",
"args": {
"id": 288,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "bubba",
"at": "2026-10-25T04:04:36.422Z",
"semantics": 2,
"result": {
"row": 288,
"from": "briefed",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok bubba-288-start rev 4080 row 288 briefed→in-progress"
},
"viewSha": "fcf44e3b271c36c7b8336d2c2b7427bf973e7edafffed4148689a03a50df399a"
},
{
"rev": 4081,
"op": "sage-4364-ab193",
"verb": "review-abandon",
"args": {
"id": 193,
"attempt": "morgana-193-inreview",
"reason": "pre-send credential check failed (morgana has no token file); outcome not logged under lock contention; no comment on #1691 (checked 04:05Z, 0 comments)"
},
"by": "sage",
"at": "2026-10-25T04:05:57.752Z",
"semantics": 2,
"result": {
"row": 193,
"round": 1,
"attempt": "morgana-193-inreview",
"state": "abandoned",
"receipt": "ok sage-4364-ab193 rev 4081 row 193 round 1 request morgana-193-inreview requesting→abandoned; a duplicate request comment may exist"
},
"viewSha": "ec04ba69967bd3628db5b75098f42e139e17d06cec910e0366a0c03908aeda6a"
},
{
"rev": 4082,
"op": "sage-4364-rq193",
"verb": "review-request",
"args": {
"id": 193
},
"by": "sage",
"at": "2026-10-25T04:08:08.349Z",
"semantics": 2,
"result": {
"row": 193,
"round": 1,
"attempt": "sage-4364-rq193",
"state": "requesting",
"receipt": "ok sage-4364-rq193 rev 4082 row 193 round 1 request sage-4364-rq193 requesting"
},
"viewSha": "f91e69002059e8eb12773c3cfd47e23d206193a522fba36874cc548a19b66bbe"
},
{
"rev": 4083,
"op": "herb-214-inreview",
"verb": "move",
"args": {
"id": 214,
"to": "in-review",
"reason": null,
"candidate": "584ee1e20",
"evidence": null,
"issue": null
},
"by": "herb",
"at": "2026-10-25T04:10:39.673Z",
"semantics": 2,
"result": {
"row": 214,
"from": "in-progress",
"to": "in-review",
"round": 1,
"issue": 1715,
"candidate": {
"kind": "commit",
"digest": "584ee1e2067982edb71459af7c15e4ed7162279a",
"text": null
},
"receipt": "ok herb-214-inreview rev 4083 row 214 in-progress→in-review round 1 on #1715; request herb-214-inreview requesting"
},
"viewSha": "ddbe9b2723ef7a5094fef624eba20eae4d698da1dc1665154f60b3bfa3afad0c"
},
{
"rev": 4084,
"op": "sage-4372-rs193",
"verb": "review-resolve",
"args": {
"id": 193,
"attempt": "sage-4364-rq193",
"comment": 31674
},
"by": "sage",
"at": "2026-10-25T04:13:06.534Z",
"semantics": 2,
"result": {
"row": 193,
"round": 1,
"attempt": "sage-4364-rq193",
"state": "posted",
"receipt": "ok sage-4372-rs193 rev 4084 row 193 round 1 request sage-4364-rq193 requesting→posted comment 31674"
},
"viewSha": "a37e0e3dbf4f805f4faadf0bceaa933f650682de7f83eca5658caae2be58fec4"
},
{
"rev": 4085,
"op": "sage-4379-brief267",
"verb": "move",
"args": {
"id": 267,
"to": "briefed",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-25T04:16:34.092Z",
"semantics": 2,
"result": {
"row": 267,
"from": "queued",
"to": "briefed",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok sage-4379-brief267 rev 4085 row 267 queued→briefed"
},
"viewSha": "fb6a2d1191896825f5da39afcc7fc85caf29d43d9cb1b6e4b2189ae2662fab16"
},
{
"rev": 4086,
"op": "sage-4382-brief255",
"verb": "move",
"args": {
"id": 255,
"to": "briefed",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-25T04:17:44.008Z",
"semantics": 2,
"result": {
"row": 255,
"from": "queued",
"to": "briefed",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok sage-4382-brief255 rev 4086 row 255 queued→briefed"
},
"viewSha": "edaa038070e1e52673ca4a3afc6d985da95741d4fa299f6f18502de4a1704bb0"
},
{
"rev": 4087,
"op": "morgana-267-start",
"verb": "move",
"args": {
"id": 267,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "morgana",
"at": "2026-10-25T04:18:49.322Z",
"semantics": 2,
"result": {
"row": 267,
"from": "briefed",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok morgana-267-start rev 4087 row 267 briefed→in-progress"
},
"viewSha": "e69727d2206984c63a740a4a58cd661a3c2fe8dff8f5dbc4d9fbeff0bdc8e5cb"
},
{
"rev": 4088,
"op": "duckworth-193-verdict-r1",
"verb": "review-record",
"args": {
"id": 193,
"verdict": "approve",
"comment": 31676,
"candidate": "e2d161e2e6354fe7f7083b95ccae8b202513f5e5"
},
"by": "duckworth",
"at": "2026-10-25T04:19:54.404Z",
"semantics": 2,
"result": {
"row": 193,
"round": 1,
"verdict": "approve",
"receipt": "ok duckworth-193-verdict-r1 rev 4088 row 193 round 1 approve by duckworth (comment 31676)"
},
"viewSha": "93d42ed21fd861328bc26344d776f52521c7c64a2fe95887b2365538792f0c1f"
},
{
"rev": 4089,
"op": "sage-4379-ab214",
"verb": "review-abandon",
"args": {
"id": 214,
"attempt": "herb-214-inreview",
"reason": "no comment on #1715 (comments empty at 04:14:08Z); herb has no Gitea token, so the pre-send step could not post; Sage posts the request"
},
"by": "sage",
"at": "2026-10-25T04:20:59.781Z",
"semantics": 2,
"result": {
"row": 214,
"round": 1,
"attempt": "herb-214-inreview",
"state": "abandoned",
"receipt": "ok sage-4379-ab214 rev 4089 row 214 round 1 request herb-214-inreview requesting→abandoned; a duplicate request comment may exist"
},
"viewSha": "8c679893953a60aa0ccf8fa39feb205548c08b77fd3adba021a2de7814d4bec5"
},
{
"rev": 4090,
"op": "sage-4379-rq214",
"verb": "review-request",
"args": {
"id": 214
},
"by": "sage",
"at": "2026-10-25T04:22:04.588Z",
"semantics": 2,
"result": {
"row": 214,
"round": 1,
"attempt": "sage-4379-rq214",
"state": "requesting",
"receipt": "ok sage-4379-rq214 rev 4090 row 214 round 1 request sage-4379-rq214 requesting"
},
"viewSha": "6ac50da826437793bac24dd1abc31e4be56a4d26af10de2a33b3f7c05a973c50"
},
{
"rev": 4091,
"op": "sage-4379-rq214.outcome",
"verb": "review-outcome",
"args": {
"id": 214,
"attempt": "sage-4379-rq214",
"outcome": "posted",
"status": 201,
"comment": 31678,
"detail": "created"
},
"by": "sage",
"at": "2026-10-25T04:23:18.136Z",
"semantics": 2,
"result": {
"row": 214,
"round": 1,
"attempt": "sage-4379-rq214",
"state": "posted",
"receipt": "ok sage-4379-rq214.outcome rev 4091 row 214 round 1 request sage-4379-rq214 posted comment 31678"
},
"viewSha": "c74e53658781138532ed40adac8a3fc476204ef6c2dbbb5328401e572d12c986"
},
{
"rev": 4092,
"op": "launchpad-255-start",
"verb": "move",
"args": {
"id": 255,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "launchpad",
"at": "2026-10-25T04:24:42.333Z",
"semantics": 2,
"result": {
"row": 255,
"from": "briefed",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok launchpad-255-start rev 4092 row 255 briefed→in-progress"
},
"viewSha": "a92a599f528ff198285660a6054200e69018054f2815f77f0dc2b5cf73d96b8d"
},
{
"rev": 4093,
"op": "filbert-214-r1-record",
"verb": "review-record",
"args": {
"id": 214,
"verdict": "approve",
"comment": 31679,
"candidate": "584ee1e2067982edb71459af7c15e4ed7162279a"
},
"by": "filbert",
"at": "2026-10-25T04:26:01.460Z",
"semantics": 2,
"result": {
"row": 214,
"round": 1,
"verdict": "approve",
"receipt": "ok filbert-214-r1-record rev 4093 row 214 round 1 approve by filbert (comment 31679)"
},
"viewSha": "442af8295ba6c7bba26cf032a67f7b60e4875b90cdb7953e4a6f3ae168764123"
},
{
"rev": 4094,
"op": "sage-214-land-note",
"verb": "note",
"args": {
"id": 214,
"text": "LANDED 3a8e4abad (pushed 78b69e1e6..3a8e4abad, 6 commits cherry-picked onto 78b69e1e6). Gate GREEN 04:10:34Z, all package suites and scripts; queue package runs via test-queue.sh (156). Filbert approve c31679 rev 4093. Range-diff equal except BUILD-LOG context."
},
"by": "sage",
"at": "2026-10-25T04:29:32.289Z",
"semantics": 2,
"result": {
"row": 214,
"field": "note",
"receipt": "ok sage-214-land-note rev 4094 row 214 note"
},
"viewSha": "72cb37f91dfb33937bcfb4df842aacf3768e9fd54265ff65efbecbb0a4a61b6c"
},
{
"rev": 4095,
"op": "sage-214-land-done",
"verb": "move",
"args": {
"id": 214,
"to": "done",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-25T04:30:41.788Z",
"semantics": 2,
"result": {
"row": 214,
"from": "in-review",
"to": "done",
"round": 1,
"issue": null,
"candidate": null,
"receipt": "ok sage-214-land-done rev 4095 row 214 in-review→done round 1"
},
"viewSha": "c89e09440ea27e853823e0138c92bb8429a623cb1d41fa41037c0513813404d7"
}
]
}
+29 -14
View File
@@ -1,4 +1,5 @@
import { randomUUID, randomBytes, createHash } from 'node:crypto';
import { validManagementKey, proxyTiming } from '../../model-proxy/src/common.mjs';
import { parseAddress } from './address.mjs';
import { isAbsolute, dirname } from 'node:path';
import { initializeControl, processingStatus } from '../../processing-control/src/state.mjs';
@@ -2587,7 +2588,7 @@ export class Broker {
}
let step = 'B';
try {
const result = write('instance.fire');
const result = await write('instance.fire');
step = 'C';
this.#store.transaction(() => {
this.#recordAuthority(sc, 'role.fire', authority, { reason: args.reason, transaction: result.transaction ?? null });
@@ -4593,7 +4594,8 @@ export class Broker {
this.#organizationFail(e, 'instances.set');
}
// sage-1610 (b): a deleted agent's Vikunja bot is named, so the human removes it in Vikunja.
return { transaction: out.transaction, retry: false, ...(out.tracker ? { bot: out.tracker.bot } : {}) };
const finish = out => ({ transaction: out.transaction, retry: false, ...(out.tracker ? { bot: out.tracker.bot } : {}) });
return out?.then ? out.then(finish, error => this.#organizationFail(error, 'instances.set')) : finish(out);
}
// The close of a taken request, with the run it started, the delete's transaction or the code
// that stopped it. A delete also names the agent's Vikunja bot (sage-1610 b). A fire's end
@@ -4823,14 +4825,16 @@ export class Broker {
if (request.verb === 'proxy.init') {
this.#human(s);
if (!this.#proxyInit) fail('not-configured');
keys(args, ['key', 'port'], ['key']);
if (typeof args.key !== 'string' || !args.key.trim() || args.key.length > 4096 || /[\x00-\x1f\x7f]/.test(args.key)) fail('proxy-key-invalid');
keys(args, ['key', 'port', 'rotateGraceMinutes', 'firstOutputSeconds'], ['key']);
if (!validManagementKey(args.key)) fail('proxy-key-invalid');
if (Object.hasOwn(args, 'port') && (!Number.isInteger(args.port) || args.port < 1024 || args.port > 65535 || args.port === 8317)) fail('proxy-config-invalid');
try { proxyTiming(args); } catch (error) { fail(error.code); }
// Async initialization is outside the store transaction. Refusals retain codes only.
return Promise.resolve().then(() => this.#proxyInit(args)).then(result => {
if (!object(result) || Object.keys(result).sort().join(',') !== 'binary,port'
if (!object(result) || Object.keys(result).some(k => !['binary', 'port', 'rotateGraceMinutes', 'firstOutputSeconds'].includes(k))
|| !Number.isInteger(result.port) || result.port < 1024 || result.port > 65535 || result.port === 8317
|| typeof result.binary !== 'string' || !result.binary.startsWith('/')) fail('proxy-init-failed');
try { proxyTiming(result); } catch (error) { fail(error.code); }
this.#store.transaction(() => this.#event(s, 'action.allowed', { call: 'proxy.init', port: result.port }, null));
return result;
}).catch(error => { throw this.#refused(s, new BusError(/^proxy-[a-z-]+$/.test(error.code ?? '') ? error.code : 'proxy-init-failed'), request); });
@@ -4882,7 +4886,10 @@ export class Broker {
}
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
return this.#chiefLifecycle(s, request.verb, structuredClone(args));
{
const result = this.#chiefLifecycle(s, request.verb, structuredClone(args));
return result?.then ? result.catch(error => { throw this.#refused(s, error, request); }) : result;
}
if (request.verb === 'grants.scope' || request.verb === 'grants.scope.set')
return this.#grantScopeVerb(s, request.verb, structuredClone(args));
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb)) {
@@ -5083,7 +5090,8 @@ export class Broker {
const { reason: _, ...written } = args;
const write = (op) => {
try {
return this.#organization(s.business, op, written, actor, context);
const result = this.#organization(s.business, op, written, actor, context);
return result?.then ? result.catch(error => this.#organizationFail(error, verb)) : result;
} catch (e) {
this.#organizationFail(e, verb);
}
@@ -5091,8 +5099,11 @@ export class Broker {
if (hire) {
this.#hireable(s, args.definition);
const result = write('instance.hire');
this.#store.transaction(() => this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction }));
return result;
const finish = result => {
this.#store.transaction(() => this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction }));
return result;
};
return result?.then ? result.then(finish) : finish(result);
}
if (!Object.hasOwn(b.roles, args.name)) fail('unknown-role');
if (END_PROTECTED.includes(b.roles[args.name].definition)) fail('organization-definition-refused');
@@ -5142,11 +5153,14 @@ export class Broker {
// Step B: the delist. An instance already delisted counts as done.
const result = write('instance.fire');
// Step C: log the fire and record it done. A failure at B or C leaves I firing until a repeat.
this.#store.transaction(() => {
this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction ?? null });
this.#event(s, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null }, args.name);
});
return { ...result, ...stopped };
const finish = result => {
this.#store.transaction(() => {
this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction ?? null });
this.#event(s, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null }, args.name);
});
return { ...result, ...stopped };
};
return result?.then ? result.then(finish) : finish(result);
}
// The shape of hire and fire arguments.
#hireFireArgs(action, args) {
@@ -5179,6 +5193,7 @@ export class Broker {
// The bus code for an organization writer's refusal. Anything the writer didn't refuse by name is
// outcome-unknown: the write may have committed.
#organizationFail(e, verb) {
if (['proxy-revoke-revision-conflict', 'proxy-key-issue-failed', 'proxy-key-revoking', 'proxy-key-refused', 'proxy-down', 'proxy-open'].includes(e.code)) fail(e.code);
// Row 239 (#1745): the writer marks an error raised after a fire's or a delete's revoke ran, so
// the human reads that the tokens may already be revoked. Without the mark, today's code.
if (e.revoked) fail(e.code === "revision-conflict" ? "revision-conflict-revoked" : "outcome-unknown-revoked");
+57 -7
View File
@@ -5,7 +5,7 @@ import {
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { createHash } from 'node:crypto';
import { BusError } from './broker.mjs';
import { credentialKind, isHostKind } from '../../business/src/vocabulary.mjs';
import { credentialKind, isHostKind, ID_PATTERN } from '../../business/src/vocabulary.mjs';
const deny = (code) => {
throw new BusError(code);
};
@@ -125,7 +125,7 @@ export class Credentials {
// storeRoot is <configDir>/secrets/agents (design D1); a reference with store: true must name the
// path under it for its own business, agent and service. `held` lists "instance:service" keys whose
// reference loads and whose file doesn't (#436b section 5).
constructor({ references = {}, repoRoots = [], dataRoot, env = process.env, storeRoot = null, held = [] }) {
constructor({ references = {}, repoRoots = [], dataRoot, env = process.env, storeRoot = null, held = [], modelAgents = [] }) {
this.#storeRoot = storeRoot;
this.#repoRoots = repoRoots;
this.#dataRoot = dataRoot;
@@ -139,6 +139,7 @@ export class Credentials {
const value = this.#read(ref, env, service);
if (value !== null) this.#tokens.set(instance + ':' + service, { ...value, date });
}
for (const instance of modelAgents) this.registerModel(instance);
} catch (e) {
this.close();
throw e;
@@ -146,7 +147,7 @@ export class Credentials {
}
// One reference's checks, for the constructor and register (row 235, r6 X4). Returns its date.
#checkRef(instance, service, ref) {
if (!credentialKind(service) || !ref || typeof ref !== 'object') deny('credential-reference');
if (!(credentialKind(service) || service === 'model') || !ref || typeof ref !== 'object') deny('credential-reference');
const date = service === 'vikunja' ? ref.expires : ref.rotateBy;
if (!day(date)) deny('credential-date');
if (
@@ -177,6 +178,34 @@ export class Credentials {
this.#refs.delete(k);
this.#revoked.delete(k);
}
// Host-only implicit kind. It is deliberately absent from role service vocabulary:
// neither role authority nor an agent's credential reference can opt into it.
registerModel(instance) {
const parts = typeof instance === 'string' ? instance.split('/') : [];
if (parts.length !== 2 || parts.some(p => !ID_PATTERN.test(p))) deny('credential-reference');
const k = instance + ':model';
if (this.#refs.has(k)) return;
const file = this.#storePath(instance, 'model');
const ref = { store: true, file, rotateBy: '9999-12-31' };
this.#refs.set(k, { ref, date: ref.rotateBy });
// An instance may have no credential directory before its first hire/launch.
if (!privateDir(this.#storeRoot, { missing: true })) return;
const value = this.#read(ref, {}, 'model');
if (value) {
this.#tokens.set(k, { ...value, date: value.model.rotateBy });
this.#refs.set(k, { ref: { ...ref, rotateBy: value.model.rotateBy }, date: value.model.rotateBy });
}
}
// Trusted lifecycle use only; no socket verb returns this private record.
modelRecord(instance) {
const entry = this.#tokens.get(instance + ':model');
return entry?.model ? structuredClone(entry.model) : null;
}
writeModel(instance, record) {
this.registerModel(instance);
this.issue(instance, 'model', JSON.stringify(record), record?.rotateBy);
return this.status().find(e => e.instance === instance && e.service === 'model');
}
// r6 X5: the executor holds the entry's #inflight slot for its steps, so a host.run refuses host-busy
// and revoke's abort() reaches the executor. A slot already held refuses host-busy. Returns the signal.
claim(instance, service) {
@@ -271,6 +300,20 @@ export class Credentials {
}
// { token } for gitea and vikunja, { token, key } for host.<id> (design D2).
static #format(service, token) {
if (service === 'model') {
let m;
try { m = JSON.parse(token); } catch { deny('credential-format'); }
const keyOk = k => typeof k === 'string' && /^msk-[a-z0-9]+-[A-Za-z0-9_-]{43}$/.test(k);
const keyId = k => createHash('sha256').update(k).digest('hex').slice(0, 12);
const instant = s => typeof s === 'string' && !Number.isNaN(Date.parse(s)) && new Date(s).toISOString() === s;
if (!m || Array.isArray(m) || Object.keys(m).some(k => !['key', 'keyId', 'issuedAt', 'rotateBy', 'state', 'revokingAt', 'retiring'].includes(k))
|| !keyOk(m.key) || m.keyId !== keyId(m.key) || !instant(m.issuedAt) || !day(m.rotateBy)
|| !['valid', 'revoking'].includes(m.state) || (m.state === 'revoking' ? !instant(m.revokingAt) : m.revokingAt !== undefined)
|| !Array.isArray(m.retiring) || m.retiring.length > 64 || m.retiring.some(r => !r || Array.isArray(r)
|| Object.keys(r).sort().join(',') !== 'issuedAt,key,keyId,retireAt'
|| !keyOk(r.key) || r.keyId !== keyId(r.key) || !instant(r.issuedAt) || !instant(r.retireAt))) deny('credential-format');
return { token: m.key, model: m };
}
if (isHostKind(service)) {
const key = parseHostKey(token);
return { token: key.text, key };
@@ -309,7 +352,7 @@ export class Credentials {
let fd;
try {
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
writeSync(fd, value.key ? value.token : value.token + '\n');
writeSync(fd, service === 'model' ? JSON.stringify(value.model) + '\n' : value.key ? value.token : value.token + '\n');
fsyncSync(fd);
} finally {
if (fd !== undefined) closeSync(fd);
@@ -380,8 +423,9 @@ export class Credentials {
}
// Memory first, then the store file (design section 6, D12). A file or env reference outside the
// store is never unlinked; the caller turns its reference into store, so a restart finds nothing.
revoke(instance, service) {
revoke(instance, service, { modelConfirmed = false } = {}) {
const { ref } = this.#known(instance, service);
if (service === 'model' && this.#tokens.has(instance + ':model') && !modelConfirmed) deny('credential-revoke-failed');
this.#tokens.delete(instance + ':' + service);
this.#revoked.add(instance + ':' + service);
this.#held.delete(instance + ':' + service);
@@ -426,7 +470,8 @@ export class Credentials {
} catch {
deny('invalid-request');
}
for (const { token } of this.#tokens.values()) if (encoded?.includes(token)) deny('credential-leak');
for (const { token, model } of this.#tokens.values())
if ([token, ...(model?.retiring ?? []).map(r => r.key)].some(k => encoded?.includes(k))) deny('credential-leak');
}
// Row 210 (design D5): whether a host.run for this agent and host is in flight.
busy(instance, service) {
@@ -438,6 +483,7 @@ export class Credentials {
const k = instance + ':' + service;
const entry = this.#tokens.get(k);
if (!entry) deny('credential-unavailable');
if (entry.model?.state === 'revoking') deny('proxy-key-revoking');
if (service === 'vikunja' && Date.now() >= Date.parse(entry.date + 'T00:00:00Z'))
deny('credential-expired');
if (!entry.key) {
@@ -474,6 +520,7 @@ export class Credentials {
status() {
return [...this.#refs.entries()].map(([key, { date }]) => {
const hostKey = this.#tokens.get(key)?.key;
const model = this.#tokens.get(key)?.model;
const i = key.lastIndexOf(':'),
service = key.slice(i + 1),
left = Date.parse(date + 'T00:00:00Z') - Date.now();
@@ -482,13 +529,16 @@ export class Credentials {
service,
date,
...(hostKey ? { fingerprint: hostKey.fingerprint, publicKey: hostKey.publicLine } : {}),
...(model ? { keyId: model.keyId, issuedAt: model.issuedAt,
...(model.revokingAt ? { revokingAt: model.revokingAt } : {}),
retiring: model.retiring.map(({ keyId, issuedAt, retireAt }) => ({ keyId, issuedAt, retireAt })) } : {}),
state: !this.#tokens.has(key)
? this.#held.has(key)
? 'held'
: this.#revoked.has(key)
? 'revoked'
: 'not-issued'
: service !== 'vikunja'
: model?.state === 'revoking' ? 'revoking' : service !== 'vikunja'
? left <= 0
? 'rotation-due'
: 'valid'
+256
View File
@@ -0,0 +1,256 @@
// P2: one broker holds this lifecycle controller after Store acquired its writer lock.
// Every management-list read/modify/write is serialized. Only Credentials owns files.
import { randomBytes, createHash } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import { BusError } from './broker.mjs';
export const REVOKE_TIMEOUT_MS = 5000;
export const REVOKE_POLL_MS = 25;
export const MODEL_STOP_TIMEOUT_MS = 5000;
const keyId = key => createHash('sha256').update(key).digest('hex').slice(0, 12);
const fail = code => { throw new BusError(code); };
export class ModelKeys {
#credentials; #client; #baseUrl; #prefix; #receipt; #now; #runs; #stopRuns; #grace;
#tail = Promise.resolve(); #ready = false; #failure = 'proxy-down'; #confirmed = new Map();
constructor({ credentials, client, configDir, baseUrl, receipt = () => {}, now = () => Date.now(),
runs = () => [], stopRuns = async () => {}, rotateGraceMinutes = 120 }) {
this.#credentials = credentials; this.#client = client; this.#baseUrl = baseUrl;
this.#prefix = `msk-${keyId(configDir)}-`; this.#receipt = receipt; this.#now = now;
this.#runs = runs; this.#stopRuns = stopRuns; this.#grace = rotateGraceMinutes * 60_000;
}
#exclusive(operation) {
const result = this.#tail.then(operation);
this.#tail = result.catch(() => {});
return result;
}
#instances() { return this.#credentials.status().filter(s => s.service === 'model').map(s => s.instance); }
#record(instance) { return this.#credentials.modelRecord(instance); }
hasRecord(instance) { return this.#record(instance) !== null; }
assertLaunchable(instance) { this.#launchable(instance); }
#sentinel(keys) {
if (!keys.some(k => k.startsWith(this.#prefix + 'sentinel-') && /^[A-Za-z0-9_-]{43}$/.test(k.slice((this.#prefix + 'sentinel-').length)))) {
this.#ready = false; this.#failure = 'proxy-open'; fail('proxy-open');
}
}
#launchable(instance) {
if (this.#record(instance)?.state === 'revoking') fail('proxy-key-revoking');
if (!this.#ready) fail(this.#failure);
}
async #replace(before, after) {
this.#sentinel(before); this.#sentinel(after);
await this.#client.putKeys(after);
const confirmed = await this.#client.getKeys();
this.#sentinel(confirmed);
const removed = before.filter(k => !after.includes(k));
if (after.some(k => !confirmed.includes(k)) || removed.some(k => confirmed.includes(k))) fail('proxy-client-response');
}
async #pollRevoked(keys) {
const deadline = performance.now() + REVOKE_TIMEOUT_MS;
for (const key of keys) {
for (;;) {
const remaining = deadline - performance.now();
if (remaining <= 0) fail('credential-revoke-failed');
let status;
try { status = await this.#client.probeKey(key, { timeout: remaining }); }
catch { status = 0; }
if (status === 401) break;
const left = deadline - performance.now();
if (left <= 0) fail('credential-revoke-failed');
await new Promise(resolve => setTimeout(resolve, Math.min(REVOKE_POLL_MS, left)));
}
}
}
async #removeKeys(keys) {
const before = await this.#client.getKeys();
await this.#replace(before, before.filter(k => !keys.includes(k)));
await this.#pollRevoked(keys);
}
async reconcile() {
return this.#exclusive(async () => {
this.#ready = false;
try {
const before = await this.#client.getKeys(); this.#sentinel(before);
const records = this.#instances().map(instance => ({ instance, record: this.#record(instance) })).filter(e => e.record);
const retained = records.filter(e => e.record.state !== 'revoking').flatMap(e => [e.record.key, ...e.record.retiring.map(r => r.key)]);
const revoking = records.filter(e => e.record.state === 'revoking');
const intended = revoking.flatMap(e => [e.record.key, ...e.record.retiring.map(r => r.key)]);
const removed = before.filter(k => k.startsWith(this.#prefix) && !k.startsWith(this.#prefix + 'sentinel-') && !retained.includes(k));
const after = [...new Set([...before.filter(k => !removed.includes(k) && !intended.includes(k)), ...retained])];
const added = after.filter(k => !before.includes(k));
if (added.length || removed.length || intended.length) await this.#replace(before, after);
await this.#pollRevoked([...new Set([...removed, ...intended])]);
for (const { instance, record } of revoking) {
this.#confirmed.set(instance, record.keyId);
this.completeRevoke(instance);
}
this.#ready = true;
const receipt = { action: 'proxy.reconcile', addedKeyIds: added.map(keyId), removedKeyIds: [...new Set([...removed, ...intended])].map(keyId) };
this.#receipt(receipt); return receipt;
} catch (error) {
this.#failure = error.code === 'proxy-open' ? 'proxy-open' : 'proxy-down';
this.#receipt({ action: 'proxy.reconcile', code: this.#failure });
fail(this.#failure);
}
});
}
async #issue(instance, rotate = false, currentKeys = null) {
this.#launchable(instance); this.#credentials.registerModel(instance);
const old = this.#record(instance);
if (old && !rotate) return { baseUrl: this.#baseUrl, key: old.key };
const key = this.#prefix + randomBytes(32).toString('base64url');
const now = this.#now(), issuedAt = new Date(now).toISOString();
const date = new Date(now + 30 * 86400_000).toISOString().slice(0, 10);
const retiring = old ? [...old.retiring, { key: old.key, keyId: old.keyId, issuedAt: old.issuedAt, retireAt: new Date(now + this.#grace).toISOString() }] : [];
try {
const before = currentKeys ?? await this.#client.getKeys();
await this.#replace(before, [...new Set([...before, key])]);
this.#credentials.writeModel(instance, { key, keyId: keyId(key), issuedAt, rotateBy: date, state: 'valid', retiring });
this.#receipt({ action: rotate ? 'proxy.key.rotated' : 'proxy.key.issued', keyIds: [keyId(key)], instance });
return { baseUrl: this.#baseUrl, key };
} catch (error) {
if (error.code === 'proxy-open') throw error;
fail('proxy-key-issue-failed');
}
}
ensure(instance) { return this.#exclusive(() => this.#issue(instance)); }
rotate(instance) { return this.#exclusive(() => this.#issue(instance, true)); }
async preflight(instance) {
// The revoking check must run before even an issue/preflight request.
this.#launchable(instance);
const proxy = await this.#exclusive(async () => {
this.#launchable(instance);
let keys;
try { keys = await this.#client.getKeys(); } catch { fail('proxy-down'); }
this.#sentinel(keys);
return this.#issue(instance, false, keys);
});
let status;
try { status = await this.#client.probeKey(proxy.key); }
catch { fail('proxy-down'); }
if (status < 200 || status >= 300) fail('proxy-key-refused');
return proxy;
}
prepareRevoke(instance) {
return this.#exclusive(async () => {
const record = this.#record(instance);
if (!record) return;
if (record.state !== 'revoking') this.#credentials.writeModel(instance, { ...record, state: 'revoking', revokingAt: new Date(this.#now()).toISOString() });
try {
await this.#removeKeys([record.key, ...record.retiring.map(r => r.key)]);
this.#confirmed.set(instance, record.keyId);
this.#receipt({ action: 'proxy.key.revoked', keyIds: [record.keyId, ...record.retiring.map(r => r.keyId)], remoteState: 'revoked', localState: 'kept' });
} catch { fail('credential-revoke-failed'); }
});
}
completeRevoke(instance) {
const record = this.#record(instance);
if (!record) return;
if (record.state !== 'revoking' || this.#confirmed.get(instance) !== record.keyId) fail('credential-revoke-failed');
this.#credentials.revoke(instance, 'model', { modelConfirmed: true }); this.#confirmed.delete(instance);
}
revisionConflict(instance) {
const record = this.#record(instance);
this.#receipt({ action: 'proxy.key.revoke-conflict', keyIds: record ? [record.keyId] : [], remoteState: 'revoked', localState: 'kept' });
fail('proxy-revoke-revision-conflict');
}
sweep() {
return this.#exclusive(async () => {
for (const instance of this.#instances()) {
let record = this.#record(instance);
if (!record || record.state === 'revoking') continue;
if (this.#now() >= Date.parse(record.rotateBy + 'T00:00:00Z')) {
await this.#issue(instance, true); record = this.#record(instance);
}
for (const old of record.retiring) {
const runs = this.#runs(instance).filter(r => r.keyId === old.keyId);
if (runs.length && this.#now() < Date.parse(old.retireAt)) continue;
if (runs.length) {
try { await this.#stopRuns(runs); } catch { fail('proxy-stop-failed'); }
}
if (this.#runs(instance).some(r => r.keyId === old.keyId)) fail('proxy-stop-failed');
await this.#removeKeys([old.key]);
const current = this.#record(instance);
this.#credentials.writeModel(instance, { ...current, retiring: current.retiring.filter(r => r.keyId !== old.keyId) });
this.#receipt({ action: 'proxy.key.retired', keyIds: [old.keyId] });
}
}
});
}
}
// No new processing-control stop reason/schema: stopSession owns bounded SIGTERM
// and start-time verification. This layer additionally checks agent/key ownership.
export async function stopModelRuns({ runs, sessions, stop, receipt, reason = 'proxy-rotation', timeoutMs = MODEL_STOP_TIMEOUT_MS }) {
for (const run of runs) {
let timer;
const ids = typeof run.keyId === 'string' && /^[a-f0-9]{12}$/.test(run.keyId) ? [run.keyId] : [];
try {
if (!ids.length) fail('proxy-stop-failed');
const entry = sessions().find(s => s.business === run.business && s.run === run.run);
if (!entry || entry.instance !== run.role || entry.modelKeyId !== run.keyId
|| entry.pid !== run.pid || entry.startTime !== run.startTime) fail('proxy-stop-failed');
if (entry.live) {
const result = await Promise.race([
stop(run.run),
new Promise((_, reject) => { timer = setTimeout(() => reject(new BusError('proxy-stop-failed')), timeoutMs); }),
]);
if (!result?.stopped || sessions().some(s => s.business === run.business && s.run === run.run && s.live)) fail('proxy-stop-failed');
}
receipt({ action: reason, keyIds: ids, business: run.business, agent: run.role, run: run.run, outcome: 'stopped' });
} catch {
receipt({ action: reason, code: 'proxy-stop-failed', keyIds: ids, run: run.run });
fail('proxy-stop-failed');
} finally { clearTimeout(timer); }
}
}
// Option C: HTTP is outside the organization lock. This adapter preserves write's
// synchronous contract and passes the captured revision into its existing lock check.
// Neither organization.mjs nor its synchronous settings callers needs modification.
export function modelOrganization({ modelKeys, writer }) {
const pending = new Map();
const adapter = {
...writer,
write(business, document, options) {
const prepare = pending.get(business);
return writer.write(business, document, prepare ? { ...options, revision: prepare.revision } : options);
},
};
const revoke = original => (business, agent, how, services) => {
const prepare = pending.get(business), instance = business + '/' + agent;
if (!prepare || prepare.instance !== instance) fail('credential-revoke-failed');
// For an already-delisted repeat there is no write callback/lock, but the
// revision still must match before completing the model credential cleanup.
if (writer.read(business).revision !== prepare.revision) modelKeys.revisionConflict(instance);
modelKeys.completeRevoke(instance);
return original(business, agent, how, services);
};
const wrap = invoke => (business, verb, args, actor, context) => {
const isRevoke = verb === 'instance.fire' || (verb === 'instances.set' && args.op === 'delete');
if (!isRevoke && verb !== 'instance.hire') return invoke(business, verb, args, actor, context);
return (async () => {
if (pending.has(business)) fail('organization-busy');
const agent = args.name ?? args.instance, instance = business + '/' + agent;
if (verb === 'instance.fire') invoke(business, 'instance.fireCheck', { name: agent }, actor, context);
if (verb === 'instances.set') invoke(business, 'instances.check', { instance: agent }, actor, context);
const { revision } = writer.read(business);
if (args.revision !== undefined && args.revision !== revision) fail('revision-conflict');
const prepare = { instance, revision, remoteRevoked: false };
pending.set(business, prepare);
const existed = modelKeys.hasRecord(instance);
try {
if (isRevoke) { await modelKeys.prepareRevoke(instance); prepare.remoteRevoked = existed; }
else await modelKeys.ensure(instance);
return invoke(business, verb, args, actor, context);
} catch (error) {
if (isRevoke && prepare.remoteRevoked && (error.code === 'revision-conflict' || error.code === 'revision-conflict-revoked')) modelKeys.revisionConflict(instance);
// A failed hire must not leave a newly issued credential usable. Preserve
// a revoking intent if remote cleanup fails; boot reconciliation finishes it.
if (!isRevoke && !existed && modelKeys.hasRecord(instance)) {
try { await modelKeys.prepareRevoke(instance); modelKeys.completeRevoke(instance); } catch {}
}
throw error;
} finally { pending.delete(business); }
})();
};
return { writer: adapter, revoke, wrap };
}
+4
View File
@@ -8,6 +8,8 @@ const LAUNCH_OPS = new Set([
'refuse',
'endLaunch',
'credentialStatus',
'modelPreflight',
'modelStall',
'checkPush',
'recordPush',
'reviewFetch',
@@ -76,6 +78,8 @@ function launchOp(m) {
if (m.op === 'sessionClearRetire') return runtime.sessionClearRetire(m.record);
if (m.op === 'sessionClearStart') return runtime.sessionClearStart(m.record);
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
if (m.op === 'modelPreflight') return runtime.modelPreflight(m);
if (m.op === 'modelStall') return runtime.modelStall(m);
// Every LAUNCH_OPS name has its line above. A missing one used to answer as credentialStatus,
// whose [] for an unknown role read as "no holders" for landHolders (row 156 mutant P02).
throw new BusError('launch-op-refused');
+55 -8
View File
@@ -8,13 +8,14 @@ import { Broker, BusError, LIBRARY_FETCH_FIX } from './broker.mjs';
import { Credentials } from './credentials.mjs';
import { serve } from './server.mjs';
import { launchSettings } from '../../business/src/launch-settings.mjs';
import { readSessions } from '../../seat/src/session.mjs';
import { readSessions, stopSession } from '../../seat/src/session.mjs';
import { compactionCount, storeDir } from '../../seat/src/compactions.mjs';
import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working.mjs';
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
import { loadManagementClient } from '../../model-proxy/src/client.mjs';
import { initializeProxy } from '../../model-proxy/src/settings.mjs';
import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } from './model-keys.mjs';
import { instanceSettings } from '../../business/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../business/src/organization.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
@@ -159,6 +160,8 @@ export async function startBroker({
|| (Object.hasOwn(librarySync, 'configDir') && typeof librarySync.configDir !== 'string')))
throw new BusError('invalid-host-request');
let modelProxyClient = null;
let modelKeys = null, modelTimer = null, modelSweep = null;
const modelRuns = new Map();
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null, accessTimer = null;
try {
const proxyReceipt = entry => process.stderr.write(`model-proxy: ${JSON.stringify(entry)}\n`);
@@ -198,7 +201,19 @@ export async function startBroker({
// Row 236 (#1742, #436b r5 section 5): each gitea grant record gives its agent a store reference to
// gitea, and `held` names the files memory must not load until reconcile or a revoke.
const { held } = storeRoot ? giteaStart(store, storeRoot, businesses, references) : { held: [] };
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot, held });
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot, held,
modelAgents: proxy ? Object.entries(businesses).flatMap(([id, b]) => Object.keys(b.roles).map(role => id + '/' + role)) : [] });
if (proxy) {
modelKeys = new ModelKeys({ credentials, client: modelProxyClient, configDir: organization.dir,
baseUrl: `http://127.0.0.1:${proxy.port}`, receipt: proxyReceipt, rotateGraceMinutes: proxy.rotateGraceMinutes ?? 120,
runs: instance => [...modelRuns.values()].filter(r => r.instance === instance),
stopRuns: async runs => {
await stopModelRuns({ runs, sessions: () => readSessions(dataRoot), stop: run => stopSession(dataRoot, run, { timeoutMs: MODEL_STOP_TIMEOUT_MS }), receipt: proxyReceipt });
for (const r of runs) modelRuns.delete(r.business + ':' + r.run);
} });
// Keep the control/human surface available, but refuse every launch on failed reconcile.
try { await modelKeys.reconcile(); } catch (error) { proxyReceipt({ action: 'proxy.boot-refused', code: error.code }); }
}
// vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs
// plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused.
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,exists:hasModule}):null;
@@ -240,7 +255,9 @@ export async function startBroker({
return { revoked: services, outsideStore: how === 'fire' ? outside : [] };
}
// Row 165 (#1662): the human adds and changes role instances through instances and instances.set.
const instances=organization?instanceSettings({dir:organization.dir,rolesDir:organization.rolesDir,system:organization.system,levels,revoke}):null;
const proxyOrganization = modelKeys ? modelOrganization({ modelKeys, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) }) : null;
const instances=organization?instanceSettings({dir:organization.dir,rolesDir:organization.rolesDir,system:organization.system,levels,revoke,
...(proxyOrganization ? { writer: proxyOrganization.writer, revoke: proxyOrganization.revoke(revoke) } : {})}):null;
const usage = await createUsage({ store, dataRoot, businesses,
log: entry => process.stderr.write(`usage: ${JSON.stringify(entry)}\n`) });
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the limit checks read the organization file at
@@ -378,7 +395,7 @@ export async function startBroker({
},
// limits and limits.set (organization.set is limits.set) carry each token limit's count; a
// plugins.set reloads the business's limit checks in the background.
organization:settings?(id,verb,args,actor,context)=>{
organization:settings?(proxyOrganization?.wrap ?? (operation => operation))((id,verb,args,actor,context)=>{
if(verb==='limits')return withTokens(id,settings.read(id));
if(verb==='plugins')return plugins.read(id);
// Row 256 (#1761): the actor is the reader, so endProtected is the delete check for that reader.
@@ -413,7 +430,7 @@ export async function startBroker({
// took, so a refused one (credentials, roots, ...) shows the restart line for a keep or a
// hire too. The other writers' answers carry no restart field.
return INSTANCE_VERBS.has(verb)?{...result,restart:!live}:result;
}:null,
}):null,
// Row 194 (D2.3): the role files as they stand, for the hire checks.
roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null,
secretCheck: (value) => credentials.assertClean(value),
@@ -472,8 +489,15 @@ export async function startBroker({
!/^\d+$/.test(r.startTime)
)
throw new BusError('invalid-launch-process');
const cap = broker.bindLaunch(r);
const { modelKeyId, ...record } = r;
if (modelKeys) {
modelKeys.assertLaunchable(r.business + '/' + r.role);
const m = credentials.modelRecord(r.business + '/' + r.role);
if (!m || ![m.keyId, ...m.retiring.map(k => k.keyId)].includes(modelKeyId)) throw new BusError('proxy-key-refused');
}
const cap = broker.bindLaunch(record);
launchRecords.push({ ...r });
if (modelKeys) modelRuns.set(r.business + ':' + r.run, { ...r, instance: r.business + '/' + r.role, keyId: modelKeyId });
return { business: r.business, run: r.run, cap };
}
const bound = launches.map(bindLaunch);
@@ -634,7 +658,7 @@ export async function startBroker({
return credentials
.status()
.filter((c) => c.instance === business + '/' + role)
.map(({ service, date, state }) => ({ service, date, state }));
.map(({ service, date, state, keyId, issuedAt }) => ({ service, date, state, ...(service === 'model' ? { keyId, issuedAt } : {}) }));
}
// C2-03a: each library entry with a sync user fetches once at start, then every 60 s. A pass
// still running skips the tick. Only codes are logged, never git's output.
@@ -679,6 +703,13 @@ export async function startBroker({
}, access.intervalMs ?? ACCESS_SWEEP_MS);
accessTimer.unref();
}
if (modelKeys) {
modelTimer = setInterval(() => {
if (modelSweep) return;
modelSweep = modelKeys.sweep().catch(error => proxyReceipt({ action: 'proxy.sweep-refused', code: error.code ?? 'proxy-key-issue-failed' })).finally(() => { modelSweep = null; });
}, 1000);
modelTimer.unref();
}
return {
broker,
credentials,
@@ -686,7 +717,20 @@ export async function startBroker({
get modelProxy() { return modelProxyClient; },
path,
bindLaunch,
endLaunch: (r) => broker.endLaunch(r),
endLaunch: (r) => { const out = broker.endLaunch(r); modelRuns.delete(r.business + ':' + r.run); return out; },
modelPreflight: async ({ business, role }) => {
const current = settings ? settings.current(business) : businesses[business];
if (!Object.hasOwn(current?.roles ?? {}, role)) throw new BusError('unknown-role');
return modelKeys ? modelKeys.preflight(business + '/' + role) : null;
},
modelStall: async ({ business, role, run, keyId }) => {
const record = modelRuns.get(business + ':' + run);
if (!record || record.role !== role || record.keyId !== keyId) throw new BusError('proxy-stop-failed');
await stopModelRuns({ runs: [record], reason: 'proxy-stall', sessions: () => readSessions(dataRoot),
stop: id => stopSession(dataRoot, id, { timeoutMs: MODEL_STOP_TIMEOUT_MS }), receipt: proxyReceipt });
modelRuns.delete(business + ':' + run);
return { stopped: true };
},
threadsState: (business) => broker.threadsState(business),
threadIdle: (r) => broker.threadIdle(r),
// Row 125 (#1619, REQ-LIMIT-4): the launcher asks the limit checks before every launch.
@@ -728,6 +772,8 @@ export async function startBroker({
launches: bound,
readers: readCaps,
async close() {
clearInterval(modelTimer);
await modelSweep;
clearInterval(accessTimer);
await stopSync();
await server.close();
@@ -738,6 +784,7 @@ export async function startBroker({
},
};
} catch (e) {
clearInterval(modelTimer);
clearInterval(accessTimer);
await server?.close();
await adapter?.close?.();
@@ -0,0 +1,76 @@
// P2 RED: model is implicit, durable/private, and status never exposes current/retiring values.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomBytes, createHash } from 'node:crypto';
import { Credentials } from '../src/credentials.mjs';
function fixture(t) {
const root = fs.mkdtempSync(join(tmpdir(), 'p2-store-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const storeRoot = join(root, 'config', 'secrets', 'agents');
fs.mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
const open = () => new Credentials({ storeRoot, dataRoot: join(root, 'data'), modelAgents: ['demo/coder'] });
const file = join(storeRoot, 'demo', 'coder', 'model');
return { root, storeRoot, open, file };
}
const key = () => 'msk-fixture-' + randomBytes(32).toString('base64url');
const id = value => createHash('sha256').update(value).digest('hex').slice(0, 12);
const record = value => ({ key: value, keyId: id(value), issuedAt: '2026-10-24T00:00:00.000Z', rotateBy: '2026-11-23', state: 'valid', retiring: [] });
test('implicit model kind starts not-issued without a business or role reference', t => {
const f = fixture(t), c = f.open(); t.after(() => c.close());
const status = c.status().find(s => s.instance === 'demo/coder' && s.service === 'model');
assert.equal(status?.state, 'not-issued');
assert.equal(fs.existsSync(f.file), false);
});
test('model record persists at 0600 in 0700 dirs and reloads ids/issue time/state only in status', t => {
const f = fixture(t), c = f.open(), value = key(); t.after(() => c.close());
assert.equal(typeof c.writeModel, 'function');
c.writeModel('demo/coder', record(value));
assert.equal(fs.statSync(f.file).mode & 0o777, 0o600);
for (const dir of [f.storeRoot, join(f.storeRoot, 'demo'), join(f.storeRoot, 'demo', 'coder')]) assert.equal(fs.statSync(dir).mode & 0o777, 0o700);
c.close();
const again = f.open(); t.after(() => again.close());
const status = again.status().find(s => s.service === 'model');
assert.equal(status.keyId === id(value), true);
assert.equal(status.issuedAt, '2026-10-24T00:00:00.000Z');
assert.equal(status.state, 'valid');
assert.equal(JSON.stringify(again.status()).includes(value), false);
assert.equal(again.modelRecord('demo/coder').key === value, true);
});
test('revoking model metadata survives restart without losing the private record', t => {
const f = fixture(t), c = f.open(), value = key(); t.after(() => c.close());
assert.equal(typeof c.writeModel, 'function');
c.writeModel('demo/coder', { ...record(value), state: 'revoking', revokingAt: '2026-10-24T01:00:00.000Z' });
c.close(); const again = f.open(); t.after(() => again.close());
assert.equal(again.status().find(s => s.service === 'model').state, 'revoking');
assert.equal(again.modelRecord('demo/coder').key === value, true);
assert.equal(JSON.stringify(again.status()).includes(value), false);
});
test('model record wrong file mode refuses startup rather than inventing a replacement', t => {
const f = fixture(t), c = f.open(); t.after(() => c.close());
assert.equal(typeof c.writeModel, 'function'); c.writeModel('demo/coder', record(key())); c.close();
fs.chmodSync(f.file, 0o640);
assert.throws(() => f.open(), e => e.code === 'credential-file');
});
test('non-proxy Credentials has no implicit model kind', t => {
const f = fixture(t), c = new Credentials({ storeRoot: f.storeRoot, dataRoot: join(f.root, 'data') });
t.after(() => c.close()); assert.deepEqual(c.status(), []);
});
test('credential-revoke-failed: generic remove cannot bypass remote model confirmation', t => {
const f = fixture(t), c = f.open(); t.after(() => c.close());
c.writeModel('demo/coder', record(key()));
assert.throws(() => c.remove('demo/coder'), e => e.code === 'credential-revoke-failed');
assert.equal(c.modelRecord('demo/coder').state, 'valid'); assert.equal(fs.existsSync(f.file), true);
});
test('retiring keys reload privately and remain covered by the credential leak guard', t => {
const f = fixture(t), c = f.open(), value = key(), old = key(); t.after(() => c.close());
c.writeModel('demo/coder', { ...record(value), retiring: [{ key: old, keyId: id(old), issuedAt: '2026-10-23T00:00:00.000Z', retireAt: '2026-10-24T02:00:00.000Z' }] });
c.close(); const again = f.open(); t.after(() => again.close());
assert.equal(again.modelRecord('demo/coder').retiring[0].key === old, true);
const status = JSON.stringify(again.status()); assert.equal(status.includes(old), false); assert.equal(status.includes(value), false);
assert.throws(() => again.assertClean({ value: old }), e => e.code === 'credential-leak');
});
+260
View File
@@ -0,0 +1,260 @@
// P2: real private Credentials, fake loopback management/model server. No live config.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import http from 'node:http';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createHash, randomBytes } from 'node:crypto';
import { Credentials } from '../src/credentials.mjs';
import { ManagementClient } from '../../model-proxy/src/client.mjs';
import { ModelKeys, REVOKE_TIMEOUT_MS, REVOKE_POLL_MS } from '../src/model-keys.mjs';
import { startBroker } from '../src/runtime.mjs';
import { busBusiness } from '../src/business.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
const hash = s => createHash('sha256').update(s).digest('hex').slice(0, 12);
const failCode = code => e => e.code === code;
async function fixture(t, { delayed = 0 } = {}) {
const root = fs.mkdtempSync(join(tmpdir(), 'p2-keys-')), configDir = join(root, 'config');
const storeRoot = join(configDir, 'secrets', 'agents'); fs.mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
const prefix = `msk-${hash(configDir)}-`, makeKey = () => prefix + randomBytes(32).toString('base64url');
const sentinel = prefix + 'sentinel-' + randomBytes(32).toString('base64url');
const state = { keys: [sentinel], calls: [], delay: delayed, removed: new Map(), failGet: false, failPut: false, hideConfirm: false, probes: 0, status: null };
const server = http.createServer(async (req, res) => {
const management = req.url === '/v8/management/config/access/api-keys';
state.calls.push(management ? req.method : 'PROBE');
if (management) {
if (req.headers.authorization !== 'Bearer fx331-manage') { res.writeHead(401); res.end('{}'); return; }
if ((req.method === 'GET' && state.failGet) || (req.method === 'PUT' && state.failPut)) { res.writeHead(503); res.end('{}'); return; }
if (req.method === 'GET') { res.end(JSON.stringify(state.hideConfirm ? [sentinel] : state.keys)); return; }
const chunks = []; for await (const c of req) chunks.push(c);
const next = JSON.parse(Buffer.concat(chunks));
for (const k of state.keys.filter(k => !next.includes(k))) state.removed.set(k, performance.now());
state.keys = next; res.end('{"status":"ok"}'); return;
}
state.probes++;
const key = req.headers.authorization?.slice(7);
const accepted = state.keys.includes(key) || (state.removed.has(key) && performance.now() - state.removed.get(key) < state.delay);
state.onProbe?.(state.status ?? (accepted ? 200 : 401));
res.writeHead(state.status ?? (accepted ? 200 : 401)); res.end('{}');
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const baseUrl = `http://127.0.0.1:${server.address().port}`, receipts = [], stopped = [], runs = [];
let now = Date.parse('2026-10-24T00:00:00.000Z');
const credentials = new Credentials({ storeRoot, dataRoot: join(root, 'data'), modelAgents: ['demo/coder', 'demo/other'] });
const client = new ManagementClient({ baseUrl, key: 'fx331-manage', receipt: r => receipts.push(r) });
const create = () => new ModelKeys({ credentials, client, configDir, baseUrl, receipt: r => receipts.push(r), now: () => now,
runs: instance => runs.filter(r => r.instance === instance), stopRuns: async rs => { stopped.push(...rs.map(r => r.run)); for (const r of rs) runs.splice(runs.indexOf(r), 1); } });
const manager = create();
const closers = [];
t.after(async () => { for (const close of closers) await close(); credentials.close(); server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); fs.rmSync(root, { recursive: true, force: true }); });
return { root, configDir, credentials, client, server, closers, state, manager, create, makeKey, sentinel, receipts, baseUrl, stopped, runs, advance: ms => { now += ms; } };
}
test('issue confirms GET/PUT/GET before private file and returns P3 shape with only own key', async t => {
const f = await fixture(t); await f.manager.reconcile(); f.state.calls.length = 0;
const answer = await f.manager.ensure('demo/coder');
assert.deepEqual(Object.keys(answer).sort(), ['baseUrl', 'key']); assert.equal(answer.baseUrl, f.baseUrl);
assert.deepEqual(f.state.calls, ['GET', 'PUT', 'GET']);
assert.equal(f.state.keys.includes(answer.key), true);
assert.equal(f.credentials.status().find(s => s.instance === 'demo/coder').date, '2026-11-23');
assert.equal(JSON.stringify(f.receipts).includes(answer.key), false);
const other = await f.manager.ensure('demo/other'); assert.equal(other.key === answer.key, false);
});
for (const fault of ['failGet', 'failPut', 'hideConfirm']) test(`proxy-key-issue-failed: ${fault} writes no model record`, async t => {
const f = await fixture(t); await f.manager.reconcile(); f.state[fault] = true;
await assert.rejects(f.manager.ensure('demo/coder'), failCode('proxy-key-issue-failed'));
assert.equal(f.credentials.modelRecord('demo/coder') === null, true);
assert.equal(fs.existsSync(join(f.configDir, 'secrets', 'agents', 'demo', 'coder', 'model')), false);
});
test('credential-revoke-failed: missing confirming GET keeps model file and revoking status', async t => {
const f = await fixture(t); await f.manager.reconcile(); await f.manager.ensure('demo/coder');
f.state.failGet = true;
await assert.rejects(f.manager.prepareRevoke('demo/coder'), failCode('credential-revoke-failed'));
assert.equal(f.credentials.status().find(s => s.instance === 'demo/coder').state, 'revoking');
});
test('revoke confirms absence then polls delayed client refusal before local removal', async t => {
const f = await fixture(t, { delayed: 105 }); await f.manager.reconcile(); const { key } = await f.manager.ensure('demo/coder');
f.state.calls.length = 0; const before = performance.now();
await f.manager.prepareRevoke('demo/coder');
assert.ok(performance.now() - before >= 100); assert.deepEqual(f.state.calls.slice(0, 3), ['GET', 'PUT', 'GET']);
assert.ok(f.state.probes >= 2); assert.equal(f.state.keys.includes(key), false);
assert.equal(f.credentials.modelRecord('demo/coder')?.state, 'revoking');
f.manager.completeRevoke('demo/coder'); assert.equal(f.credentials.modelRecord('demo/coder') === null, true);
assert.equal(JSON.stringify(f.receipts).includes(key), false);
});
test('reconcile restores missing recorded keys, removes own unknown, and preserves foreign and sentinel', async t => {
const f = await fixture(t); await f.manager.reconcile(); const { key } = await f.manager.ensure('demo/coder');
const unknown = f.makeKey(), foreign = 'fx331-human'; f.state.keys = [f.sentinel, unknown, foreign]; f.state.calls.length = 0;
await f.create().reconcile();
assert.equal(f.state.keys.includes(key), true); assert.equal(f.state.keys.includes(unknown), false);
assert.equal(f.state.keys.includes(foreign), true); assert.equal(f.state.keys.includes(f.sentinel), true);
assert.deepEqual(f.state.calls.slice(0, 3), ['GET', 'PUT', 'GET']);
});
test('reconcile revoking never restores key and completes delayed revocation after restart', async t => {
const f = await fixture(t, { delayed: 105 }); await f.manager.reconcile(); const { key } = await f.manager.ensure('demo/coder');
const record = f.credentials.modelRecord('demo/coder');
f.credentials.writeModel('demo/coder', { ...record, state: 'revoking', revokingAt: '2026-10-24T00:00:00.000Z' });
await f.create().reconcile();
assert.equal(f.state.keys.includes(key), false); assert.equal(f.credentials.modelRecord('demo/coder') === null, true);
});
test('proxy-open: no own sentinel blocks reconcile and every launch', async t => {
const f = await fixture(t); f.state.keys = ['fx331-human'];
await assert.rejects(f.manager.reconcile(), failCode('proxy-open'));
await assert.rejects(f.manager.preflight('demo/coder'), failCode('proxy-open'));
assert.equal(f.state.probes, 0);
});
test('proxy-key-revoking: launch hold occurs before any preflight call', async t => {
const f = await fixture(t); await f.manager.reconcile(); await f.manager.ensure('demo/coder');
const record = f.credentials.modelRecord('demo/coder'); f.credentials.writeModel('demo/coder', { ...record, state: 'revoking', revokingAt: '2026-10-24T00:00:00.000Z' });
await assert.rejects(f.manager.preflight('demo/coder'), failCode('proxy-key-revoking')); assert.equal(f.state.probes, 0);
});
test('proxy-revoke-revision-conflict keeps local revoking record and ids-only remote-revoked receipt', async t => {
const f = await fixture(t); await f.manager.reconcile(); const { key } = await f.manager.ensure('demo/coder'); await f.manager.prepareRevoke('demo/coder');
assert.throws(() => f.manager.revisionConflict('demo/coder'), failCode('proxy-revoke-revision-conflict'));
assert.equal(f.credentials.modelRecord('demo/coder').state, 'revoking');
assert.equal(f.state.keys.includes(key), false);
assert.ok(f.receipts.some(r => r.remoteState === 'revoked' && r.localState === 'kept'));
assert.equal(JSON.stringify(f.receipts).includes(key), false);
await f.create().reconcile(); assert.equal(f.credentials.modelRecord('demo/coder') === null, true);
});
for (const status of [401, 503, 302]) test(`proxy-key-refused: preflight rejects HTTP ${status} with exactly one probe`, async t => {
const f = await fixture(t); await f.manager.reconcile(); await f.manager.ensure('demo/coder'); f.state.status = status;
await assert.rejects(f.manager.preflight('demo/coder'), failCode('proxy-key-refused')); assert.equal(f.state.probes, 1);
});
test('rotate keeps old key while its run lives, new launches use new, exit removes old', async t => {
const f = await fixture(t); await f.manager.reconcile(); const first = await f.manager.ensure('demo/coder');
f.runs.push({ instance: 'demo/coder', run: 'old', keyId: hash(first.key) });
const next = await f.manager.rotate('demo/coder'); assert.equal(next.key === first.key, false);
assert.equal(f.state.keys.includes(first.key), true); assert.equal((await f.manager.ensure('demo/coder')).key === next.key, true);
await f.manager.sweep(); assert.equal(f.state.keys.includes(first.key), true);
f.runs.length = 0; await f.manager.sweep(); assert.equal(f.state.keys.includes(first.key), false);
});
test('rotation grace stops surviving old-key runs before remote revocation', async t => {
const f = await fixture(t); await f.manager.reconcile(); const first = await f.manager.ensure('demo/coder');
f.runs.push({ instance: 'demo/coder', run: 'old', keyId: hash(first.key) }); await f.manager.rotate('demo/coder');
f.advance(120 * 60_000); await f.manager.sweep(); assert.deepEqual(f.stopped, ['old']); assert.equal(f.state.keys.includes(first.key), false);
});
test('revocation polling constants retain the approved five-second bound and 25ms spacing', () => {
assert.equal(REVOKE_TIMEOUT_MS, 5000); assert.equal(REVOKE_POLL_MS, 25);
});
test('credential-revoke-failed: polling cannot treat management absence as client revocation', async t => {
const f = await fixture(t, { delayed: Infinity }); await f.manager.reconcile(); await f.manager.ensure('demo/coder');
const start = performance.now();
await assert.rejects(f.manager.prepareRevoke('demo/coder'), failCode('credential-revoke-failed'));
const elapsed = performance.now() - start;
assert.ok(elapsed >= 4900 && elapsed < 6500, 'five-second total revocation polling bound');
assert.equal(f.credentials.modelRecord('demo/coder').state, 'revoking');
});
test('proxy-down: unreachable listener refuses preflight', async t => {
const f = await fixture(t); await f.manager.reconcile(); await f.manager.ensure('demo/coder');
await new Promise(resolve => f.server.close(resolve));
await assert.rejects(f.manager.preflight('demo/coder'), failCode('proxy-down'));
});
test('proxy-stop-failed: old proxy key and retiring record survive a failed grace stop, next sweep retries', async t => {
const f = await fixture(t); await f.manager.reconcile(); const first = await f.manager.ensure('demo/coder');
f.runs.push({ instance: 'demo/coder', run: 'old', keyId: hash(first.key) });
let stopped = false;
const manager = new ModelKeys({ credentials: f.credentials, client: f.client, configDir: f.configDir, baseUrl: f.baseUrl,
now: () => Date.parse('2026-10-24T03:00:00.000Z'), runs: () => f.runs,
stopRuns: async () => { if (!stopped) throw Error('fx331-stop'); f.runs.length = 0; } });
await manager.reconcile(); await manager.rotate('demo/coder');
// This manager created a fresh 120-minute window at 03:00; use a second view
// with time beyond that deadline to exercise failure then retry of the same record.
const overdue = new ModelKeys({ credentials: f.credentials, client: f.client, configDir: f.configDir, baseUrl: f.baseUrl,
now: () => Date.parse('2026-10-24T06:00:00.000Z'), runs: () => f.runs,
stopRuns: async () => { if (!stopped) throw Error('fx331-stop'); f.runs.length = 0; } });
await overdue.reconcile();
await assert.rejects(overdue.sweep(), failCode('proxy-stop-failed'));
assert.equal(f.state.keys.includes(first.key), true); assert.equal(f.credentials.modelRecord('demo/coder').retiring.length, 1);
stopped = true; await overdue.sweep(); assert.equal(f.state.keys.includes(first.key), false);
});
async function bootBusiness(f) {
const rolesDir = rolesCopy(f.root), system = systemFor(f.root), doc = businessDoc(f.root, 'demo');
doc.roles['sec-1'] = { definition: 'security-reviewer' }; doc.launch.instances.push('sec-1');
const file = writeJson(join(f.configDir, 'businesses', 'demo.json'), doc);
fs.writeFileSync(join(f.configDir, 'secrets', 'proxy-management.key'), 'fx331-manage', { mode: 0o600 });
const business = loadBusiness('demo', { dir: f.configDir, rolesDir }), resolved = {};
for (const instance of Object.keys(business.roles)) resolved[instance] = resolveInstance({ system, business, instance });
fs.mkdirSync(join(f.root, 'data'), { mode: 0o700 });
const bus = await startBroker({ dataRoot: join(f.root, 'data'), businesses: { demo: busBusiness(business, resolved) },
organization: { dir: f.configDir, rolesDir, system }, proxy: { port: Number(new URL(f.baseUrl).port), binary: '/fixture-unused' } });
f.closers.push(() => bus.close());
const chief = bus.broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
bus.broker.request(chief, { verb: 'role.claim' });
const fire = () => bus.broker.request(chief, { verb: 'instance.fire', args: { name: 'sec-1', reason: 'fixture fire' } });
const listed = () => JSON.parse(fs.readFileSync(file, 'utf8')).launch.instances.includes('sec-1');
return { bus, chief, fire, listed, file };
}
test('bus fire proves remote 401 before delisting the agent and deleting model file', async t => {
const f = await fixture(t, { delayed: 105 }); await f.manager.reconcile(); const issued = await f.manager.ensure('demo/sec-1');
const b = await bootBusiness(f); let sawRefusal = false;
f.state.onProbe = status => {
assert.equal(b.listed(), true); assert.equal(b.bus.credentials.modelRecord('demo/sec-1')?.state, 'revoking');
if (status === 401) sawRefusal = true;
};
const result = await b.fire(); f.state.onProbe = null;
assert.equal(sawRefusal, true); assert.equal(result.delisted, true); assert.equal(b.listed(), false);
assert.equal(f.state.keys.includes(issued.key), false); assert.equal(b.bus.credentials.modelRecord('demo/sec-1') === null, true);
});
test('credential-revoke-failed: bus fire on failed remote PUT keeps membership and model record', async t => {
const f = await fixture(t); await f.manager.reconcile(); const issued = await f.manager.ensure('demo/sec-1'); const b = await bootBusiness(f);
const before = fs.readFileSync(b.file, 'utf8'); f.state.failPut = true;
await assert.rejects(b.fire(), failCode('credential-revoke-failed'));
assert.equal(fs.readFileSync(b.file, 'utf8'), before); assert.equal(b.listed(), true);
assert.equal(f.state.keys.includes(issued.key), true); assert.equal(b.bus.credentials.modelRecord('demo/sec-1')?.state, 'revoking');
});
test('proxy-revoke-revision-conflict: racing organization write keeps record, next boot finishes intent', async t => {
const f = await fixture(t); await f.manager.reconcile(); const issued = await f.manager.ensure('demo/sec-1'); const b = await bootBusiness(f);
let raced = false;
f.state.onProbe = status => { if (status === 401 && !raced) { raced = true; fs.appendFileSync(b.file, '\n'); } };
await assert.rejects(b.fire(), failCode('proxy-revoke-revision-conflict')); f.state.onProbe = null;
assert.equal(b.listed(), true); assert.equal(f.state.keys.includes(issued.key), false);
assert.equal(b.bus.credentials.modelRecord('demo/sec-1')?.state, 'revoking');
await b.bus.close(); f.closers.length = 0;
const record = new Credentials({ storeRoot: join(f.configDir, 'secrets', 'agents'), dataRoot: join(f.root, 'data'), modelAgents: ['demo/sec-1'] });
const manager = new ModelKeys({ credentials: record, client: f.client, configDir: f.configDir, baseUrl: f.baseUrl });
await manager.reconcile(); assert.equal(record.modelRecord('demo/sec-1') === null, true); record.close();
});
test('bus hire issues and confirms implicit model key before publishing new membership', async t => {
const f = await fixture(t); const b = await bootBusiness(f); const calls = f.state.calls.length;
const result = await b.bus.broker.request(b.chief, { verb: 'instance.hire', args: { name: 'sec-new', definition: 'security-reviewer', reason: 'fixture hire' } });
assert.equal(typeof result.transaction, 'string');
assert.equal(JSON.parse(fs.readFileSync(b.file, 'utf8')).launch.instances.includes('sec-new'), true);
assert.equal(b.bus.credentials.modelRecord('demo/sec-new')?.state, 'valid');
assert.deepEqual(f.state.calls.slice(calls, calls + 3), ['GET', 'PUT', 'GET']);
});
test('proxy-key-issue-failed: bus hire failure publishes neither membership nor model file', async t => {
const f = await fixture(t); const b = await bootBusiness(f); f.state.failPut = true;
const before = fs.readFileSync(b.file, 'utf8');
await assert.rejects(b.bus.broker.request(b.chief, { verb: 'instance.hire', args: { name: 'sec-new', definition: 'security-reviewer', reason: 'fixture hire' } }), failCode('proxy-key-issue-failed'));
assert.equal(fs.readFileSync(b.file, 'utf8'), before); assert.equal(b.bus.credentials.modelRecord('demo/sec-new') === null, true);
});
test('proxy-open: removing sentinel after boot refuses the next launch before models probe', async t => {
const f = await fixture(t); await f.manager.reconcile(); await f.manager.ensure('demo/coder');
f.state.keys = f.state.keys.filter(k => k !== f.sentinel);
await assert.rejects(f.manager.preflight('demo/coder'), failCode('proxy-open')); assert.equal(f.state.probes, 0);
});
test('newly hired role can use runtime preflight without a broker restart', async t => {
const f = await fixture(t); const b = await bootBusiness(f);
await b.bus.broker.request(b.chief, { verb: 'instance.hire', args: { name: 'sec-new', definition: 'security-reviewer', reason: 'fixture hire' } });
const proxy = await b.bus.modelPreflight({ business: 'demo', role: 'sec-new' });
assert.equal(proxy.baseUrl, f.baseUrl); assert.equal(f.state.keys.includes(proxy.key), true);
});
test('first launch issues implicit key and performs exactly one successful models probe', async t => {
const f = await fixture(t); await f.manager.reconcile();
const answer = await f.manager.preflight('demo/coder');
assert.equal(f.state.probes, 1); assert.equal(f.state.keys.includes(answer.key), true);
});
test('steady boot reconcile reads management list once and writes nothing', async t => {
const f = await fixture(t); await f.manager.reconcile(); assert.deepEqual(f.state.calls, ['GET']);
});
test('thirty-day rotation due automatically replaces and retires an unused old key', async t => {
const f = await fixture(t); await f.manager.reconcile(); const first = await f.manager.ensure('demo/coder');
f.advance(30 * 86400_000); await f.manager.sweep();
const current = await f.manager.ensure('demo/coder'); assert.equal(current.key === first.key, false);
assert.equal(f.state.keys.includes(first.key), false); assert.equal(f.state.keys.includes(current.key), true);
});
+22
View File
@@ -14,3 +14,25 @@ test('proxy.init not-configured',t=>{const f=fixture(t,null);assert.throws(()=>f
test('proxy.init callback error redacts key and maps outcome to named refusal',async t=>{const f=fixture(t,()=>{throw Error('p1-management');});await assert.rejects(f.b.request(f.human,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='proxy-init-failed'&&!e.message.includes('p1-management'));assert.equal(JSON.stringify(f.store.all('SELECT body FROM events')).includes('p1-management'),false);});
test('socket awaits proxy.init and does not return a Promise object or retain human cap',async t=>{const {serve}=await import('../src/server.mjs');const {Client}=await import('../src/client.mjs');const f=fixture(t,async()=>{await new Promise(r=>setTimeout(r,10));return {port:18432,binary:'/fixture'};});const root=mkdtempSync(join(tmpdir(),'p1-sock-'));const socket=join(root,'s');const server=await serve({broker:f.b,path:socket,peerReader:{read:async()=>({pid:process.pid,uid:process.getuid(),gid:process.getgid()}),close(){}},authenticateHuman:()=>f.b.bindHuman({business:'demo',human:'owner',via:'cli',outsideAgent:true})});t.after(()=>server.close());const client=new Client({path:socket,human:{business:'demo',pid:process.pid,startTime:'1',nonce:'fixture'}});assert.deepEqual(await client.call('proxy.init',{key:'p1-management'}),{port:18432,binary:'/fixture'});});
test('proxy-init-failed: extra callback fields cannot return a management key',async t=>{const f=fixture(t,async()=>({port:18432,binary:'/fixture',key:'p1-management'}));await assert.rejects(f.b.request(f.human,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='proxy-init-failed');assert.equal(JSON.stringify(f.store.all('SELECT body FROM events')).includes('p1-management'),false);});
// P2 R2-N1: catches local broker predicates diverging from management-client rules.
for (const [label, key] of [['Latin-1', 'fx331-caf\u00e9'], ['leading space', ' fx331-key'], ['trailing space', 'fx331-key '], ['internal space', 'fx331 key']]) {
test(`proxy-key-invalid: broker rejects ${label} before initializer`, async t => {
let called = false;
const f = fixture(t, () => { called = true; return { port: 18432, binary: '/fixture' }; });
let pending;
try {
assert.throws(() => { pending = f.b.request(f.human, { verb: 'proxy.init', args: { key } }); }, e => e.code === 'proxy-key-invalid');
} finally { await Promise.resolve(pending).catch(() => {}); }
assert.equal(called, false);
assert.equal(JSON.stringify(f.store.all('SELECT body FROM events')).includes(key), false);
});
}
for (const [field, code, value] of [['rotateGraceMinutes', 'proxy-rotate-grace-invalid', 1441], ['firstOutputSeconds', 'proxy-first-output-invalid', 3601]]) {
test(`proxy.init ${code}: human setting validated before initializer`, t => {
let called = false; const f = fixture(t, () => { called = true; });
assert.throws(() => f.b.request(f.human, { verb: 'proxy.init', args: { key: 'fx331-manage', [field]: value } }), e => e.code === code);
assert.equal(called, false);
});
}
+22
View File
@@ -0,0 +1,22 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { stopModelRuns, MODEL_STOP_TIMEOUT_MS } from '../src/model-keys.mjs';
const run = { business: 'demo', role: 'coder', run: 'r-test', keyId: '0123456789ab', pid: 123, startTime: '456' };
const entry = { business: 'demo', instance: 'coder', run: 'r-test', modelKeyId: '0123456789ab', pid: 123, startTime: '456', live: true };
test('bounded proxy stop is five seconds by default', () => assert.equal(MODEL_STOP_TIMEOUT_MS, 5000));
for (const patch of [{ instance: 'other' }, { modelKeyId: 'fedcba987654' }, { pid: 999 }, { startTime: '999' }]) test(`proxy-stop-failed: mismatched ${Object.keys(patch)[0]} is never signalled`, async () => {
let signals = 0; const receipts = [];
await assert.rejects(stopModelRuns({ runs: [run], sessions: () => [{ ...entry, ...patch }], stop: async () => { signals++; }, receipt: r => receipts.push(r) }), e => e.code === 'proxy-stop-failed');
assert.equal(signals, 0); assert.equal(receipts[0].code, 'proxy-stop-failed');
});
test('proxy-stop-failed: hung SIGTERM path is bounded and error text is not logged', async () => {
const receipts = [], start = performance.now();
await assert.rejects(stopModelRuns({ runs: [run], sessions: () => [entry], stop: () => new Promise(() => {}), receipt: r => receipts.push(r), timeoutMs: 20 }), e => e.code === 'proxy-stop-failed');
assert.ok(performance.now() - start < 1000);
assert.deepEqual(receipts, [{ action: 'proxy-rotation', code: 'proxy-stop-failed', keyIds: ['0123456789ab'], run: 'r-test' }]);
});
test('stop verifies the run exited and logs the proxy reason with ids only', async () => {
let live = true; const receipts = [];
await stopModelRuns({ runs: [run], sessions: () => [{ ...entry, live }], stop: async () => { live = false; return { stopped: true }; }, receipt: r => receipts.push(r), reason: 'proxy-stall' });
assert.deepEqual(receipts, [{ action: 'proxy-stall', keyIds: ['0123456789ab'], business: 'demo', agent: 'coder', run: 'r-test', outcome: 'stopped' }]);
});
+1 -1
View File
@@ -45,7 +45,7 @@ export const USAGE = `usage:
${LIMITS_USAGE}
${PLUGINS_USAGE}
${CREDENTIALS_USAGE}
mosaic proxy fetch | init [--port N] [--business ID]
mosaic proxy fetch | init [--port N] [--business ID] [--rotate-grace-minutes N] [--first-output-seconds N]
${HOSTS_USAGE}
${GRANTS_USAGE}
${INSTANCES_USAGE}
+17 -2
View File
@@ -139,6 +139,8 @@
// transaction; a later delete retries it.
import { createServer } from "node:net";
import { createHash } from 'node:crypto';
import { watchModelOutput } from './model-stall.mjs';
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
@@ -507,6 +509,13 @@ export function createLauncher({
({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread,session,human}));
admission(admissionGeneration);
const { run, runDir } = newRun(dataRoot, b.id);
let proxy = null, modelKeyId;
if (system.proxy) {
try { proxy = await host.op({ op: 'modelPreflight', business: b.id, role: instance }); }
catch (error) { throw new Refusal(error.code ?? 'proxy-down', error.code ?? 'proxy-down'); }
if (!proxy || typeof proxy.key !== 'string' || proxy.baseUrl !== `http://127.0.0.1:${system.proxy.port}`) throw new Refusal('proxy-down', 'proxy-down');
modelKeyId = createHash('sha256').update(proxy.key).digest('hex').slice(0, 12);
}
const sessionDir = store ? store.session : join(runDir, "session");
if (!store) mkdirSync(sessionDir, { mode: 0o700 });
if (store) compactions(store, model.harness, harness, { instance, thread, session, run });
@@ -522,6 +531,7 @@ export function createLauncher({
const holding = holders?.filter((h) => h.instance === instance) ?? null;
const land = thread ? await host.op({ op: "landHeld", record: { business: b.id, role: instance, thread } }) : holding.length > 0;
const bundle = buildBundle({
...(proxy ? { proxy } : {}),
dir: join(runDir, "bundle"),
resolved,
models: b.launch.models,
@@ -611,12 +621,13 @@ export function createLauncher({
admission(admissionGeneration);
if (!outerStart) throw new Error("the session process exited at once");
lock?.update({ run, runPid: child.pid, runStartTime: outerStart });
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, transcript, pid: child.pid, startTime: outerStart, ...(thread ? { thread, session } : {}) });
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, transcript, pid: child.pid, startTime: outerStart, ...(thread ? { thread, session } : {}), ...(proxy ? { modelKeyId } : {}) });
bound = true;
admission(admissionGeneration);
entry = {
business: b.id, instance, ...(thread ? { thread, session, workspace } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey,
pid: child.pid, startTime: outerStart, runnerPid: runner.pid, runnerStartTime: runner.startTime, runDir, startedAt: new Date().toISOString(),
...(proxy ? { modelKeyId } : {}),
};
registry.add(entry);
// Row 156: one start per decision, whichever launch wrote it into landing.
@@ -630,10 +641,14 @@ export function createLauncher({
spawnedAt.delete(run);
unregisterRuntime(dataRoot, managed.id);
if (e instanceof Refusal) throw e;
if (e.code?.startsWith('proxy-')) throw new Refusal(e.code, e.code);
throw new Refusal("launch-failed", e.message);
}
children.set(run, exited);
const outputWatch = proxy ? watchModelOutput({ runDir, keyId: modelKeyId, seconds: system.proxy.firstOutputSeconds ?? 120,
stop: () => host.op({ op: 'modelStall', business: b.id, role: instance, run, keyId: modelKeyId }), receipt: record }) : null;
exited.then(async ({ code, signal }) => {
outputWatch?.cancel();
let receipt;
try { receipt = runtime(dataRoot, managed.id); }
catch { log(`run ${run}: processing receipt unavailable at exit`); }
@@ -1191,7 +1206,7 @@ export function createLauncher({
log(`run ${s.run} (${s.instance}) from an earlier host still runs (pid ${s.pid}, runner ${s.runnerPid}): no session of ${s.instance} starts until it exits`);
}
try {
await host.bindLaunch({ business: b.id, role: s.instance, run: s.run, harness: s.harness, pid: s.pid, startTime: s.startTime, ...(s.thread ? { thread: s.thread } : {}), ...(s.session ? { session: s.session } : {}) });
await host.bindLaunch({ business: b.id, role: s.instance, run: s.run, harness: s.harness, pid: s.pid, startTime: s.startTime, ...(s.thread ? { thread: s.thread } : {}), ...(s.session ? { session: s.session } : {}), ...(s.modelKeyId ? { modelKeyId: s.modelKeyId } : {}) });
} catch (e) {
// A host that died after ending the run but before updating sessions.json.
if (e.code === "run-ended") log(`run ${s.run} (${s.instance}) from an earlier host was already ended`);
+9 -4
View File
@@ -1,6 +1,7 @@
import { CliError } from './errors.mjs';
import { refuseInsideAgent } from './transport.mjs';
import { fetchPinned } from '../../model-proxy/src/fetch.mjs';
import { validManagementKey, proxyTiming } from '../../model-proxy/src/common.mjs';
const failure = (code, exitCode = 2) => Object.assign(new CliError(code, exitCode), { code });
// A raw terminal suppresses echo; input is never argv, env, readline history or stdout.
export function promptProxyKey(io, label) {
@@ -31,14 +32,14 @@ export function promptProxyKey(io, label) {
export async function proxyCommand(args, io, { system, callFor, prompt = promptProxyKey, fetch = fetchPinned }) {
const [verb, ...rest] = args;
if (args.includes('--tester')) throw failure('human-required', 3);
if (verb === 'init') {
if (verb === 'init' || verb === 'fetch') {
try { refuseInsideAgent(io.env); } catch { throw failure('human-required', 3); }
}
if (!['fetch', 'init'].includes(verb)) throw failure('proxy-usage', 4);
const parsed = { flags: new Set(), values: {} }; let port;
for (let i = 0; i < rest.length; i++) {
const flag = rest[i];
if (verb !== 'init' || !['--business', '--port'].includes(flag) || parsed.values[flag] !== undefined || !rest[i + 1]) throw failure('proxy-usage', 4);
if (verb !== 'init' || !['--business', '--port', '--rotate-grace-minutes', '--first-output-seconds'].includes(flag) || parsed.values[flag] !== undefined || !rest[i + 1]) throw failure('proxy-usage', 4);
parsed.values[flag] = rest[++i];
}
if (parsed.values['--port'] !== undefined) {
@@ -46,6 +47,10 @@ export async function proxyCommand(args, io, { system, callFor, prompt = promptP
port = Number(parsed.values['--port']);
if (port < 1024 || port > 65535 || port === 8317) throw failure('proxy-config-invalid');
}
const timing = {};
for (const [flag, field] of [['--rotate-grace-minutes', 'rotateGraceMinutes'], ['--first-output-seconds', 'firstOutputSeconds']])
if (parsed.values[flag] !== undefined) timing[field] = /^[0-9]+$/.test(parsed.values[flag]) ? Number(parsed.values[flag]) : NaN;
try { proxyTiming(timing); } catch (error) { throw failure(error.code); }
try {
const config = typeof system === 'function' ? system() : system;
if (verb === 'fetch') { const result = await fetch({ dataRoot: config.dataRoot }); io.stdout.write(JSON.stringify({ binary: result.binary, panel: result.panel }) + '\n'); return; }
@@ -54,8 +59,8 @@ export async function proxyCommand(args, io, { system, callFor, prompt = promptP
const call = callFor(parsed);
const first = await prompt(io, 'Management key: '), second = await prompt(io, 'Repeat management key: ');
if (first !== second) throw failure('proxy-key-mismatch');
if (!first.trim() || first.length > 4096 || /[\x00-\x1f\x7f]/.test(first)) throw failure('proxy-key-invalid');
const result = await call('proxy.init', { key: first, ...(port === undefined ? {} : { port }) });
if (!validManagementKey(first)) throw failure('proxy-key-invalid');
const result = await call('proxy.init', { key: first, ...(port === undefined ? {} : { port }), ...timing });
io.stdout.write(JSON.stringify(result) + '\n');
} catch (e) {
if (e instanceof CliError) throw e;
+20
View File
@@ -0,0 +1,20 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
// Observe existence only. Harness stdout and credentials never enter this watcher.
export function watchModelOutput({ runDir, seconds = 120, keyId, stop, receipt }) {
let timer, settled = false, finish;
const done = new Promise(resolve => { finish = resolve; });
const cancel = () => { if (settled) return; settled = true; clearTimeout(timer); finish(); };
const deadline = performance.now() + seconds * 1000;
const tick = async () => {
if (settled) return;
if (existsSync(join(runDir, 'proxy-first-output'))) { cancel(); return; }
const left = deadline - performance.now();
if (left > 0) { timer = setTimeout(tick, Math.min(1000, left)); return; }
try { await stop(); receipt({ action: 'proxy-stall', keyIds: [keyId] }); }
catch { receipt({ action: 'proxy-stall', code: 'proxy-stop-failed', keyIds: [keyId] }); }
finally { cancel(); }
};
timer = setTimeout(tick, 0);
return { cancel, done };
}
@@ -0,0 +1,52 @@
// P2 RED tests: these catch fetch before eligibility and divergent key predicates.
import test from 'node:test';
import assert from 'node:assert/strict';
import { proxyCommand } from '../src/model-proxy.mjs';
import { io } from './helpers.mjs';
for (const env of [{ CODEX_THREAD_ID: 'fixture' }, { MOSAIC_AGENT_RUN: 'fixture' }]) {
test(`human-required: proxy fetch refuses ${Object.keys(env)[0]} before config or fetch`, async () => {
let touched = false;
const forbidden = () => { touched = true; throw new Error('eligibility must run first'); };
await assert.rejects(proxyCommand(['fetch'], io(env), { system: forbidden, fetch: forbidden }), e => e.code === 'human-required');
assert.equal(touched, false);
});
}
test('human-required: proxy fetch tester refuses before config or fetch', async () => {
let touched = false;
const forbidden = () => { touched = true; throw new Error('eligibility must run first'); };
await assert.rejects(proxyCommand(['fetch', '--tester'], io(), { system: forbidden, fetch: forbidden }), e => e.code === 'human-required');
assert.equal(touched, false);
});
for (const key of ['fx331-caf\u00e9', ' fx331-key', 'fx331-key ', 'fx331 key']) {
test(`proxy-key-invalid: CLI rejects non-management-key input (${JSON.stringify(key).length})`, async () => {
const x = io(); x.stdin.isTTY = true; x.stdout.isTTY = true;
let calls = 0;
await assert.rejects(proxyCommand(['init'], x, {
system: { dataRoot: '/unused-fixture' }, prompt: async () => key,
callFor: () => async () => { calls++; return { port: 18432, binary: '/fixture' }; },
}), e => e.code === 'proxy-key-invalid');
assert.equal(calls, 0);
assert.equal(x.out.text.includes(key), false);
assert.equal(x.err.text.includes(key), false);
});
}
for (const [flag, code, value] of [['--rotate-grace-minutes', 'proxy-rotate-grace-invalid', '1441'], ['--first-output-seconds', 'proxy-first-output-invalid', '3601']]) {
test(`${code}: init flag refuses before config or prompt`, async () => {
let touched = false;
const forbidden = () => { touched = true; throw new Error('must not run'); };
await assert.rejects(proxyCommand(['init', flag, value], io(), { system: forbidden, prompt: forbidden }), e => e.code === code);
assert.equal(touched, false);
});
}
test('human proxy init passes optional timing flags only through authenticated call', async () => {
const x = io(); x.stdin.isTTY = true; x.stdout.isTTY = true;
const calls = [];
await proxyCommand(['init', '--rotate-grace-minutes', '20', '--first-output-seconds', '10'], x, {
system: { dataRoot: '/unused-fixture' }, prompt: async () => 'fx331-manage',
callFor: () => async (verb, args) => { calls.push({ verb, args }); return { port: 18432, binary: '/fixture' }; },
});
assert.equal(calls.length, 1); assert.equal(calls[0].args.rotateGraceMinutes, 20); assert.equal(calls[0].args.firstOutputSeconds, 10);
assert.equal(x.out.text.includes('fx331-manage'), false);
});
+30
View File
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { watchModelOutput } from '../src/model-stall.mjs';
function fixture(t) { const root = fs.mkdtempSync(join(tmpdir(), 'p2-stall-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); return root; }
test('proxy-stall: silent run stops and logs only ids, never output or key values', async t => {
const root = fixture(t), receipts = []; let stops = 0;
const watch = watchModelOutput({ runDir: root, seconds: 0.02, keyId: '0123456789ab', stop: async () => { stops++; }, receipt: r => receipts.push(r) });
t.after(watch.cancel); await watch.done;
assert.equal(stops, 1); assert.deepEqual(receipts, [{ action: 'proxy-stall', keyIds: ['0123456789ab'] }]);
});
test('first stdout marker cancels stall without reading or logging marker text', async t => {
const root = fixture(t), receipts = []; let stops = 0;
fs.writeFileSync(join(root, 'proxy-first-output'), 'fx331-private-output');
const watch = watchModelOutput({ runDir: root, seconds: 0.02, keyId: '0123456789ab', stop: async () => { stops++; }, receipt: r => receipts.push(r) });
t.after(watch.cancel); await watch.done; assert.equal(stops, 0); assert.deepEqual(receipts, []);
});
test('proxy-stop-failed: failed stall stop is logged by code and never leaks error text', async t => {
const root = fixture(t), receipts = [];
const watch = watchModelOutput({ runDir: root, seconds: 0.02, keyId: '0123456789ab', stop: async () => { throw Error('fx331-private-output'); }, receipt: r => receipts.push(r) });
t.after(watch.cancel); await watch.done;
assert.deepEqual(receipts, [{ action: 'proxy-stall', code: 'proxy-stop-failed', keyIds: ['0123456789ab'] }]);
});
test('run exit cancellation settles watcher without stopping the run', async t => {
const root = fixture(t); let stops = 0;
const watch = watchModelOutput({ runDir: root, seconds: 0.02, keyId: '0123456789ab', stop: async () => { stops++; }, receipt: () => {} });
watch.cancel(); await watch.done; assert.equal(stops, 0);
});
+12
View File
@@ -0,0 +1,12 @@
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
// The host needs an output-exists signal, never the output or a model credential.
export function firstOutputMarker(runDir) {
let seen = false;
return chunk => {
if (seen || !chunk.length) return;
try { writeFileSync(join(runDir, 'proxy-first-output'), 'output\n', { flag: 'wx', mode: 0o600 }); }
catch (error) { if (error.code !== 'EEXIST') throw error; }
seen = true;
};
}
+3
View File
@@ -38,6 +38,7 @@ import { createServer, connect } from "node:net";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { Client } from "../../bus/src/client.mjs";
import { firstOutputMarker } from './first-output.mjs';
import { GUARD_CODES, FIXED_TEXTS } from "../../library/src/reply-protocol.mjs";
import { fetchReview, landWork, pushWorking } from "./git-tools.mjs";
@@ -344,8 +345,10 @@ function runTurn({ session, request, n, env, onChild }) {
closeSync(errFd);
onChild(child);
let out = "";
const markOutput = firstOutputMarker(session.runDir);
child.stdout.setEncoding("utf8");
child.stdout.on("data", (s) => {
markOutput(s);
if (out.length < 4 * BODY_MAX) out += s;
});
return new Promise((resolve) => {
@@ -0,0 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { firstOutputMarker } from '../src/first-output.mjs';
test('stdout marker contains a fixed token only, never output text; emitted once after nonempty output', t => {
const root = fs.mkdtempSync(join(tmpdir(), 'p2-output-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const observe = firstOutputMarker(root), file = join(root, 'proxy-first-output');
observe(''); assert.equal(fs.existsSync(file), false);
observe('fx331-private-output');
assert.equal(fs.readFileSync(file, 'utf8'), 'output\n'); assert.equal(fs.statSync(file).mode & 0o777, 0o600);
const before = fs.statSync(file).mtimeMs;
observe('other output'); assert.equal(fs.statSync(file).mtimeMs, before);
assert.equal(fs.readFileSync(file, 'utf8').includes('fx331-private-output'), false);
});
+34 -3
View File
@@ -1,4 +1,4 @@
# Model proxy (P1, #1827)
# Model proxy (P1 #1827, P2 #1829)
The bus initializes and manages one pinned CLIProxyAPI per instance. PIN records
release URLs and independently reviewable SHA256s of the archive, executable and
@@ -20,8 +20,8 @@ contains `proxy.port` and `proxy.binary` only when proxy.json exists.
The management client is imported by the bus; it only GETs/PUTs the v8 list on
127.0.0.1, never follows redirects, and emits receipts with SHA256 key IDs only.
Client-key values are held in memory, never returned by a bus verb. P2 adds
per-agent lifecycle integration; this row creates only the sentinel.
Client-key values are never returned by an agent-facing bus verb. The bus manages
per-agent lifecycle below; initialization creates only the sentinel.
The unit starts through a hash-checking runner with the pinned panel directory
and `-local-model`, so catalogs require no network. Render/install is explicit;
@@ -47,3 +47,34 @@ Then start the bus normally and retry init from the human terminal. If the bus
or initialization state is uncertain, leave the lock in place. A refused fetch
may leave empty `opt/cliproxyapi/` parent directories; they are not an installed
version and need not be removed before retrying.
P2 adds an implicit `model` credential for every agent in proxy mode. The bus
confirms the v8 key-list write before storing a key at
`<configDir>/secrets/agents/<business>/<agent>/model` (0600, directories 0700).
The private file holds the key and its lifecycle metadata. Status and receipts
contain key IDs, issue time, rotation date and state; they never contain values.
No role or business `credentials.model` reference is needed or accepted.
Rotation defaults to 30 days. New launches use the replacement key. The old key
stays until its runs end or its grace expires; a failed bounded session stop
keeps both the old key and its retirement metadata for the next check. Revoke
requires a confirming management GET and polling `/v1/models` to 401 before local
cleanup. Polling has a five-second total deadline and 25ms intervals. A durable
`revoking` state blocks launches before preflight, and boot reconciliation
finishes it without restoring that key. Reconciliation preserves foreign keys
and refuses launches when this instance's sentinel is absent.
For a new instance only, human `proxy init` also accepts
`--rotate-grace-minutes N` (1–1440, default 120) and
`--first-output-seconds N` (1–3600, default 120). These are optional fields in
proxy.json; existing two-field sidecars remain valid. There is no per-agent
rotation-period setter in P2. Fetch also refuses agent-marked and tester calls
before reading config or touching the network/disk.
The trusted launcher requests `modelPreflight` with business/role and receives
exactly `{ baseUrl, key }`. That answer never passes through an agent socket or
status API. A 401 or other non-success HTTP status refuses `proxy-key-refused`;
a failed connection refuses `proxy-down`. No stdout by the configured deadline
requests a bounded stop and records `proxy-stall` with key IDs. The runner writes
only a fixed token to `<run>/proxy-first-output`, never stdout contents. A stop
failure records/refuses `proxy-stop-failed` and retains the key.
+21
View File
@@ -62,6 +62,27 @@ export class ManagementClient {
}
getKeys() { return this.#call('GET'); }
putKeys(keys) { if (!validKeys(keys)) refuse('proxy-client-invalid'); return this.#call('PUT', keys); }
// Client-key authentication only. Never send the management key to the model route,
// follow a Location, consume/log the response body or trust an ambient dispatcher.
async probeKey(key, { timeout = this.#timeout } = {}) {
if (!validManagementKey(key) || !Number.isFinite(timeout) || timeout <= 0) refuse('proxy-client-invalid');
let timer, status = 0;
try {
return await new Promise((resolve, reject) => {
const req = http.request(this.#base + '/v1/models', { method: 'GET', headers: { Authorization: `Bearer ${key}` } }, response => {
status = response.statusCode;
response.destroy();
resolve(status);
});
req.on('error', () => reject(new ProxyError('proxy-down')));
timer = setTimeout(() => { req.destroy(); reject(new ProxyError('proxy-down')); }, timeout);
req.end();
});
} finally {
clearTimeout(timer);
this.#receipt({ call: 'GET /v1/models', status, keyIds: [sha256(key).slice(0, 12)] });
}
}
}
// Trusted bus boot only: no CLI/Console/session exports this value or client.
+10
View File
@@ -34,3 +34,13 @@ export function validManagementKey(key) {
try { validateHeaderValue('Authorization', `Bearer ${key}`); return true; }
catch { return false; }
}
export function proxyTiming(value) {
const result = {};
for (const [field, max, code] of [['rotateGraceMinutes', 1440, 'proxy-rotate-grace-invalid'], ['firstOutputSeconds', 3600, 'proxy-first-output-invalid']]) {
if (!Object.hasOwn(value, field)) continue;
if (!Number.isInteger(value[field]) || value[field] < 1 || value[field] > max) refuse(code);
result[field] = value[field];
}
return result;
}
+10 -6
View File
@@ -2,23 +2,26 @@ import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import { randomBytes } from 'node:crypto';
import { PIN, refuse, safePath, regularBytes, sha256, validManagementKey } from './common.mjs';
import { PIN, refuse, safePath, regularBytes, sha256, validManagementKey, proxyTiming } from './common.mjs';
export function installPaths(dataRoot, pin = PIN) {
const dir = path.join(dataRoot, 'opt', 'cliproxyapi', pin.version);
return { dir, binary: path.join(dir, 'cli-proxy-api'), panel: path.join(dir, 'static', 'management.html') };
}
export function validateProxy(value, dataRoot, pin = PIN) {
if (!value || typeof value !== 'object' || Array.isArray(value)
|| Object.keys(value).sort().join(',') !== 'binary,port'
|| Object.keys(value).some(k => !['binary', 'port', 'rotateGraceMinutes', 'firstOutputSeconds'].includes(k))
|| !Number.isInteger(value.port) || value.port < 1024 || value.port > 65535 || value.port === 8317
|| value.binary !== installPaths(dataRoot, pin).binary) refuse('proxy-config-invalid');
return { port: value.port, binary: value.binary };
return { port: value.port, binary: value.binary, ...proxyTiming(value) };
}
export function loadProxy(configDir, dataRoot) {
const file = path.join(configDir, 'proxy.json');
try { fs.lstatSync(file); } catch (e) { if (e.code === 'ENOENT') return undefined; refuse('proxy-config-invalid'); }
try { return validateProxy(JSON.parse(regularBytes(file, 'proxy-config-invalid')), dataRoot); }
catch { refuse('proxy-config-invalid'); }
catch (error) {
if (['proxy-rotate-grace-invalid', 'proxy-first-output-invalid'].includes(error.code)) throw error;
refuse('proxy-config-invalid');
}
}
export function verifyInstall(dataRoot, pin = PIN) {
const paths = installPaths(dataRoot, pin);
@@ -50,8 +53,9 @@ export function initialConfig({ dataRoot, port, key, sentinel }) {
};
}
// Called only by the bus's path-bound human verb, never by an agent or the tester.
export async function initializeProxy({ configDir, dataRoot, key, port, pin = PIN }) {
export async function initializeProxy({ configDir, dataRoot, key, port, pin = PIN, ...options }) {
if (!validManagementKey(key)) refuse('proxy-key-invalid');
const timing = proxyTiming(options);
safePath(configDir, 'proxy-init-failed'); safePath(dataRoot, 'proxy-init-failed');
const paths = verifyInstall(dataRoot, pin);
const settingsFile = path.join(configDir, 'proxy.json'), proxyDir = path.join(dataRoot, 'proxy');
@@ -77,7 +81,7 @@ export async function initializeProxy({ configDir, dataRoot, key, port, pin = PI
const tag = sha256(configDir).slice(0, 12);
const sentinel = `msk-${tag}-sentinel-${randomBytes(32).toString('base64url')}`;
fs.writeFileSync(path.join(proxyDir, 'config.yaml'), JSON.stringify(initialConfig({ dataRoot, port: reservation.port, key, sentinel }), null, 2) + '\n', { flag: 'wx', mode: 0o600 });
const settings = { port: reservation.port, binary: paths.binary };
const settings = { port: reservation.port, binary: paths.binary, ...timing };
const settingsFd = fs.openSync(settingsFile, 'wx', 0o600); ownsSettings = true;
try { fs.writeFileSync(settingsFd, JSON.stringify(settings, null, 2) + '\n'); } finally { fs.closeSync(settingsFd); }
return settings;
@@ -0,0 +1,49 @@
// P2 RED: catch wrong-key preflight, 401 success, redirect following and unbounded probes.
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { ManagementClient } from '../src/client.mjs';
async function fixture(t, handler) {
const server = http.createServer(handler);
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise(resolve => { server.closeAllConnections(); server.close(resolve); }));
const baseUrl = `http://127.0.0.1:${server.address().port}`;
const receipts = [];
return { server, receipts, client: new ManagementClient({ baseUrl, key: 'fx331-manage', receipt: r => receipts.push(r) }) };
}
for (const status of [200, 401, 503]) {
test(`probeKey observes ${status} with one agent-authenticated GET /v1/models`, async t => {
const calls = [];
const f = await fixture(t, (req, res) => {
calls.push({ path: req.url, method: req.method, ownKey: req.headers.authorization === 'Bearer fx331-agent' });
res.writeHead(status); res.end('fixture-body');
});
assert.equal(typeof f.client.probeKey, 'function', 'client must support bounded agent-key probe');
assert.equal(await f.client.probeKey('fx331-agent', { timeout: 100 }), status);
assert.deepEqual(calls, [{ path: '/v1/models', method: 'GET', ownKey: true }]);
const output = JSON.stringify(f.receipts);
assert.equal(output.includes('fx331-agent'), false);
assert.equal(output.includes('fx331-manage'), false);
});
}
test('proxy-down: agent-key probe connection failure is sanitized', async t => {
const f = await fixture(t, (_req, res) => res.end());
await new Promise(resolve => f.server.close(resolve));
assert.equal(typeof f.client.probeKey, 'function');
await assert.rejects(f.client.probeKey('fx331-agent', { timeout: 100 }), e => e.code === 'proxy-down' && !e.message.includes('fx331-agent'));
});
test('proxy-down: silent listener cannot extend probe request deadline', async t => {
const f = await fixture(t, () => {});
assert.equal(typeof f.client.probeKey, 'function');
const started = performance.now();
await assert.rejects(f.client.probeKey('fx331-agent', { timeout: 50 }), e => e.code === 'proxy-down');
assert.ok(performance.now() - started < 1000, 'bounded HTTP probe must settle');
});
test('agent-key probe returns redirect status without following Location or exposing key', async t => {
let calls = 0;
const f = await fixture(t, (_req, res) => { calls++; res.writeHead(302, { Location: '/wrong' }); res.end(); });
assert.equal(typeof f.client.probeKey, 'function');
assert.equal(await f.client.probeKey('fx331-agent', { timeout: 100 }), 302);
assert.equal(calls, 1);
});
@@ -0,0 +1,15 @@
// P2 named settings refuse independently; legacy sidecars retain their shape.
import test from 'node:test';
import assert from 'node:assert/strict';
import { validateProxy, installPaths } from '../src/settings.mjs';
const dataRoot = '/fixture-data';
const base = { port: 18432, binary: installPaths(dataRoot).binary };
test('proxy settings without optional timing fields remain valid', () => assert.deepEqual(validateProxy(base, dataRoot), base));
test('proxy settings accept each positive upper bound', () => {
assert.deepEqual(validateProxy({ ...base, rotateGraceMinutes: 1440, firstOutputSeconds: 3600 }, dataRoot), { ...base, rotateGraceMinutes: 1440, firstOutputSeconds: 3600 });
});
for (const [field, maximum, code] of [['rotateGraceMinutes', 1440, 'proxy-rotate-grace-invalid'], ['firstOutputSeconds', 3600, 'proxy-first-output-invalid']]) {
for (const value of [0, -1, maximum + 1, 1.5, '120', null]) test(`${code}: ${JSON.stringify(value)}`, () => {
assert.throws(() => validateProxy({ ...base, [field]: value }, dataRoot), e => e.code === code);
});
}
+2 -2
View File
@@ -4,8 +4,8 @@
"options": "bundle, iife, es2022, browser, legal-comments inline, unminified; aliases in editor/build.mjs",
"outputs": {
"src/public/editor.js": {
"sha256": "bd167899a551c70dc07e500fcdd1853d7fb710b23c217ba5b42daf5785c38a72",
"bytes": 759959
"sha256": "9c881b13740fae2408717ad75fa1d85bfcfe3fa8b3af0f1b3f2e706c90e80fee",
"bytes": 758420
},
"src/public/editor.css": {
"sha256": "8654ac57d1e7a561110138ecd7f701c34a074017ff8c4cd2768d8083908ee61f",
+6 -44
View File
@@ -23,13 +23,6 @@ export const linkTarget = raw => {
return typeof gate === 'function' ? gate(raw) : null;
};
// C2-11a: an inert single-line atom keeps its exact source, including labels
// and heading fragments. HTML/entity-like targets stay ordinary text so the
// existing escaped-text serialiser still protects rewritten blocks.
const wikilinkSource = /^!?\[\[[^\[\]\r\n<>&`]+\]\]$/;
const wikilinkAt = /!?\[\[[^\[\]\r\n<>&`]+\]\]/y;
const isWikilink = value => typeof value === 'string' && wikilinkSource.test(value);
const kept = { raw: { default: null }, sig: { default: null }, lead: { default: '' } };
const block = attrs => ({ ...kept, ...attrs });
@@ -41,7 +34,7 @@ export const schema = new Schema({
horizontal_rule: { group: 'block', attrs: block({}), parseDOM: [{ tag: 'hr' }], toDOM() { return ['div', ['hr']]; } },
heading: {
attrs: block({ level: { default: 1 } }),
content: '(text | image | wikilink)*', group: 'block', defining: true,
content: '(text | image)*', group: 'block', defining: true,
parseDOM: [1, 2, 3, 4, 5, 6].map(level => ({ tag: 'h' + level, attrs: { level } })),
toDOM(node) { return ['h' + +node.attrs.level, 0]; },
},
@@ -86,16 +79,6 @@ export const schema = new Schema({
return ['span', { class: 'md-image', 'data-src': a.src, 'data-alt': a.alt, 'data-title': a.title }, shown];
},
},
// No href, image, target resolution or click handler. C2-11b owns opening.
wikilink: {
inline: true, group: 'inline', atom: true,
attrs: { source: {} },
parseDOM: [{ tag: 'span.md-wikilink[data-source]', getAttrs: dom => {
const source = dom.getAttribute('data-source');
return isWikilink(source) ? { source } : false;
} }],
toDOM(node) { return ['span', { class: 'md-wikilink', 'data-source': node.attrs.source }, node.attrs.source]; },
},
hard_break: { inline: true, group: 'inline', selectable: false, parseDOM: [{ tag: 'br' }], toDOM() { return ['br']; } },
},
marks: {
@@ -135,16 +118,6 @@ export const schema = new Schema({
// ------------------------------------------------------------------ Markdown to nodes
const splitter = MarkdownIt('commonmark', { html: true }).enable('table').enable('strikethrough');
const tokenizer = MarkdownIt('commonmark', { html: false }).enable('table').enable('strikethrough');
// The escape and backtick rules run first. A sticky match avoids copying the
// paragraph suffix at every bracket, and never crosses a link-label boundary.
tokenizer.inline.ruler.before('link', 'wikilink', (state, silent) => {
wikilinkAt.lastIndex = state.pos;
const found = wikilinkAt.exec(state.src);
if (!found || state.pos + found[0].length > state.posMax) return false;
if (!silent) { const token = state.push('wikilink', '', 0); token.content = found[0]; }
state.pos += found[0].length;
return true;
});
// Soft breaks stay line breaks in the text, so a wrapped paragraph keeps its wrapping. A link whose
// target the gate refuses makes the whole block source text (`refused`), so nothing rewrites it.
@@ -178,7 +151,6 @@ const parser = new MarkdownParser(schema, prepared, {
hr: { node: 'horizontal_rule' },
image: { node: 'image', getAttrs: tok => ({ src: tok.attrGet('src'), title: tok.attrGet('title') || null, alt: tok.content || null }) },
hardbreak: { node: 'hard_break' },
wikilink: { node: 'wikilink', getAttrs: tok => ({ source: tok.content }) },
em: { mark: 'em' },
strong: { mark: 'strong' },
link: { mark: 'link', getAttrs: tok => ({ href: tok.attrGet('href'), title: tok.attrGet('title') || null }) },
@@ -202,13 +174,6 @@ const fenceInfo = value => {
const info = typeof value === 'string' ? value.split(/[\r\n]/, 1)[0] : '';
return info.includes('`') ? '' : info;
};
// Share the complete text policy with invalid wikilink attributes. The
// vendored state.text(..., true) alone does not escape HTML or entities.
function escapedText(state, text, escape = true) {
const esc = state.esc;
state.esc = (str, start) => esc.call(state, str, start).replace(/[<&]/g, '\\$&');
try { state.text(text, escape); } finally { delete state.esc; }
}
const link = defaultMarkdownSerializer.marks.link;
const serializer = new MarkdownSerializer({
...defaultMarkdownSerializer.nodes,
@@ -216,20 +181,17 @@ const serializer = new MarkdownSerializer({
const params = fenceInfo(node.attrs.params);
defaultMarkdownSerializer.nodes.code_block(state, params === node.attrs.params ? node : node.type.create({ ...node.attrs, params }, node.content));
},
text(state, node) { escapedText(state, node.text, !state.inAutolink); },
text(state, node) {
const esc = state.esc;
state.esc = (str, start) => esc.call(state, str, start).replace(/[<&]/g, '\\$&');
try { state.text(node.text, !state.inAutolink); } finally { delete state.esc; }
},
bullet_list(state, node) { state.renderList(node, ' ', () => '- '); },
image(state, node) {
const a = node.attrs;
const title = oneLine(a.title);
state.write('![' + state.esc(oneLine(a.alt) || '') + '](' + a.src.replace(/[()]/g, '\\$&') + (title ? ' "' + title.replace(/[\\"]/g, '\\$&') + '"' : '') + ')');
},
wikilink(state, node) {
const source = node.attrs.source, valid = isWikilink(source);
// A programmatic invalid atom must not bypass the same text escaping that
// protects pasted HTML and one-line attributes elsewhere in this model.
if (valid) state.text(source, false);
else escapedText(state, oneLine(String(source ?? '')));
},
raw_block(state, node) { state.text(node.textContent, false); state.closeBlock(node); },
}, {
...defaultMarkdownSerializer.marks,
+8 -45
View File
@@ -18176,9 +18176,6 @@
const gate = globalThis.MosaicMarkdown?.linkTarget;
return typeof gate === "function" ? gate(raw) : null;
};
var wikilinkSource = /^!?\[\[[^\[\]\r\n<>&`]+\]\]$/;
var wikilinkAt = /!?\[\[[^\[\]\r\n<>&`]+\]\]/y;
var isWikilink = (value) => typeof value === "string" && wikilinkSource.test(value);
var kept = { raw: { default: null }, sig: { default: null }, lead: { default: "" } };
var block2 = (attrs2) => ({ ...kept, ...attrs2 });
var schema2 = new Schema({
@@ -18195,7 +18192,7 @@
} },
heading: {
attrs: block2({ level: { default: 1 } }),
content: "(text | image | wikilink)*",
content: "(text | image)*",
group: "block",
defining: true,
parseDOM: [1, 2, 3, 4, 5, 6].map((level) => ({ tag: "h" + level, attrs: { level } })),
@@ -18269,20 +18266,6 @@
return ["span", { class: "md-image", "data-src": a.src, "data-alt": a.alt, "data-title": a.title }, shown];
}
},
// No href, image, target resolution or click handler. C2-11b owns opening.
wikilink: {
inline: true,
group: "inline",
atom: true,
attrs: { source: {} },
parseDOM: [{ tag: "span.md-wikilink[data-source]", getAttrs: (dom) => {
const source = dom.getAttribute("data-source");
return isWikilink(source) ? { source } : false;
} }],
toDOM(node) {
return ["span", { class: "md-wikilink", "data-source": node.attrs.source }, node.attrs.source];
}
},
hard_break: { inline: true, group: "inline", selectable: false, parseDOM: [{ tag: "br" }], toDOM() {
return ["br"];
} }
@@ -18331,17 +18314,6 @@
});
var splitter = lib_default("commonmark", { html: true }).enable("table").enable("strikethrough");
var tokenizer = lib_default("commonmark", { html: false }).enable("table").enable("strikethrough");
tokenizer.inline.ruler.before("link", "wikilink", (state, silent) => {
wikilinkAt.lastIndex = state.pos;
const found2 = wikilinkAt.exec(state.src);
if (!found2 || state.pos + found2[0].length > state.posMax) return false;
if (!silent) {
const token = state.push("wikilink", "", 0);
token.content = found2[0];
}
state.pos += found2[0].length;
return true;
});
var refused = false;
var prepared = {
parse(text2, env) {
@@ -18377,7 +18349,6 @@
hr: { node: "horizontal_rule" },
image: { node: "image", getAttrs: (tok) => ({ src: tok.attrGet("src"), title: tok.attrGet("title") || null, alt: tok.content || null }) },
hardbreak: { node: "hard_break" },
wikilink: { node: "wikilink", getAttrs: (tok) => ({ source: tok.content }) },
em: { mark: "em" },
strong: { mark: "strong" },
link: { mark: "link", getAttrs: (tok) => ({ href: tok.attrGet("href"), title: tok.attrGet("title") || null }) },
@@ -18389,15 +18360,6 @@
const info = typeof value === "string" ? value.split(/[\r\n]/, 1)[0] : "";
return info.includes("`") ? "" : info;
};
function escapedText(state, text2, escape2 = true) {
const esc = state.esc;
state.esc = (str, start) => esc.call(state, str, start).replace(/[<&]/g, "\\$&");
try {
state.text(text2, escape2);
} finally {
delete state.esc;
}
}
var link2 = defaultMarkdownSerializer.marks.link;
var serializer = new MarkdownSerializer({
...defaultMarkdownSerializer.nodes,
@@ -18406,7 +18368,13 @@
defaultMarkdownSerializer.nodes.code_block(state, params === node.attrs.params ? node : node.type.create({ ...node.attrs, params }, node.content));
},
text(state, node) {
escapedText(state, node.text, !state.inAutolink);
const esc = state.esc;
state.esc = (str, start) => esc.call(state, str, start).replace(/[<&]/g, "\\$&");
try {
state.text(node.text, !state.inAutolink);
} finally {
delete state.esc;
}
},
bullet_list(state, node) {
state.renderList(node, " ", () => "- ");
@@ -18416,11 +18384,6 @@
const title = oneLine(a.title);
state.write("![" + state.esc(oneLine(a.alt) || "") + "](" + a.src.replace(/[()]/g, "\\$&") + (title ? ' "' + title.replace(/[\\"]/g, "\\$&") + '"' : "") + ")");
},
wikilink(state, node) {
const source = node.attrs.source, valid = isWikilink(source);
if (valid) state.text(source, false);
else escapedText(state, oneLine(String(source ?? "")));
},
raw_block(state, node) {
state.text(node.textContent, false);
state.closeBlock(node);
@@ -1,82 +0,0 @@
// C2-11a. Unchanged files alone cannot detect escaped wikilinks: the editor
// keeps raw source. The browser checks the structured node and a dirty block.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadEditor } from './helpers/editor-bundle.mjs';
import { WIKILINK_CASES } from './helpers/wikilink-cases.mjs';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
const editor = loadEditor();
for (const { name, text } of WIKILINK_CASES) test(`wikilink round-trip: ${name}`, () => {
assert.equal(editor.roundTrip(text), text);
assert.equal(editor.roundTrip(text.trimEnd()), text.trimEnd(), 'no trailing newline');
});
const page = async t => {
const f = await fixture(); t.after(() => f.close());
const b = await browser(); t.after(() => b.close());
await b.navigate(f.base);
return b;
};
const mount = text => `(() => {
window.__wikiEditor?.destroy();
const host = document.createElement('div'); document.body.append(host);
window.__wikiEditor = window.MosaicEditor.mount(host, {text: ${JSON.stringify(text)}});
return true;
})()`;
const atoms = `(() => {
const result = [];
window.__wikiEditor.view.state.doc.descendants(node => {
if (node.type.name === 'wikilink') result.push({source: node.attrs.source, atom: node.isAtom, inline: node.isInline});
}); return result;
})()`;
test('wikilinks are inline atoms and survive a neighbouring edit in every required context', { timeout: 120000 }, async t => {
const b = await page(t);
for (const { name, text, sources } of WIKILINK_CASES) {
await b.evaluate(mount(text));
assert.deepEqual(await b.evaluate(atoms), sources.map(source => ({ source, atom: true, inline: true })), name);
assert.equal(await b.evaluate('document.querySelectorAll(".md-wikilink a, .md-wikilink img, .md-wikilink iframe").length'), 0, 'no navigation or resource elements');
const changed = await b.evaluate(`(() => {
const e = window.__wikiEditor; let end;
e.view.state.doc.descendants((node, pos) => { if (node.isTextblock && node.type.name !== 'raw_block') end = pos + node.nodeSize - 1; });
e.view.dispatch(e.view.state.tr.insertText(' changed', end));
const output = e.text(); e.setText(output); return output;
})()`);
assert.ok(changed.includes(' changed'), name);
for (const source of sources) assert.ok(changed.includes(source), `${name}: spelling survives dirty-block serialisation`);
assert.deepEqual(await b.evaluate(atoms), sources.map(source => ({ source, atom: true, inline: true })), `${name}: survives reparse`);
if (text.startsWith('---')) assert.ok(changed.startsWith(text.slice(0, text.indexOf('\n\n') + 2)), 'front matter is untouched');
if (name === 'reference-definition collision')
assert.ok(changed.endsWith('\n\n[a]: u\n'), 'the separate reference-definition block keeps its exact bytes');
}
});
test('code, escaped openers, malformed syntax and HTML-like targets stay outside wikilink atoms', { timeout: 120000 }, async t => {
const b = await page(t);
const text = '\\[[escaped]] `[[inline-code]]`\n\n```\n[[fenced-code]]\n```\n\n[[open\n\n[[]]\n\n[[Q&A]]\n\n[[a`]]`\n\n[[<img src=x onerror=alert(1)>]]\n';
assert.equal(editor.roundTrip(text), text);
await b.evaluate(mount(text));
assert.deepEqual(await b.evaluate(atoms), []);
assert.equal(await b.evaluate('document.querySelector(".ProseMirror").querySelectorAll("img, iframe, script").length'), 0);
});
test('wikilink DOM paste accepts only bounded single-line inert syntax; malformed attrs serialise as escaped text', { timeout: 120000 }, async t => {
const b = await page(t); await b.evaluate(mount('[[a]]\n'));
const result = await b.evaluate(`(() => {
const e = window.__wikiEditor, spec = e.view.state.schema.nodes.wikilink.spec;
const attrs = source => spec.parseDOM[0].getAttrs({getAttribute: () => source});
const invalid = ['not a link', '[[a]]\\n\\n<script>x</script>', '[[<img src=x>]]', '[[a&copy;]]', null].map(source => attrs(source));
const bad = e.view.state.schema.nodes.wikilink.create({source:'[[a]]\\n\\n<img src=x onerror=alert(1)>&copy;'});
e.view.dispatch(e.view.state.tr.replaceWith(1, e.view.state.doc.content.size - 1, bad));
return {valid:attrs('[[a#h|label]]'), invalid, output:e.text()};
})()`);
assert.deepEqual(result.valid, { source: '[[a#h|label]]' });
assert.deepEqual(result.invalid, [false, false, false, false, false]);
assert.ok(!result.output.includes('\n\n<img'));
assert.doesNotMatch(result.output, /(?<!\\)<img/);
assert.match(result.output, /\\<img/);
assert.doesNotMatch(result.output, /(?<!\\)&copy;/);
assert.match(result.output, /\\&copy;/);
});
@@ -1,13 +0,0 @@
// C2-11a byte fixtures shared by the baseline measurement and regression tests.
export const WIKILINK_CASES = [
{ name: 'plain', text: '[[a]]\n', sources: ['[[a]]'] },
{ name: 'labelled', text: '[[a|b]]\n', sources: ['[[a|b]]'] },
{ name: 'heading target', text: '[[a#h]]\n', sources: ['[[a#h]]'] },
{ name: 'list item', text: '- [[a]]\n', sources: ['[[a]]'] },
{ name: 'next to front matter', text: '---\nrelated: "[[metadata]]"\n---\n\n[[a]]\n', sources: ['[[a]]'] },
{ name: 'heading block and combined label', text: '# See [[a#h|Title]]\n', sources: ['[[a#h|Title]]'] },
{ name: 'spaces, Unicode and adjacent links', text: 'See [[ Héllo there # A heading | a label ]][[next]]\n', sources: ['[[ Héllo there # A heading | a label ]]', '[[next]]'] },
{ name: 'reference-definition collision', text: '[[a]]\n\n[a]: u\n', sources: ['[[a]]'] },
{ name: 'inside an ordinary link', text: '[x [[a]]](u)\n', sources: ['[[a]]'] },
{ name: 'inert embed', text: '![[cover.png]]\n', sources: ['![[cover.png]]'] },
];
+1 -1
View File
@@ -353,7 +353,7 @@ async function validate(document, file) {
const { loadProxy } = await import("../packages/model-proxy/src/settings.mjs");
proxy = loadProxy(path.dirname(file), dataRoot);
}
} catch { fail(2, "proxy-config-invalid"); }
} catch (error) { fail(2, ['proxy-rotate-grace-invalid', 'proxy-first-output-invalid'].includes(error.code) ? error.code : "proxy-config-invalid"); }
return {
configVersion: document.configVersion,