Compare commits

..
Author SHA1 Message Date
jason.woltje dde5b84964 row 217: keep fixture status on its current served tip (Untested) 2026-10-23 14:44:50 -05:00
jason.woltje b6c3129d2e row 217: early-review coverage and availability RED cases (Untested) 2026-10-23 14:39:55 -05:00
jason.woltje c25f5b8dae row 217: citation runner retry and acceptance draft (Untested) 2026-10-23 14:25:43 -05:00
jason.woltje e25ef0373c launchpad: row 217 C2-06 work in progress, UNTESTED, handed to Beakley (mos-1500)
Guard, limits, M11 fence, broker guard/page store/check event, BusError
reason, runtime wiring and the CLI replies verb. No runner work, no tests,
nothing run. BUILD.md lists the rulings, the row 225 contract and what is left.
2026-10-23 14:10:13 -05:00
125 changed files with 3895 additions and 4096 deletions

No files matched your search

+3 -3
View File
@@ -5412,8 +5412,8 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r
2026-10-23T17:32:37Z | Gyro sage-3544 fenton-0016 N1/N2 delta | Before: W7 Gate/final real-conversion list omit the W8 build-order cross-reference and W8 human-view filtering cites agent settings. After: both locations explicitly put W8 landing before human opening as mos-1470 build order, not acceptance; comment/tracked-deletion links are not current story links and never appear in the human Connections view regardless of agent settings. New commit atop approved 5af060fb5, no amend. Static diff and append-only checks pass. Docs only, no repo Node/tests/live action. Fenton checks only this delta.
2026-10-23 | Violet row297 #1803, Q245/mos-1498 | Before: no GitHub grant method or Q-B3 answer; row198 unverified. Recovery: read brief, AGENTS, roster, row198 BUILD, #436b r5 1be535aca and Gitea implementation. Merged 14fe7513c then 4d8123b0a without rewriting upstream commits, both Untested. Q-B3 proposal in agents/violet/work/queue-297/DESIGN.md agrees S1/S2/S3/S5/S6 and proposes per-method S4 with bounded token expiry; repository identity and renewal consumer scope go to Sage before implementation. No node/tests/build/live credential operation. Gate and landing remain Sage's, after row198.
2026-10-23 | Beakley (88b97f65) | Row 217 #1718 takeover, before implementation | Clean base 45dca69f6 on beakley/row217. Recovering Launchpad handoff for citation guard, live settings, page store and runner retry. Acceptance is C2-06/design 5.2 plus sage-3595/3599/3602. Tracking agents/beakley/work/queue-217/BUILD.md; no product changes or Node/tests; Sage CLEAR required.
2026-10-23 | Violet row297 #1803, implementation candidate (UNTESTED) | After Q-B3 reads (dewey-0931, megavolt-0206) and scope sage-3620: protected GitHub App admin/HTTPS/JWT boundary, event-backed durable grants, guarded renewal, token generations, expiry/revoke/recovery, broker/runtime/CLI wiring, additive role/project binding parsing and documentation. All new tests use fake HTTPS/scratch CA/RSA keys; no node or tests run under Sage hold. Old-test delta only packages/business/tests/role.test.mjs:209 1/1 regex, Mos mos-1518; new closed-service-list test preserves its former claim. Consumer/publication gap is explicit in BUILD and README. Static git diff --check only; suites, mutants, manifest approval and gate remain outstanding. Row198 ab6417475 announced as next merge base, still untested.
2026-10-23 | Beakley (88b97f65) | Row 217 #1718 untested draft checkpoint | Continued Launchpad e25ef0373; added runner retry/text/note implementation, pure protocol constants and sage-3640 approved single read-only mount, CLI documentation and new acceptance test files. Static checks caught missing projects compatibility, async request mutation and Git pathspec matching; bounded fixes and regression tests authored. git diff --check clean. No Node/test/mutant executed; Sage holds CLEAR. 283/216/285 dependency checks and all acceptance execution remain open. Packet agents/beakley/work/queue-217/MANIFEST.md.
2026-10-23 | Violet row297 #1803 static review dewey-0944/0946 follow-up (UNTESTED) | Before: no per-path digest packet and lifecycle-delete test stopped after first revoke job. After: second-request completion asserted; expiry sweep removes expired generation files without removing durable grants; installation-token fallback removed; manual run validates before lookup; official API-version source and admin-remove/event-scan limits documented. Three added mutant killers cover DELETE 401, uncertain 0/5xx mint and shutdown signal veto. Digest file will pin the 24 reviewed git blobs and its own SHA will be reported separately. No Node/tests/mutants run; static diff only, Sage CLEAR still required. Landing must retain the seat's BUILD-LOG and SESSIONS appends alongside refactor's.
2026-10-23 | Beakley (88b97f65) | Row 217 early-look follow-up, Untested | Webby 0939c0682 E1 adds ordinary mallory human-swap test; E2 adds stored trailer-derived agent/file event evidence; E6 creative-sender matrix and E7 runtime readRaw tests authored. Sage-3668/3670 accepts direct writer substitution because setter keys exclude replyLimits (organization.set exists, correcting review premise), and requires distinct availability refusal for E4. Real held-lock/socket and runner-reason regressions plus M5 authored before that production fix; expected RED awaits Sage CLEAR. No Node/tests/mutants, no old-test edits, no green claim.
+157
View File
@@ -0,0 +1,157 @@
# Row 217 — C2-06 citation guard, page store and runner retry
Owner: Beakley (88b97f65-97db-4f05-8c6e-081f92bc3501), assigned by Mos Q245.
Issue: #1718. Reviewer: Webby. Branch: `beakley/row217`.
Brief: `docs/plans/2026-10-20_case2-rows.md`, C2-06.
Status: Untested draft on Launchpad handoff `e25ef0373`; targeted execution awaits Sage CLEAR.
## Plan and boundaries
Continue Launchpad's implementation from its handed-off ref. Complete the guard,
live reply limits and M11 writer fence, broker page store and check evidence,
CLI setter and runner retry/note behavior against the brief and design 5.2.
Supply acceptance tests and mutants, then request Sage's test slot and Webby's
review. Sage owns queue movement, gate and landing. Push only `beakley/*`, add
commits without amend/force or attribution lines. No credential reads.
## Standing rulings received from Sage (sage-3610)
- sage-3595: stub supporting scope until row 283 lands, then rebase onto it and
add real-fixture tests. Landing follows 283. `runtime.mjs` wiring approved.
- Check evidence belongs in an `action.allowed` event in the same transaction
as message insertion; no new event kind. Read the library at the cited commit.
- In library businesses only, JSON over 32,768 characters gets the
`request-too-large` retry. `role.claim` adds `library` only for a library business.
- Store `check.citations` entries with `scope`, `agent`, and `file` (the file's
last commit at the cited commit). On `thread.messages`, add `last` at the served
tip, one git call per distinct path per read. Scope/agent are broker evidence.
- sage-3599: optional `BusError.reason`, guard refusals only, capped at 300
characters. Add bus server/client to manifest. Reasonless envelopes stay
byte-identical.
- `render.mjs` obsolete notice hits belong to row 225.
- Node and tests require Sage CLEAR (sage-3610 supersedes the initial permission
for targeted new tests). Full suites, webui builds and gate require shared slot.
- Existing test edits require Mos GO through Sage; list every file and numstat
below before seeking that approval.
## Recovery and coordination
Initial base `45dca69f6`, clean. `scripts/mosaic queue next beakley` refused:
linked worktrees cannot run the queue. Reported to Sage/Mos in beakley-0001;
Sage will change ownership at a queue gap. No queue files changed.
Read AGENTS.md, agents/README.md, C2-06 brief, design 5.2 and the row 225 BUILD
contract. Requested Launchpad handoff in beakley-0002. Its header timestamp was
mistakenly typed ahead of the actual clock; sequence key identifies the message.
## Existing test changes
None yet. No tests or Node commands run.
## Handoff adopted and independent work (2026-10-23)
Fast-forwarded `beakley/row217` to Launchpad `e25ef0373` on `45dca69f6`.
The full original handoff/rulings remain at
`agents/launchpad/work/queue-217/BUILD.md`. No handoff code was rebuilt from scratch.
Implemented the runner path: role.claim library switch; exact fixed texts; note
inside JSON text with citations retained and no JSON clip; fixed texts defer
notes; one same-session retry for guard codes and both existing size caps;
retry fallback chosen from the second refusal; separate usage turn id for retry.
Outside library businesses the existing send path remains intact.
Added the CLI README usage paragraph. New acceptance tests cover library parsing,
real Git reachability/quotes/scope/origin, settings fallback, M11 direct writer
and serialization, CLI dispatch, broker insert/check atomicity, live settings,
page bounds/lifecycle, socket reasons, per-path last reads, request snapshots,
and runner RESULT/typed send_message, fixed texts, long JSON and unslop behavior.
These are authored evidence only: none has executed.
Static defects corrected locally: a missing projects object now means no library;
the guard sends its captured request arguments after the asynchronous check;
origin/last Git logs use literal pathspecs; a web quote can match any retained
fetch of the cited URL. Each has new-file regression coverage.
## Scope additions and cross-row contract
- `packages/library/src/reply-protocol.mjs`: shared GUARD_CODES/FIXED_TEXTS,
import-free, so runner imports do not reach host Git modules. guard.mjs
reexports them to preserve Launchpad's API. Fethry confirmed no renderer import
is needed when the renderer simply displays fixed texts as ordinary text.
- `packages/seat/src/sandbox.mjs`: one RUNNER_FILES entry for that pure file,
approved by sage-3640. No other mount change. The existing sandbox.test.mjs
import-closure test must pass unchanged; seat joins the gate list.
- Runtime hunks remain exactly Launchpad's import and library guard/limits/last
adapters, plus library.limits.set dispatch. Drift checks wait on 216 and 285.
- Status contract confirmed with Fethry (beakley-0004, fethry-0009): nested
`replyLimits: {requireCitation, citationsPerReply, fallback: [keys],
problem: "reply-limits-unreadable" | null}`. Check entries remain ordered
`scope/agent/file`, plus `last` on reads; no changed boolean.
## Verification and remaining acceptance
- Static `git diff --check`: clean so far. No Node, suite, test or mutant run.
- Existing test file edits: none (numstat 0/0 for every pre-existing test).
All new test files are listed in manifest.r1.sha256; no Mos approval requested.
- `mutants.py` is authored for isolated HEAD archives with green controls:
M1 remove both writer fences; M2 compare incoming human; M3 split evidence
from insert; M4 drop client reason. Not executed.
- Still required: Sage CLEAR for targeted runs; fix first-run failures; supporting
scope acceptance against row 283; 216 index/readLibrary fixture comparison;
285/runtime drift check; full library/business/bus/cli/harness/seat suites and
every scripts/test-*.sh on Sage's gate; mutant execution; Webby approval.
- Before in-review, perform brief's obsolete-code/key search and report outside
ownership hits to Sage. render.mjs/render.test.mjs hits belong to 225.
- No queue changes, protected branch pushes, gate claims or acceptance claims.
Static obsolete-code/key search completed: only packages/library/src/render.mjs:6 and packages/library/tests/render.test.mjs:58 match no-cited-answer; both belong to row 225 per standing ruling. No owned-path hit. Repeat before in-review after dependency changes.
## Webby early look 0939c0682 / webby-0249 and sage-3668/3670
This remains an untested draft. The follow-up adds tests only; production E4
changes wait for the held-lock regression's first RED under Sage CLEAR.
- E1: added an ordinary actor `mallory` that sets both `human` to itself and
`replyLimits`. The existing role/tester actors did not distinguish M2 because
the actor-shape exclusions rejected them independently of the human comparison.
M2 is still unrun; no kill claimed.
- E2: added a real bus event assertion for `agent: "acme/scribe"` from a file's
Mosaic-Agent trailer. The cited tip includes a later unrelated commit, so the
event must store the file commit rather than the library tip. thread.messages
must show `last === file` for that unchanged path. Supporting scope still waits
for row 283's real fixture, as ruled.
- E3: sage-3668 accepts direct writer.write tests under chief and tester actors
as substitutes for the brief's setter cases (brief lines 483-491 at base
45dca69f6). Correction to the early-look premise: organization.set DOES exist,
broker.mjs:4701/4709, but ORGANIZATION_KEYS excludes replyLimits; instances.set
also cannot carry it. Direct writes exercise the shared M11 boundary those
setters cannot reach with that input. Sage confirmed the accurate reason in
sage-3670; the unrelated actor case remains required.
- E4: traced readSyncState -> withLibraryLock -> library-sync-busy after five
seconds -> servedMirror catch-all -> null served -> misleading citation-invalid.
New real-flock/socket test first proves the cite valid, holds the lock, asserts
the exact citation-unavailable reason the agent receives, verifies no insert,
and checks fixed fallback delivery and recovery after unlock. Additional tests
distinguish malformed sync state from absent/new mirrors, and assert that the
actual runner retry request contains that reason. These tests are expected RED
until the fix. Planned production change: only an absent mirror or legitimate
empty state yields null; other failures become GuardError citation-unavailable;
bodies without path cites do not require the mirror lock. Add the code to
GUARD_CODES so the runner sends the agent that reason on its one retry. Per
sage-3670, existing four fallback bodies can remain only if that delivery is
verified. M5 restores catch-all behavior; authored, not run.
- E5: first-library-project selection is a Launchpad handoff implementation
choice, not a validated rule that a business can only have one library.
Cites into another library remain a limitation. No mapping/schema rule added.
- E6: the broker's full guard table now runs as both coder and research.
- E7: new citation-runtime.test.mjs starts the real runtime, reads limits from
the real organization file, then changes, corrupts and removes that fixture
file and checks library.status without replacing the library adapter.
No Node, tests or mutants ran. No test that predated row 217 changed.
## Webby follow-up 192049c98 / webby-0258
E1, E2, E6 and E7 closed statically; no executed acceptance claimed. E3 correction and E5 limitation accepted. E4 tests accepted statically; real RED then production fix/green remain on hold for Sage CLEAR, with supporting scope waiting on 283. F1: citation-helpers status now uses its mutable served value, matching guard/last after setServed instead of reporting the original fixture commit. Test-only fixture correction, unrun. manifest.r3.sha256 records the updated helper; no production or old-test changes.
+68
View File
@@ -0,0 +1,68 @@
# Row 217 draft manifest — Untested
Issue #1718, Case 2 C2-06. Base `45dca69f6`, continued from Launchpad
`e25ef0373`. This is a checkpoint for static review, not an in-review or
acceptance claim. `manifest.r1.sha256` freezes the product and test files.
## Product scope
| Path | Change |
| --- | --- |
| packages/library/src/guard.mjs | Schema and seven checks, real cited-commit reads, origins and latest path commits |
| packages/library/src/limits.mjs | Pure defaults, validation and conservative unreadable-file fallback |
| packages/library/src/reply-protocol.mjs | Import-free guard codes and four runner fixed texts |
| packages/library/src/index.mjs | Guard and limits exports |
| packages/business/src/business.mjs | Permit any replyLimits value without widening another schema |
| packages/business/src/organization.mjs | M11 fence on input and serialized output, human setter, raw live reader |
| packages/bus/src/broker.mjs | Async pre-insert guard, atomic existing-kind check event, per-session pages, settings verb/status, optional library claim flag and read-time last evidence |
| packages/bus/src/runtime.mjs | Inject guard/live limits/last and dispatch human setter (sage-3595) |
| packages/bus/src/server.mjs | Await guarded sends and thread reads; optional guard reason (sage-3599) |
| packages/bus/src/client.mjs | Preserve optional capped reason (sage-3599) |
| packages/harness/src/runner.mjs | Library texts, one retry, JSON note/clip behavior and retry usage tracking |
| packages/seat/src/sandbox.mjs | Exactly one read-only RUNNER_FILES addition for the pure protocol (sage-3640) |
| packages/cli/src/limits.mjs | Human limits replies file command |
| packages/cli/README.md | Reply settings usage |
No dependency, SQL schema, event kind, role authority or credential changes.
The additional sandbox file carries only constants and has no imports.
## New tests and fixtures
| Path | Acceptance focus |
| --- | --- |
| packages/library/tests/guard.test.mjs | Seven table checks, cap boundaries, real Git origins/reachability/literal names, web quotes and limits fallback |
| packages/business/tests/reply-limits.test.mjs | Human-only M11, old-human comparison, dotted agent identity, serialization, invalid values and raw reads |
| packages/bus/tests/citation-helpers.mjs | Shared made-up repository and real broker fixture |
| packages/bus/tests/citation-guard.test.mjs | Refusal insertion counts, transaction failure, live settings/status, page bounds, socket envelopes, file/last and async snapshot |
| packages/cli/tests/reply-limits.test.mjs | Revision read, exact setter args and usage refusals |
| packages/harness/tests/fixtures/citation-adapter.mjs | Scripted local harness, typed calls and unslop events |
| packages/harness/tests/citation-runner.test.mjs | Every RESULT refusal and typed send_message, one retry, both size caps, plain multibyte answers, fixed texts and notes, old non-library behavior |
No existing test changed. Existing role.claim pins and the seat import-closure
test stay untouched. Existing test numstat: none.
## Review focus and outstanding evidence
The check event and message insert must roll back together. Scope/agent/file
come from Git; `last` is read once per distinct path per read. Guard refusals
alone have a reason, bounded at 300 characters without quote or URL content.
Captured arguments must be the ones inserted after async validation. Every
plain or JSON length refusal shares the same single retry budget.
All tests and four isolated mutants are unrun pending Sage CLEAR. The supporting
scope tests require row 283; the index-set comparison requires row 216; runtime
drift must be checked against 216/285 before in-review. Landing follows 283.
Full gate: library, business, bus, cli, harness and seat, plus every
scripts/test-*.sh. Webby reviews the final tested manifest; Sage owns the gate,
queue and landing. No push to a protected branch is authorized.
## Early-look follow-up packet (manifest.r2.sha256)
Tests-only follow-up to Webby 0939c0682. Adds ordinary-actor M2 discrimination,
stored agent/file evidence at an unrelated tip, creative-sender table coverage,
and real runtime readRaw coverage. New citation-availability.test.mjs and runner
availability case are intentionally unrun regressions for E4/sage-3668/3670.
Production availability handling is not implemented yet: run that targeted RED
on CLEAR before applying its fix, then rerun the same cases. M5 restores the
catch-all. This packet is not ready for a green gate or manifest approval.
@@ -0,0 +1,21 @@
68c05644d29188b897e2d4695d8e7025e143d424d69a91bc07bc950f589d34bf packages/bus/src/broker.mjs
ad3c686a089f67f3c89610937c6fc9b32367cea5d9735125d89bef042082d4e8 packages/bus/src/client.mjs
49e01e5e73cc3e830ad48a1b6b13fe4d06a38950f17847dc049b75209d6d3f0a packages/bus/src/runtime.mjs
66b26d131a19f4f411686427274936850c1ff0aed11e9b281ae4aadcf4b289fc packages/bus/src/server.mjs
29114051a9bc5271e48978a9552d64b51b0ac4810f94a1f98940bf617b148df8 packages/bus/tests/citation-guard.test.mjs
dc2d5836f10f591fc174aaf9587a3bff793b24ac29efe9ab68c6a9243d1d1c2c packages/bus/tests/citation-helpers.mjs
825da818ba3cf45a4837f0f15a9c2cdba55a3d0bbb4479d55236f35799427c6f packages/business/src/business.mjs
dbd79a4dfc926620d034b540f98f39336d81e4c79f214424c3cf19250c1d1f8d packages/business/src/organization.mjs
bec04a894bc2af000548283ed7719e4de1c6d56688f42238a113390e9b2d3fbe packages/business/tests/reply-limits.test.mjs
1f1bea250f694a2aae9b9dc856b3c3424ceca153ccf826c99ccb98e6ea9cd394 packages/cli/README.md
2f291a71f490f2741edada7a3e3788500f8976a08eb8ab1411f6c5982f778260 packages/cli/src/limits.mjs
f38af2d46d5f5f6388f599912507b94b87a0bf9dcc2104bb2187c38ee35118d5 packages/cli/tests/reply-limits.test.mjs
6ad72b4245e0a66e32725941900c1ece64c2e5de2fb73d65c8e042b4f3a691a6 packages/harness/src/runner.mjs
ec7ef0cbf493217eb9229895cae2fe7ef603f41913840e59a73220df387c81ca packages/harness/tests/citation-runner.test.mjs
330bc71bf6c7e64d3ddb70440903d2d9acf7878d44a49d7e30c2352ee4c807ec packages/harness/tests/fixtures/citation-adapter.mjs
0365114cf5773cbaa9fba196fbd1e773b2ec5ad2300c40231c357ca58884b9e3 packages/library/src/guard.mjs
aaa8f7ba60a729a88a6ef57b3d5ff5a69bcf0e17b60e7db51a8dc5a697627b6c packages/library/src/index.mjs
3e04fa25a64334d33ffbec704205b32cd01992f2d341e908f933a345199beee0 packages/library/src/limits.mjs
c9e682eff87e822647921288d7fce0ebdc7b09f0a6dfdd6528f10249ae26e81b packages/library/src/reply-protocol.mjs
dd056409712113eb9c1b70e29b21022f5b30df6ffbfb2658e293edd6ec7195c7 packages/library/tests/guard.test.mjs
2f3ec99129d16394d4458e252b9cba4e38ff66cd9393e3889f8afc3140e15a8e packages/seat/src/sandbox.mjs
@@ -0,0 +1,23 @@
68c05644d29188b897e2d4695d8e7025e143d424d69a91bc07bc950f589d34bf packages/bus/src/broker.mjs
ad3c686a089f67f3c89610937c6fc9b32367cea5d9735125d89bef042082d4e8 packages/bus/src/client.mjs
49e01e5e73cc3e830ad48a1b6b13fe4d06a38950f17847dc049b75209d6d3f0a packages/bus/src/runtime.mjs
66b26d131a19f4f411686427274936850c1ff0aed11e9b281ae4aadcf4b289fc packages/bus/src/server.mjs
cdd0304ec25ef1ad3e31c52502fa46f2c80bd77c8d3c2e13f8ed4b5328f342c9 packages/bus/tests/citation-availability.test.mjs
8dc344b8a905c58d544b031ca84adb7eb869d9de88eb8e6b0fc1d9190702a38a packages/bus/tests/citation-guard.test.mjs
62d8a55945d4b700e22c8c49b6420a5e25719d266901a2ad20c72514735e65e9 packages/bus/tests/citation-helpers.mjs
168dcac99d9bfdfdf5ff0eaa2c0fff91595b351b4d0092dc299532c9c32869f0 packages/bus/tests/citation-runtime.test.mjs
825da818ba3cf45a4837f0f15a9c2cdba55a3d0bbb4479d55236f35799427c6f packages/business/src/business.mjs
dbd79a4dfc926620d034b540f98f39336d81e4c79f214424c3cf19250c1d1f8d packages/business/src/organization.mjs
4d2eb11beaa80175e524af1b95c81e7164990fba1d3e1754f5ba06eee390a14f packages/business/tests/reply-limits.test.mjs
1f1bea250f694a2aae9b9dc856b3c3424ceca153ccf826c99ccb98e6ea9cd394 packages/cli/README.md
2f291a71f490f2741edada7a3e3788500f8976a08eb8ab1411f6c5982f778260 packages/cli/src/limits.mjs
f38af2d46d5f5f6388f599912507b94b87a0bf9dcc2104bb2187c38ee35118d5 packages/cli/tests/reply-limits.test.mjs
6ad72b4245e0a66e32725941900c1ece64c2e5de2fb73d65c8e042b4f3a691a6 packages/harness/src/runner.mjs
a5ef1b65230239a7baf6d0e8f2b8a51c0516348c7c4339a55b3f0a1a6ff531ba packages/harness/tests/citation-runner.test.mjs
330bc71bf6c7e64d3ddb70440903d2d9acf7878d44a49d7e30c2352ee4c807ec packages/harness/tests/fixtures/citation-adapter.mjs
0365114cf5773cbaa9fba196fbd1e773b2ec5ad2300c40231c357ca58884b9e3 packages/library/src/guard.mjs
aaa8f7ba60a729a88a6ef57b3d5ff5a69bcf0e17b60e7db51a8dc5a697627b6c packages/library/src/index.mjs
3e04fa25a64334d33ffbec704205b32cd01992f2d341e908f933a345199beee0 packages/library/src/limits.mjs
c9e682eff87e822647921288d7fce0ebdc7b09f0a6dfdd6528f10249ae26e81b packages/library/src/reply-protocol.mjs
dd056409712113eb9c1b70e29b21022f5b30df6ffbfb2658e293edd6ec7195c7 packages/library/tests/guard.test.mjs
2f3ec99129d16394d4458e252b9cba4e38ff66cd9393e3889f8afc3140e15a8e packages/seat/src/sandbox.mjs
@@ -0,0 +1,23 @@
68c05644d29188b897e2d4695d8e7025e143d424d69a91bc07bc950f589d34bf packages/bus/src/broker.mjs
ad3c686a089f67f3c89610937c6fc9b32367cea5d9735125d89bef042082d4e8 packages/bus/src/client.mjs
49e01e5e73cc3e830ad48a1b6b13fe4d06a38950f17847dc049b75209d6d3f0a packages/bus/src/runtime.mjs
66b26d131a19f4f411686427274936850c1ff0aed11e9b281ae4aadcf4b289fc packages/bus/src/server.mjs
cdd0304ec25ef1ad3e31c52502fa46f2c80bd77c8d3c2e13f8ed4b5328f342c9 packages/bus/tests/citation-availability.test.mjs
8dc344b8a905c58d544b031ca84adb7eb869d9de88eb8e6b0fc1d9190702a38a packages/bus/tests/citation-guard.test.mjs
8a2d4b636f8d4f926c484254c33ecf7b7e4722acef3250d2730593984d5ac06b packages/bus/tests/citation-helpers.mjs
168dcac99d9bfdfdf5ff0eaa2c0fff91595b351b4d0092dc299532c9c32869f0 packages/bus/tests/citation-runtime.test.mjs
825da818ba3cf45a4837f0f15a9c2cdba55a3d0bbb4479d55236f35799427c6f packages/business/src/business.mjs
dbd79a4dfc926620d034b540f98f39336d81e4c79f214424c3cf19250c1d1f8d packages/business/src/organization.mjs
4d2eb11beaa80175e524af1b95c81e7164990fba1d3e1754f5ba06eee390a14f packages/business/tests/reply-limits.test.mjs
1f1bea250f694a2aae9b9dc856b3c3424ceca153ccf826c99ccb98e6ea9cd394 packages/cli/README.md
2f291a71f490f2741edada7a3e3788500f8976a08eb8ab1411f6c5982f778260 packages/cli/src/limits.mjs
f38af2d46d5f5f6388f599912507b94b87a0bf9dcc2104bb2187c38ee35118d5 packages/cli/tests/reply-limits.test.mjs
6ad72b4245e0a66e32725941900c1ece64c2e5de2fb73d65c8e042b4f3a691a6 packages/harness/src/runner.mjs
a5ef1b65230239a7baf6d0e8f2b8a51c0516348c7c4339a55b3f0a1a6ff531ba packages/harness/tests/citation-runner.test.mjs
330bc71bf6c7e64d3ddb70440903d2d9acf7878d44a49d7e30c2352ee4c807ec packages/harness/tests/fixtures/citation-adapter.mjs
0365114cf5773cbaa9fba196fbd1e773b2ec5ad2300c40231c357ca58884b9e3 packages/library/src/guard.mjs
aaa8f7ba60a729a88a6ef57b3d5ff5a69bcf0e17b60e7db51a8dc5a697627b6c packages/library/src/index.mjs
3e04fa25a64334d33ffbec704205b32cd01992f2d341e908f933a345199beee0 packages/library/src/limits.mjs
c9e682eff87e822647921288d7fce0ebdc7b09f0a6dfdd6528f10249ae26e81b packages/library/src/reply-protocol.mjs
dd056409712113eb9c1b70e29b21022f5b30df6ffbfb2658e293edd6ec7195c7 packages/library/tests/guard.test.mjs
2f3ec99129d16394d4458e252b9cba4e38ff66cd9393e3889f8afc3140e15a8e packages/seat/src/sandbox.mjs
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Run only on Sage CLEAR. Mutate isolated HEAD archives; never edit the checkout.
A green control run is required before each negative result counts as a kill.
Usage: python3 agents/beakley/work/queue-217/mutants.py OUTPUT_DIRECTORY
"""
from pathlib import Path
import io
import os
import subprocess
import sys
import tarfile
import tempfile
ROOT = Path(__file__).resolve().parents[4]
OUT = Path(sys.argv[1]).resolve()
OUT.mkdir(parents=True, exist_ok=True)
WRITER = "packages/business/src/organization.mjs"
BROKER = "packages/bus/src/broker.mjs"
CLIENT = "packages/bus/src/client.mjs"
ATOMIC = "if (check) this.#event(s, 'action.allowed', { action: 'message.checked', message: mid, check }, mid, checkEventId(mid));"
REENTER = "return this.#reenter(cap, s, { verb: 'message.send', args }, check);"
SPLIT = """const result = this.#reenter(cap, s, { verb: 'message.send', args }, check);
if (check) this.#store.transaction(() => this.#event(s, 'action.allowed', { action: 'message.checked', message: result.id, check }, result.id, checkEventId(result.id)));
return result;"""
MUTANTS = [
("M1-writer-fence", [(WRITER, " replyLimitsFence(old, document, actor);", ""),
(WRITER, " replyLimitsFence(old, written, actor);", "")],
["packages/business/tests/reply-limits.test.mjs"]),
("M2-incoming-human", [(WRITER, "actor === old.human", "actor === document.human")],
["packages/business/tests/reply-limits.test.mjs"]),
("M3-split-check-transaction", [(BROKER, ATOMIC, ""), (BROKER, REENTER, SPLIT)],
["packages/bus/tests/citation-guard.test.mjs"]),
("M5-hide-library-unavailable", [("packages/library/src/guard.mjs",
"refuse('citation-unavailable', 'The library is busy or unavailable. Retry the answer when the library is available.');",
"served = null;")], ["packages/bus/tests/citation-availability.test.mjs"]),
("M4-drop-client-reason", [(CLIENT, "new BusError(r.error, typeof r.reason === 'string' ? r.reason.slice(0, 300) : undefined)", "new BusError(r.error)")],
["packages/bus/tests/citation-guard.test.mjs"]),
]
archive = subprocess.check_output(["git", "archive", "HEAD", "packages", "roles", "scripts", "tools", "docs", "package.json", "RELEASE"], cwd=ROOT)
summary = []
for name, changes, tests in MUTANTS:
with tempfile.TemporaryDirectory(prefix="row217-mutant-") as directory:
work = Path(directory)
with tarfile.open(fileobj=io.BytesIO(archive)) as source:
source.extractall(work, filter="data")
modules = ROOT / "node_modules"
if modules.exists():
os.symlink(modules.resolve(), work / "node_modules", target_is_directory=True)
cmd = ["node", "--test", *tests]
with (OUT / f"{name}-control.log").open("w") as log:
control = subprocess.run(cmd, cwd=work, stdout=log, stderr=subprocess.STDOUT, timeout=120)
if control.returncode:
raise SystemExit(f"{name}: control RED, no mutant result claimed")
for file, before, after in changes:
path = work / file
text = path.read_text()
if text.count(before) != 1:
raise SystemExit(f"{name}: expected exactly one mutation anchor in {file}")
path.write_text(text.replace(before, after))
with (OUT / f"{name}.log").open("w") as log:
mutant = subprocess.run(cmd, cwd=work, stdout=log, stderr=subprocess.STDOUT, timeout=120)
if mutant.returncode == 0:
raise SystemExit(f"{name}: SURVIVED")
summary.append(f"{name}: killed (control green, mutant exit {mutant.returncode})")
(OUT / "summary.txt").write_text("\n".join(summary) + "\n")
print(summary[-1], flush=True)
-509
View File
@@ -1,509 +0,0 @@
# Row 198 build: retire the board writer on 7331; the Console serves the board in-process
Branch `bushroot/row198-r3`, rebased onto `aca16404c` (queue revs 3195-3216 on refactor). Supersedes
`bushroot/row198-r2` (base b79ad5f2c), which is kept and not force-pushed.
Issue #1495 family, Gitea #1696. Ruling source: Sage sage-2821 answering Webby webby-0150
(CHANGES B1-B5, N1-N3); earlier Q220 ruling sage-2803 (Mos mos-1301), reviewer Webby.
## Status: UNVERIFIED
No test suite, mutant, or node process has run for this commit. Sage's gate (sage-2803, sage-2821):
"no node of any kind and no https git; no test runs until my CLEAR." This commit is local.
It must not be pushed until the gate runs green and Sage opens a gap for the push.
## Ruled: `--board` is dropped for every op (Sage sage-2834 ruling a, sage-2837)
Design item 8's "keep the flag where the target parses it" part is withdrawn. `webui-service.sh
install` has no `--board` branch (exit 4), and `c3b60e513` removed the flag from its parse_flags,
so a kept flag fails at install-console. `install_stack` (stack-release.sh:565) runs that install on
activate as well as rollback, so a re-pin to a pre-198 copy has the same failure. sage-2837 makes
the rule per target, not per op.
Code: `console_for_target` drops every `--board` pair from `BASE_CONSOLE`, for any op. The reason
follows the target's `cli.mjs`:
- `parses_flag` true (pre-198 target): `drop --board <url>; the 7331 board is retired, so this
Console runs without the board`.
- `parses_flag` false (post-198 target): `drop --board <url>; the target Console does not parse it,
so the board runs in the Console`.
`DROP_BOARD_WHY` carries the reason to both print sites (plan and steps).
Tests: M12 unchanged (post-198 target, activate --plan). M13 now covers a kept `--board` for a target
that parses it, on both `activate --plan` to a pre-198 copy and `rollback --plan`. Each asserts the
drop line with the retired-board reason and no `--board` on the `Console:` install line, and that the
plan leaves state unchanged. The README rollback note covers any move to a pre-198 copy.
## What changed in the source
- `scripts/stack-release.sh` (B2, design item 8): `console_for_target` drops a `--board` pair from
`BASE_CONSOLE` unless the target's `cli.mjs` parses `--board`. `parses_flag` matches
`flag === '--board'` and ignores lines that contain `throw` (N2: the target names `--board` only
in a refusal). A dropped pair sets `DROP_BOARD` and `DROP_BOARD_WHY` (reason by target, see the
Ruled section), printed in the plan and the steps. Init at the globals. `bash -n` passes; not run.
- `packages/control-board/src/board.mjs` (B4): `createBoard` takes `isPidAlive` and passes it to
`scanAsync`, so the stale-pid refusal can be tested with an injected liveness check.
- `packages/control-board/src/cli.mjs`: `serve` throws RETIRED, exit 2 (`ConfigError` path).
- `packages/webui/src/serve.mjs`: `/api/seen` and `/api/reply` answer 405 for a non-POST, 403
`{error:'read-only', sent:false}` without `--send` on the public name, 503 with no board, and
otherwise run the in-process board. `boardSend` answers 400 on a body that is not JSON, before
any board call.
- `packages/webui/src/cli.mjs`: `--board` throws `--board retired: the board runs in the Console
(row 198)`. The board is built over `system.dataRoot` (`board/`, `seats/`).
- `scripts/webui-service.sh`: the unit no longer carries `--board`; render and install refuse it.
- `scripts/stack-copy.sh`: COPY_PATHS adds `packages/control-board/src` and `tools/tmux`.
## What changed in the tests
- `packages/cli/tests/stack-release.test.mjs` (B2): new block before the Row 181 section.
`setCli` rewrites a copy's `cli.mjs` and restores it with mode 0444 in `t.after`; `withBoard`
appends the flags to the pointer console.
- M12: `seed`, `setCli` to a refusal-only `cli.mjs`, `withBoard`, `activate --plan`. Expects the
drop line, no `--board` in the Console line, and no state change.
- M13: `setCli` to a parser, real `activate` without the flag, then the unit gets `--board` and
`rollback --plan`. Expects the rollback drop line with the retired-board reason and no `--board`
in the `Console:` install line. Activation runs without the flag because `webui-service.sh
install` refuses it.
- `packages/control-board/tests/serve.test.mjs` (B4, B5): `replyBoard(f, agentSend, isPidAlive)`
passes `isPidAlive` through. New refusal row: a stale registration (pid 4242, `isPidAlive` false)
answers 409 with `/stale/`. B5: the non-numeric `--port` test is deleted, since `serve` no
longer takes a port.
- `packages/webui/tests/send.test.mjs` (B4): `setup` builds an in-process board object
(`index`, `markSeen`, `reply`, each recording into `boardHits`); the `upstream` server is gone.
New case: a board write with a body that is not JSON answers 400 under `--send`, and the board
runs nothing.
- `packages/control-board/tests/retired.test.mjs` (new, B3): M1 `cli.mjs` starts no server;
M2 no board source imports `node:http`, calls `createServer`/`.listen(`, or exports a server;
M9 live READMEs, AGENTS.md and `scripts/*.sh` do not tell anyone to run `control-board serve`
or open 127.0.0.1:7331, except a line that says it is retired. Append-only logs and plans are
not scanned. Grep check on the current tree: no hits.
- `packages/control-board/tests/board.test.mjs` (B3): new test, M15. After a write, `index()` must
start a fresh scan. The event log must read `write, bump, scan, scan`, and the answer must show
the mark.
- The reply join killer (B3, M16) lives in `packages/control-board/tests/serve.test.mjs`, not
`board.test.mjs`. `reply()` refuses an unregistered row before any send, so the killer needs a
registered fixture (`replyFixture`), which `serve.test.mjs` already builds.
- `packages/webui/tests/board-inprocess.test.mjs` (new, B3): M6 and M7 (preview: each board route
answers 401 without the proxy header and 403 with a foreign Host, and the board never runs);
M5 (`/api/seen` without `--send` writes no `seen.json`); M10 (positive control: with `--send` on
the public name, `/api/seen` writes the mark and `/api/reply` runs exec once); M3 (a board read
makes no request to a URL other than the Console's own); M14 (a real board over a 1 s tmux shim:
the loop never stalls and `/healthz` answers while `/api/board` is pending).
- `packages/webui/tests/pinned-copy.test.mjs` (B3, M11): the existing "serve runs from a copy"
test also asserts `/api/board` answers 200 with a `sessions` array from the copy.
## Dropped coverage (B4), with reasons
- Stale-pid reply case. Not dropped: moved. It runs against `createBoard` with the new
`isPidAlive` passthrough (`serve.test.mjs`, the 409 `/stale/` row).
- Bad-JSON request case. Moved to `packages/webui/tests/send.test.mjs`. The HTTP writer is gone;
body parsing now happens in the Console's `boardSend`.
- `page.html` tests. Dropped: the page is deleted, and the Console serves the board page.
- Tester board-proxy tests. Dropped: there is no proxy. The tester socket is built with the
in-process board (N1 below).
## Corrections to r2 (N1)
The r2 note said the tester socket gets `board: null`. That was wrong. Only
`packages/webui/tests/proof.test.mjs` passes `board: null`. `cli.mjs` passes the in-process board
to `startTesterSocket`.
## Mutant map (M1-M15, design rev 5)
Each mutant runs on a scratch copy with the new tests. Not run yet.
| # | Mutant | Killer |
|---|---|---|
| M1 | `cli.mjs serve` starts a server | `retired.test.mjs` M1; also `board.test.mjs` "cli serve refuses" |
| M2 | serve source restores or exports `createServer`/`startServer` | `retired.test.mjs` M2 |
| M3 | Console proxy fetch to a board URL returns | `board-inprocess.test.mjs` "makes no request to a board URL" |
| M4 | `/api/reply` skips the `send` check | `webui serve.test.mjs` "writes refused without --send" (existing; zero captures) |
| M5 | `/api/seen` skips the `send` check | `board-inprocess.test.mjs` "writes no mark"; `serve.test.mjs` as M4 |
| M6 | board block runs before the preview and header gate | `board-inprocess.test.mjs` preview 401 |
| M7 | board route accepts a foreign Host | `board-inprocess.test.mjs` preview 403 |
| M8 | `webui-service.sh` re-accepts `--board` | `service.test.mjs` render refusal list (status 1) |
| M9 | live doc or script tells people to run `control-board serve` | `retired.test.mjs` M9 |
| M10 | `/api/reply` and `/api/seen` branches move below the read-only refusal | `board-inprocess.test.mjs` positive control (200, exec once) |
| M11 | `COPY_PATHS` drops `packages/control-board/src` or `tools/tmux` | `pinned-copy.test.mjs` "serve runs from a copy": `/api/board` 200 |
| M12 | `console_for_target` keeps `--board` for a target that does not parse it | `stack-release.test.mjs` M12 |
| M13 | a recorded `--board` is kept for a target that parses it (activate or rollback) | `stack-release.test.mjs` M13 (activate and rollback): plan drop line, then a real run: drop line in the output (rollback: steps.log), no `--board` in the unit or pointer |
| M14 | a board read blocks the loop: `tmuxInspectAsync` in `scan.mjs` swapped for `tmuxInspect` (spawnSync) | `board-inprocess.test.mjs` M14: real `createBoard`, tmux shim on PATH sleeps 1 s; the timer gap while `/api/board` is pending and `/healthz` elapsed time |
| M15 | `index()` joins a scan that started before the last write | `board.test.mjs` M15 (event log) |
| M16 | `reply()` drops the counter bump: `writes++` at `board.mjs:61` (the event at line 62 is a second mutant, caught by the event log) | `serve.test.mjs` M16: event log `["scan","scan","send","bump","scan"]`, then the next `index()` must log `scan` |
| M17 | `index()` never clears a settled scan: the `promise.then(clear, clear)` line in `board.mjs` `index()` is dropped (Webby B4, r3) | `board.test.mjs` B4: two `index()` calls with no write log `["scan","scan"]` and the second shows a session line appended between them; `board.test.mjs` B4 rejected scan: the next `index()` runs fresh. M15 and M16 still pass after the clear, so they do not kill M17 |
### M14: closed by the real-board test (Sage sage-2839, Webby N1)
The earlier gap came from a stub board: a `spawnSync` in `scan.mjs` never ran under the test. The
M14 test now builds a real `createBoard` whose tmux check runs through a shim on PATH. The shim is
`#!/bin/sh`, `sleep 1`, `exit 1`. The test measures two things, both by elapsed time:
- a 10 ms timer's largest gap while `/api/board` is pending (a blocked loop stalls the timer);
- `/healthz` elapsed time, fetched after `/api/board` is sent.
The bound is 500 ms for each. The async path (`runAsync`) keeps both near 10 ms. The M14 mutant
(`tmuxInspectAsync` calls `spawnSync`) stalls the loop for the shim's full second, so both checks
fail. The real-board test is not run yet (gate). Known soft spots, to check on the first run: the
shim must win the PATH lookup, and the `specs` entry is minimal (no registration), which may not
match a real spec.
## Checks done without running anything
- Read whole: `packages/webui/tests/fixture.mjs`, `packages/webui/tests/send.test.mjs` (first
70 lines), `packages/webui/tests/serve.test.mjs` (first 80 lines), `packages/control-board/
tests/board.test.mjs`, `packages/control-board/src/board.mjs`, `packages/webui/tests/pinned-copy.test.mjs`
(the serve-from-copy test).
- Grep, current tree: `--board` and `boardURL` in `packages/*/tests` and `scripts/test-*.sh`
show only intended refusals and the unrelated case2 suite (`case2-unit.test.mjs`, `case2-unit.sh`).
- The M9 and M2 rules run as greps over the live docs and `packages/control-board/src`: no hits.
- sha256 pins: no changed file pins a file sha256. The 64-hex literal at `service.test.mjs:23` is
a fixture uid, not a hash.
- `packages/webui/tests/library-cli.test.mjs:16`: a fake serve module exports `DEFAULT_BOARD`.
Harmless; may be dropped later.
## Docs (N2)
- `packages/webui/README.md`: the start block is one command (`node packages/webui/src/cli.mjs
serve`); `--board` is refused; the proxy-origin sentence is gone; the Host and write paragraph
says seen and reply reach the in-process board under `--send` on the public name; rollback has
no 7331 step.
- `packages/control-board/README.md`: `serve` is retired and exits 2; the board runs in the
Console; `scan` is kept; the serve-only flags are removed; the route list is marked as served by
the Console; exit 0 is scan only.
- `docs/plans/CURRENT.md`: one dated line appended at the end (append-only).
Open, ruled sage-2868: boardSend's `/api/seen` catch returns 400 for a scan failure after writeMark. Left as built: a scan failure is not a client error, so this is a real gap. Named follow-up in Sage's post-253 DEFERRED sweep (N2).
Still in the README, on purpose: `packages/webui/README.md` line 826 runs the control-board suite
(`node --test packages/control-board/tests/`). The suite still exists, so the line stays.
## Gate, not run
- control-board, webui and cli suites; `scripts/test-*.sh`; the mutants M1-M15 on a scratch copy.
All wait for Sage's CLEAR.
- The per-target drop (sage-2834 ruling a, sage-2837) is implemented and documented; M13 has its
expectation for activate and rollback. Pushed at 2a758a5ba (sage-2852 gap, 17:20Z).
- Webby webby-0152/0153 and Sage sage-2839, after 2a758a5ba: B1 and N3 met at 2a758a5ba. B2 (real
activate and rollback runs, unit and pointer checked), B3 and M16 (reply() join), N1 and M14 (real
board over a tmux shim) are written and uncommitted; the test runs wait for the CLEAR.
- N2 is open, not changed: `boardSend`'s `/api/seen` catch answers 400 with `err.message` for a scan
that fails after the mark is written. Webby marked it non-blocking; Sage has not ruled on it. A
500 for that case would need `ConfigError` to tell refusals from scan failures.
- M14: Sage accepted the gap as recorded (sage-2834). The mutant run is the check.
- Push: not done. Sage's gaps govern it.
## r4: rebase onto 45dca69f6 (sage-3575 step 1, sage-3586 ruling a)
Branch `bushroot/row198-r4-45dca69f6`: the eight r3 commits replayed on `origin/refactor` 45dca69f6.
`bushroot/row198-r3-253` stays at 1fb66792e and is not touched. Seven commits replayed clean. Only the
retire commit (0bde5c8d7, now 4288b7b16) conflicted, in three files and seven hunks.
Ruling applied: every upstream feature stays (federation view, decisionWrite, userOptions, the
`/api/config` fields, granted-user gating). The board runs in-process. The URL proxy to a board origin is
removed, because the proxy is the route to the 7331 writer that row 198 retires. This is the one place an
upstream behaviour changes. Board routes now answer from the in-process board with the same response
shapes. A granted user still gets `owner-only` 403 on board routes, because the granted-user gate runs before
them.
### Conflict hunks
Each hunk: upstream side, our side, resolution, reason.
1. `packages/webui/src/serve.mjs`, `startServer` signature. Upstream: `board = DEFAULT_BOARD`, with
`decisionWrite`, `userOptions`, `federation`. Ours: `board = null`, none of the three. Resolved: upstream
signature with `board = null`, and our doc comment above it. Reason: keeps upstream's parameters; no board
is now the default, not port 7331.
2. `packages/webui/src/serve.mjs`, body after the tester checks. Upstream: decision check, `confirmations`,
`const upstream = boardURL(board)`, `userOptions` check, `owner` with `upstream`, `hub`. Ours: nothing.
Resolved: upstream minus `const upstream = boardURL(board);`; `owner` drops `upstream`. Reason:
`boardURL` is the proxy's loopback validator; there is no proxy. The `let {...}` destructure and the
granted-user `upstream = null` line lose the same name.
3. `packages/webui/src/serve.mjs`, the board proxy block (`allowed`, `fetch(upstream + path ...)`, 502 on
failure). Upstream: the proxy, after the read-only gate. Ours: nothing here; in-process routes sit at two
points. Writes (`/api/seen`, `/api/reply`) answer 405 for a non-POST, 403 `{error:'read-only', sent:false}`
without `--send` and a named human, 503 with no board, then `boardSend`. Reads (`/api/board`,
`/api/conversations`, `/api/conversation`) answer 405 for a non-GET, 503 with no board, then
`consoleRead(board.index())` or `board.conversation`. Resolved: ours, upstream block removed. Reason: this
block is the 7331 route. Auth check order: the sign-in gate (401 for an unsigned request under `--sso`)
and the granted-user owner-only gate both run before the write routes and the read routes.
4. `packages/webui/src/serve.mjs`, `/api/config`. Upstream: `board: upstream`. Ours: `board: boardLabel`.
Resolved: `board: granted ? null : boardLabel`, with every upstream field after it unchanged. Reason:
upstream sent `null` to a granted user (its `upstream` was null); `boardLabel` is `'in the Console'` for the
owner and `null` with no system config.
5. `packages/webui/src/cli.mjs`, imports. Upstream: `DEFAULT_BOARD` in the `./serve.mjs` import, plus the bus
and console imports. Ours: `createBoard`, the two scan discovery imports, and `busCaller` again. Resolved:
upstream imports minus `DEFAULT_BOARD`, plus `createBoard` and `discoverRepoAgents`/`discoverFleetAgents`.
Reason: `DEFAULT_BOARD` no longer exists; `busCaller` was already imported by upstream.
6. `packages/webui/src/cli.mjs`, the `startServer` call. Upstream: `board: options.board`, with
`federation: system?.federation ?? null`. Ours: `board`, without `federation`, `decisionWrite`,
`userOptions`. Resolved: upstream call with `board`. Reason: `--board` is refused at `cli.mjs:39`, so
`options.board` no longer exists; the new `board` is the in-process object.
7. `packages/webui/src/public/app.js`, `/api/config` handler. Upstream: `board = config.board`, the federation
render, the sso line. Ours: `board = config.board || 'the Console board'`. Resolved: upstream handler with
our fallback label. Reason: `config.board` is `null` for a granted user and with no system config, which
would print `Board: null`.
### Upstream tests changed to match (same commit, 4288b7b16)
Upstream added tests that pass a URL board string or `f.boardURL`. Each now means "no board" (`board: null`,
which answers the board routes 503) or the in-process fixture (`f.board`). `board: 'http://127.0.0.1:9'` and
`board:'http://127.0.0.1:9'` became `board: null`; `f.boardURL` became `f.board` in decide-browser,
thread-claims-browser, processing-browser and granted-user. No assertion compared the URL string. The fake
`DEFAULT_BOARD` export strings in library-cli and thread-claims are left: they are fake module text, not board
config.
### Not ported: fake HTTP board servers (known to fail; port before the gate)
These still pass a fake HTTP origin as `board`. The in-process board never calls it, so each test's
board-route assumptions are false now. Each needs a read and a port to an in-process `board` object:
- `packages/webui/tests/browser-edge.test.mjs:19` (waits for an HTTP `/api/board` request, lines 15 and 31)
- `packages/webui/tests/preview.test.mjs:62` (fake board answers `/api/board`; line 160 asserts 200)
- `packages/webui/tests/tester-listener.test.mjs:29` (`hits` counter on the fake board)
- `packages/webui/tests/shell.test.mjs:111` and `:427` (fake board with unavailable, empty, one, fail, refuse modes)
Also a risk, not checked: any test that reaches a board route with no `board` argument. The default moved
from 7331 (a 502 on a dead origin) to `null` (503). A test asserting 502 there would now fail.
### Range-diff
`git range-diff 48e450ce8..bushroot/row198-r3-253 45dca69f6..HEAD`, markers only:
1: 0bde5c8d7 ! 1: 4288b7b16 retire the board writer on 7331 (resolved above)
2: 69ac924bc = 2: aad3000fe build design item 8; killers for M1-M15; B4 drops; docs; BUILD.md
3: 716b46adf = 3: 77c12b554 rollback always drops --board (sage-2834 ruling a)
4: 0bb5f230f = 4: c9c8badc3 drop --board for every op, reason by target (sage-2837)
5: d388f47ec = 5: f2fd6145a real-run M13 pairs, reply join killer M16, real-board M14 (sage-2839)
6: 02d5bd9b4 = 6: f913d0e76 index() clears a settled scan; B4 tests and mutant M17 (webby-0157)
7: 25565fa97 = 7: 41b5aae3c BUILD.md places the reply join killer (B3, M16)
8: 1fb66792e = 8: 3c57ebb22 BUILD.md records N2 as a named follow-up (sage-2868)
Seven commits are unchanged by patch-id. Only the retire commit is `!`.
### Files to rerun (auto-merged or changed on rebase)
The drift check found nine paths touched by both r3 and upstream. Six auto-merged and need a test rerun:
- `packages/cli/tests/stack-release.test.mjs`
- `packages/webui/README.md`
- `packages/webui/tests/browser.test.mjs`
- `packages/webui/tests/chat-browser.test.mjs`
- `packages/webui/tests/chat.test.mjs`
- `scripts/stack-release.sh`
The three conflicted src files are in the hunk list above. The 21 upstream-stub test files listed above also
need a rerun, because the drift check did not cover them: they are upstream-only changes.
### Gate status
UNVERIFIED. No node, no test run, and no mutant has run on r4. The gate waits for Sage's CLEAR, after 226,
220/216 and 285 in gate order. The four fake-board tests above are known red. The rest are unknown until the
run.
## r4: the four fake-board tests ported to the in-process board (sage-3597, sage-3603)
Scope: the four tests that read the retired fake HTTP board server, plus one new granted-user test. Numstat is
against 14fe7513c, the r4 tip before this entry. Nothing in this section has been run. The gate is still
UNVERIFIED, and the tests here were not executed (slot rule). Sage sends the list below to Mos (mos-1272).
| File | +/- | What changed |
|---|---|---|
| `packages/webui/tests/browser-edge.test.mjs` | 14/13 | The fake server is gone. `index()` holds while `holdBoard` is set and resolves to the page's board on release. `reply()` holds in place of the `held` response and returns `{status, body}`. The 5 s "made no /api/board request" check still runs: it now watches `index()` being called. Every assertion is kept. |
| `packages/webui/tests/preview.test.mjs` | 12/16 | `setup()` builds an in-process board that records `GET /api/board`, `POST /api/seen` and `POST /api/reply` in `boardHits`. The 401 and read-only tests keep their `boardHits` checks. The dead-board case is a board whose `index()` throws, so the answer is 502 `invalid-response`. The upstream origin is no longer in the body (see the deleted assertion below). |
| `packages/webui/tests/tester-listener.test.mjs` | 4/4 | The first test's `hits` counter moves into an in-process `index()`. Every bus and board read still counts, and both 403 checks are unchanged. The `createServer` import is removed. |
| `packages/webui/tests/shell.test.mjs` | 44/63 | `board()` is an in-process board. Modes: `empty`, `one`, `registered`, `fail` (throws, so 502 `invalid-response`), `unavailable` (throws `ReadError('read-failed')`, so 503). `hold()` gates `index()`. The conversation catalogue and page are sync. Changed expectations (listed below). Refusal-only steps removed (listed below). |
| `packages/webui/tests/granted-user.test.mjs` | 32/2 | `setup()` takes `board` and `send` options, defaulting to the old `null` and `false`. New test `R6 G4b`: a granted session gets 403 `owner-only` on all five board paths with no board read, and the owner session gets 200 on each, with the read order checked. |
### Changed expectations (not loosened; each is a product change, for Mos)
These expectations now name the in-process contract. The message text came from the fake server before. Each one is
an old assertion with a new value.
- `shell.test.mjs` stale-data check: `/board scan failed.*Showing last known data/` is now `/invalid-response.*Showing last known data/`. A scan throw is mapped by `consoleRead` to `invalid-response` (502). Before, the fake returned 500 with the text.
- `shell.test.mjs` unavailable check: `/board unavailable.*No board data loaded/` is now `/read-failed.*No board data loaded/`. `consoleRead` sends the code as `error`, and the page shows `err.message`, which is that code. The page shows `read-failed` now, where it showed `board unavailable` before.
- `preview.test.mjs` dead board: the status stays 502, and `body.error` is now `invalid-response`.
### Deleted assertions (proxy-only, no in-process counterpart)
- `preview.test.mjs` `assert.equal(board.body.board, dead)`. The in-process board has no origin, so the body
can't name one. The status and the bus checks around it are kept.
- `shell.test.mjs` the refusal block: the fake's HTTP 403 `board-refused`. `consoleRead` has no 403 code: `STATUS`
in `reads.mjs` maps only `not-configured`, `no-bus-host`, `queue-refused`, `read-failed`, `not-found` and
`invalid-request`, and any other code is 502. So the in-process board cannot refuse, and `app.js`'s
`err.status === 403` branch ("The board refused the read", `#board-refused`, the fail-closed drop of kept
rows, the inspector and the counts) has no trigger in these tests. The block that was deleted asserted every
one of these. The conversation refusal is deleted too: a 403 from `/api/conversation` closed the open
conversation, and the new step closes it with `#conv-back`.
This is a coverage loss, and it needs a decision. Either `reads.mjs` maps a board refusal to 403 (a product
change, which Mos rules on), or the refusal branch is retired. The granted-user 403 `owner-only` answer goes
through the same `err.status === 403` branch, so the page path is still reached by granted users. It is not
covered by a shell check.
### Status change, dead board (Webby's question, webby-0229)
A board failure used to answer by the origin's state: a dead origin was 502 with the origin named, a board 500
was 500, and a 503 passed through. Now:
- no board configured (`board` null): 503 `not-configured`;
- a `ReadError` with a code in `STATUS`: that code's status;
- any other throw (a scan failure): 502 `invalid-response` on `/api/board`. The 500 applies only on the
conversation routes (`conversation read failed`).
No test under `packages/webui/tests` asserts 502 on a board path other than the preview dead-board case above.
The grep: `usage`, `limits-route`, `routes`, `chat`, `library-status`, `thread-claims`, `send`, `bus`,
`project-file`, `tester-listener`, `send-browser`, `instances`, `tester-cli` and `agents`. Those 502s belong to bus,
chat, or project-file routes, which this change does not touch.
### Granted-user gate (sage-3603): the test and its mutant
The board is a closure that the granted rebind doesn't touch, so `userRoute` is the only gate in front of it
for a granted session. Upstream's null-origin backstop is gone. Test `R6 G4b` pins that: the granted session gets
403 `owner-only` on `GET /api/board`, `GET /api/conversations`, `GET /api/conversation`, `POST /api/seen` and
`POST /api/reply`, and `reads` is still empty after those five requests. The owner session gets 200 on each, and
`reads` shows the five calls in order.
Mutant M18 (not run): in `packages/webui/src/serve.mjs`, change the granted gate
`if (!userRoute(path, req.method)) return json(res, 403, ...)` so it passes when `path` is one of the five board
paths. Expected: `R6 G4b` fails at the granted 403 assertion, because the granted request reaches the board and
`reads` is not empty. Run it with the rest of the granted-user suite once Sage gives CLEAR. The kill is not yet shown.
### Gate status
UNVERIFIED. These four files and the granted-user test are edited but not run. The rule "commit only after suites
are green" is not yet met, and this commit says so. The coverage loss above needs Sage's ruling before the gate.
## r4 round 2: mos-1513 (B1 fix), N1 test, route helper (19:23Z 10-23)
Not run. Node and browser suites wait for Sage's CLEAR.
### B1: CDP interception instead of a named listener on the old files
Per mos-1513, the browser tests keep the real in-process board. Each one calls `routeWrites(b, base, board)` from
`packages/webui/tests/board-route.mjs` before it navigates. Fetch intercepts the page's `/api/config` (answered
with the real body and `send: true`), `/api/reply` and `/api/seen`. Each write is forwarded over node http to a
named send listener on the same board, with the preview Host, the `https://` Origin and the signed user header.
So the server's own gate, parse and status mapping answer, and the page gets that answer back. Nothing re-implements
`boardSend`.
Shared helper, named for webby-0243: `board-route.mjs` exports `ROUTE`, `namedListener(board)`, `forward(port, path,
body)` and `routeWrites(b, base, board)`. `namedListener` and `forward` are the same code `routeWrites` uses.
### Numstat (git diff --numstat, packages/webui/tests, against 4d8123b0a)
3 1 board-held.test.mjs
7 2 browser-edge.test.mjs
6 2 browser.test.mjs
6 3 discord.test.mjs
4 2 history-return-flow.test.mjs
4 2 return-flow.test.mjs
New files: `board-route.mjs` (helper), `board-route-map.test.mjs` (node, see below), `board-refused.test.mjs` (N1).
### Line map: every removed line in the six old tests
No assertion line is changed. Every removed line is one of these, and each is listed by old line:
- board-held.test.mjs: `done` teardown (adds `stopRoute()` before `b.close()`); the `routeWrites` line is added in
`setup`. No assertion.
- browser-edge.test.mjs: the `await b.viewport(320); await b.navigate(...)` line is rewritten as the same two
statements with the `routeWrites` line added before it; the `finally` adds `stopRoute?.()`. The `board` object
is unchanged. `held` and `releaseBoard` are the same. No assertion.
- browser.test.mjs: the `finally` adds `stopRoute?.()`; `routeWrites` is added before the `viewport`/`navigate`
line, which is byte-identical. No assertion.
- discord.test.mjs: the `fetch(base + '/api/reply', ...)` request line becomes `namedListener` + `forward`, the
request setup, not the assertion; `assert.equal(reply.status, 409)` is byte-identical. The `finally` closes the
named listener.
- history-return-flow.test.mjs: `let b` gains `stopRoute`; `finally` adds `stopRoute()`. No assertion.
- return-flow.test.mjs: `let web, b` gains `stopRoute`; `routeWrites` added after `browser()`; `finally` adds
`stopRoute()`. No assertion.
### N1 (sage-3618, mos-1506 b): the refusal block, old to new
Old line numbers are in `shell.test.mjs` at 14fe7513c, the block 178-245. New line numbers are in
`board-refused.test.mjs`. Every old assertion is restored with its text unchanged:
181 -> 74 183 -> 76 184 -> 77 185 -> 78 187 -> 80
188 -> 81 189 -> 82 190 -> 83 191 -> 84 192 -> 85
193 -> 86 194 -> 87 195 -> 88 196 -> 89 197 -> 90
203 -> 97 204 -> 98 205 -> 99 206 -> 100 207 -> 101
208 -> 102 209 -> 103 210 -> 104 211 -> 105 212 -> 106
215 -> 109 216 -> 110 217 -> 111 218 -> 112 220 -> 114
221 -> 115 227 -> 122 229 -> 124 232 -> 127 236 -> 131
237 -> 132 238 -> 133 239 -> 134 240 -> 135 241 -> 136
243 -> 138
Changes in the port: the page-level 403 stub (`Fetch` on `*/api/board*` and `*/api/conversation*`) is the one
place a page-level stub is used, per N1. The board is the in-process one with `refuse()` set. The fake-board
data in `board()` is the old fake board's data, moved.
Pinned N1 mutant (not run): in `packages/webui/public/app.js` (the `error(err)` branch that sets
`refused = err.status === 403`), drop the `#board-refused` show and the `refused` banner. Expected:
`board-refused.test.mjs` fails at line 80 (`banner ref`) and line 83 (`#board-refused` hidden). Run after CLEAR.
### Node coverage for the real route
`board-route-map.test.mjs` (new) starts the named listener on a fixture board and pins the mapping the browser
tests now rely on: seen is 200; a delivered reply is 200 with `delivered: true`; an exit-4 reply is 200 with
`delivered: false` and `exitCode: 4` (this is `replyWith` in control-board/src/serve.mjs, which maps a nonzero exit
to 200, not a status code). Existing coverage cited, not duplicated: `board-inprocess.test.mjs:84-97` (seen 200 and
reply delivered through a named listener); `send.test.mjs:114-119` (loopback Host writes 403 read-only, nothing
reaches the board); `preview.test.mjs:185-199` (every write 403 read-only from the public name and loopback; 401
unsigned on `/api/reply`).
The discord 409 is now reached through the named listener, so `discord.test.mjs` pins the connector refusal on the
real route.
### Not done in this round
- The plain-listener browser check for the reply form and Seen line with send off (the `canSend` gate in app.js).
The node and browser coverage for that gate is not written yet.
- `send-browser.test.mjs:67,243` is unchanged. Webby to confirm next round.
- Mutant M18 and the N1 mutant are not run.
### Gate status
UNVERIFIED. Nothing in this round is run. Waiting for Sage's CLEAR before any node or browser run.
### Correction to the r4 round 2 entry above (19:24Z 10-23)
- The N1 mutant's file is `packages/webui/src/public/app.js`, not `packages/webui/public/app.js`.
- `board-refused.test.mjs` does not use the in-process board. Its `board()` is an inline stub object with a `refuse()`
flag; the 403 comes from a page-level Fetch stub on `*/api/board*` and `*/api/conversation*`, as the N1 ruling
allows. The "in-process board with refuse() set" sentence above is wrong.
## r4 round 2 fixes: mos-1522 (19:40Z 10-23)
Not run. Commit held locally until the suites pass (mos-1522).
- B1 (discord.test.mjs:43, :50): app.js now checks `r.connector` first, then `!canSend`, then the registered-seat text, in both the inspector reply line (app.js:100) and `convForm` (app.js:200, conv-no-reply). Discord still asserts "Board replies disabled for Discord connectors" with no routeWrites added. Assert lines are unchanged.
- B2: `conv-no-reply` with send off reads "Replies are off: this Console is read-only.", asserted in send-off-browser.test.mjs.
- routeWrites flips only `send` to true, over the real `/api/config` answer. Nothing else in the config body is changed.
- Mutants (not run): (a) drop `canSend &&` from `canReply` (app.js:51); (b) drop the `seenButton` guard (app.js:71). Expected: send-off-browser fails on `.reply-form` absent (a) and on `[data-seen]` absent (b).
## r4 round 2 follow-up: Webby CHANGES on ab6417475 (N1, N2, B1 board-refused) (10-23 19:46Z)
Not run. Gate waits for Sage's CLEAR. Commit A of the follow-up; the old-test setup is commit B, below.
- N1: `routeWrites` builds the config URL with `new URL('/api/config', base)`, so a trailing-slash base no longer requests `//api/config` (which parses as host `api`). The config body now comes from one new export, `sendConfig(base)` (board-route.mjs), which `routeWrites` and `sendOnConfig` both use.
- New export `sendOnConfig(b, base)` (board-route.mjs): Fetch answers the page's `/api/config` with send on and leaves every write alone. It is config only (mos-1524); its callers are in commit B.
- board-refused.test.mjs: line 13 imports `sendConfig`; the Fetch pattern list adds `*/api/config` (line 59 onward); the handler answers `/api/config` with `sendConfig(base)` and returns before the refusal branch. The 403 refusal stub is unchanged. The assert that was line 122 (`#conv-form` hidden is false) is now line 128, unchanged; it sees send on.
- N2, the "Not done" list above is out of date. Its line "the canSend check is not written" is wrong: the check is in app.js (ab6417475, mos-1522 fixes in b0c79751c). Written as of this follow-up:
- app.js:24 `canSend` (set from `/api/config` at app.js:482); app.js:51 `canReply` gated on `canSend`; app.js:71 `seenButton` guard.
- app.js:100 inspector reply line and app.js:200 `convForm` (conv-no-reply): connector first, then the read-only line, then the registered-seat line.
- New user-visible strings: "Replies are off: this Console is read-only." and "Seen is off: this Console is read-only.".
- New test files: board-route.mjs (helper), board-route-map.test.mjs (node), board-refused.test.mjs, send-off-browser.test.mjs.
- Mutants planned, not run: (a) drop `canSend &&` from `canReply` (app.js:51); (b) drop the `seenButton` guard (app.js:71). Both must fail in send-off-browser.test.mjs.
## r4 round 2 follow-up, commit B: old-test setup for the canSend gate (Webby B1, mos-1524) (10-23 19:46Z)
Not run. Setup only: no assert line changed. Approved on the mos-1522 terms (mos-1524: config-only stub, not routeWrites). Gate waits for Sage's CLEAR.
Numstat for this commit (`git diff --numstat`):
| file | added | removed | change |
| --- | --- | --- | --- |
| packages/webui/tests/relaunch.test.mjs | 2 | 1 | line 10 imports `sendOnConfig`; line 32 `await sendOnConfig(b, webURL)` after `browser()` |
| packages/webui/tests/shell.test.mjs | 2 | 0 | line 17 imports `sendOnConfig`; line 124 `await sendOnConfig(b, base)`, first test only |
| packages/webui/tests/sso-browser.test.mjs | 1 | 1 | line 33 page stub: `/api/config` gets `send: true` on every page; `sso: true` still only with `sso` in the URL |
- relaunch.test.mjs:49 (`#reply` present, "reply eligibility unchanged") is unchanged and now sees send on. No teardown is needed: `sendOnConfig` starts no server; `b.close()` in the finally ends the Fetch domain.
- shell.test.mjs:217 (`#conv-form` hidden is false) is unchanged and now sees send on. The five-states test (from about line 410) has no reply, Seen or conv-form assertion and is not changed.
- sso-browser.test.mjs:91-92 (`[data-seen]` wait and click) is unchanged. Its `/api/seen` write is still answered 401 `sign-in required` by the page stub before it reaches the server, so the sign-in path is what runs. The sign-in lines and the `__signIn` list are unchanged.
- board-refused.test.mjs:128 (`#conv-form` hidden is false) is in commit A, with its config stub; it needs no change here.
- Totals for this commit: 5 added, 2 removed, 3 files. Assert lines changed: 0.
+124
View File
@@ -0,0 +1,124 @@
# Row 217 (Case 2 C2-06): build notes
Brief: docs/plans/2026-10-20_case2-rows.md, section C2-06, at base 45dca69f6.
Builder: Launchpad until 2026-10-23 19:09Z. Then Mos moved coding to Codex seats (mos-1500, Q245) and
the row went to Beakley on beakley/row217.
## State at handoff
All code in this commit is UNTESTED. No node, no test and no mutant has run on it. No test file is written.
`packages/harness/src/runner.mjs` is not started.
Done, untested:
- `packages/library/src/limits.mjs` (new, pure): `REPLY_LIMITS`, `resolveReplyLimits(read)` gives
`{requireCitation, citationsPerReply, fallback, unreadable}`, `checkReplyLimits(value)` gives a clone or null.
An unreadable business file forces `requireCitation` true. A key falls back to its default when
`replyLimits` is not an object or the key holds an invalid value.
- `packages/library/src/guard.mjs` (new): `GUARD_CODES` in design 5.2's table order, `FIXED_TEXTS`,
`WEB_CITE_DEFINITIONS`, `REASON_MAX` (300), `GuardError`, `parseReply`, `checkReply`, `libraryReader`,
`lastCommits`, `guardReply`.
- `packages/library/src/index.mjs`: exports for both.
- `packages/business/src/business.mjs`: `replyLimits` is a known top key. The validated output doesn't carry it.
- `packages/business/src/organization.mjs`: the M11 fence `replyLimitsFence(old, document, actor)` compares
against `old.human`, refuses the tester and any `<definition>.<instance>` of `old.roles`, and is called for
both `document` and `written`. `setReplyLimits` and `readRaw` are exported.
- `packages/bus/src/broker.mjs`: the guard call, the page store, the check event, `library.limits.set`,
`library.status` gains `replyLimits`, `role.claim` gains `library`, `thread.messages` gains `check` and `last`.
- `packages/bus/src/server.mjs`, `client.mjs`: `BusError.reason` (sage-3599).
- `packages/bus/src/runtime.mjs`: guard deps and the `library.limits.set` dispatch.
- `packages/cli/src/limits.mjs`: `limits replies --file LIMITS`.
Left:
- `packages/harness/src/runner.mjs`: the C2-07 runner work (see "Runner plan" below).
- `packages/cli/README.md`: a line for `limits replies`.
- Every test in the brief and the rulings below, then the mutants.
- The rebases onto rows 283, 216 and 285, with the drift checks below.
- This file's gate results and the manifest.
## Rulings in force
sage-3595:
1. Supporting scope: a stub classify test now. Before in-review, rebase onto row 283's landing
(honker/row283-build bd2c1c4ea when ruled) and add two real-fixture tests: a supporting cite with
`as: "canon"` refuses `playground-cited-as-canon`, and the stored check result names `supporting`.
2. runtime.mjs is approved for the guard wiring and the one `library.limits.set` branch. List its hunks here,
name it in the manifest, and drift-check it after the 216 and 285 rebases.
3. The check result is an `action.allowed` event with `action: "message.checked"`. The events kind CHECK at
`packages/bus/src/migrations/3b.sql:9` has no `message.checked`, so a new kind would be a schema step.
The event and the message INSERT share one transaction: a mutant that splits them must fail.
4. readLibrary at the cited commit stands in for the C2-05a index. On the 216 rebase, compare 216's index
set with readLibrary's for the fixture and record the result here.
5. The retry is for a library business only. Tests: a JSON body between 32,768 characters and 64 KiB gets
the retry; outside a library business nothing changes.
6. Old `role.claim` pins stay as they are.
7. `render.mjs:6` and `render.test.mjs:58` belong to row 225. Leave them.
8. `thread.messages` rows that carry `check` get `last` per path cite: the file's last commit at the served
tip, one git call per distinct path per read. Test with a fixture file committed after the cite.
sage-3599: `BusError` gets an optional `reason`. Only the citation guard's refusals set it. The envelope
carries `reason` only when it is set. It is the guard's own text, never a quote, file text, url or a path
other than the cited one, at most 300 characters. Tests: a guard refusal reaches the runner with code and
reason; a refusal without a reason has an envelope byte-identical to today's, checked against an existing
pin; a mutant that drops the client.mjs pass-through fails.
sage-3602: store `file` per path cite from the same git log call as the trailer. Test with a cited commit
that didn't touch the file, so `file` differs from the cited commit, and `last` equals `file`.
## The check-result contract with row 225 (launchpad-0118, launchpad-0120, morgana-0200, sage-3596)
- Stored on the `message.checked` event: `{citations: [...]}` in body order.
- A path cite: `{scope, agent, file}`. `scope` is the classify scope. `agent` is the cite's file's last
commit's `Mosaic-Agent` trailer at the cited commit, or null. `file` is that commit's 40-hex id, or null.
- A url cite: `{scope: "web", agent: null}`.
- On `thread.messages`, each path cite gains `last`: the file's last commit at the served tip (40-hex or null).
The Console compares `last` with `file`.
- Rows without `check` are unchanged. No `changed` boolean. The webui passes rows through unchanged.
## Hunks outside the brief's file list
- `packages/bus/src/runtime.mjs`: imports `setReplyLimits`; the library deps gain `guard`, `replyLimits`
(organizationWriter `readRaw`, or `{readable: false}` with no organization), `last` and `GuardError`;
the organization dispatch gains `library.limits.set`.
- `packages/bus/src/server.mjs`: a `refusal(e, other)` helper above serve's doc comment builds every
BusError envelope and adds `reason` only when set. The synchronous request path accepts a Promise result
(the guard is async), with a 120 s timeout, and releases `own` when it settles.
- `packages/bus/src/client.mjs`: `new BusError(r.error, reason)` with `reason` a string cut at 300.
## Decisions and their tradeoffs
- The guard runs async outside the SQL transaction, then re-enters request() through `#guardPass`, so
message.send's insert and the check event stay one synchronous transaction.
- The guard runs before message.send's own checks, so a guard code can come before an authority code.
- The check event id is derived from the message id (a UUID-shaped sha256), so `thread.messages` finds it
through the unique index on `events.id`.
- With no organization configured, `replyLimits` reads as unreadable, so it fails closed.
- With more than one library project, the guard uses the first in the business file's order.
- A library business without guard deps refuses guarded sends with `project-not-configured`.
- The page store is per cap: 20 pages of 200 KB, kept only in a library business, dropped at session
delete or disconnect and at run end.
- The CLI `replies` verb reads the revision itself from `limits`.
- Guard reasons never carry a url or a quote (sage-3599).
## Runner plan (not built)
`role.claim`'s reply is discarded at `runner.mjs:462`; keep it and read `library === true`. In a library
business, at the reply site (`runner.mjs:582-587`):
- Fixed texts `(the session gave no answer)` and `(the turn failed)`; the reason stays in the run log.
A fixed text goes with no note, and `unslop.sent()` is not called, so the note waits.
- A body that parses as a JSON object with a string `text` gets `unslop.note()` appended to `text`, is
serialized again and is never clipped. A plain body keeps `clip(body) + unslop.note()`.
- On a refusal whose code is in `GUARD_CODES`, or `request-too-large` from the client, or the broker's
`invalid-request` from `string(a.body, 32768)` for a body over 32,768 characters (ruling 5; the runner
can tell it by the body length it sent), run one new turn in the same
session naming the code and `err.reason`, or the 64 KiB limit. Bracket it with usage like other turns.
- After the retry: a table code sends `(no answer passed the citation check)`, too large sends
`(the answer was too long to send)`. One retry per answer, whatever the cause.
- `runTurn`'s output buffer holds up to 4 * BODY_MAX characters, so a reply over 64 KiB can reach the send.
- Outside a library business nothing changes.
## Mutants planned
- The M11 writer check removed.
- `document.human` compared instead of `old.human`.
- The check event split from the insert transaction.
- The client.mjs reason pass-through dropped.
-154
View File
@@ -1,154 +0,0 @@
# Row 297: GitHub grant method (#1803)
Owner Violet (thread `6229eb89-192c-4ca8-9a07-204a33458de3`), reviewer Dewey.
Assignment: Mos mos-1498 / Q245, 2026-10-23. Branch `violet/row297`.
Brief: `docs/plans/2026-10-23_mvp-readiness-rows.md`, GitHub grant method section.
## Status: Untested; Q-B3 contract review before implementation
First commit merges row 198 at `14fe7513c`; second merges its next head `4d8123b0a`.
Both are merge commits; Bushroot's commits remain byte-identical. New 198 heads will
be merged, never rebased/amended/force-pushed. Row 198 is unverified and its old-test
approval is pending. Row 297 cannot land before row 198 passes its own gate.
Read AGENTS.md, agents/README.md, Bushroot's queue-198 BUILD.md and the supplied brief.
Recovered #436b r5 at `1be535aca`, section 10 S1-S6, and inspected row 236's Gitea
implementation, current credential schema, CLI and broker integration. Bushroot
confirmed there are no prior Q-B3 answers or started row 297 work.
`DESIGN.md` answers Q-B3, proposes per-method S4, and names the repository identity
and renewal consumer boundaries requiring Sage's ruling. No implementation starts
before Dewey and Megavolt read the answer, as brief Step 1 requires. Shared Gitea
behavior remains unchanged. No credential file or live token was read; research
used public official GitHub documentation only.
## Verification and slot
- `scripts/mosaic queue next violet` in this worktree refused linked-worktree execution.
- Retried in `/mnt/storage/src/mosaic-stack`: reported an unknown rendered view and
`nothing` for Violet. No queue file changed. Sage owns registration and queue changes.
- No node, test, webui build, gate or mutant run. Sage sage-3614 requires CLEAR first.
- Merge commits are explicitly labeled Untested per Mos's assignment.
## Pre-existing tests changed by Violet
None. Inherited row 198 test changes belong to Bushroot and are recorded in that
row's BUILD.md; Violet has neither changed nor claimed verification of those files.
## Push and landing
Only `violet/*` may be pushed. No push, PR, landing or queue movement yet. First push
ETA communicated as 20:00 UTC, subject to Q-B3 review. Sage owns the gate and landing.
## First push: 6af63e1d3 (2026-10-23)
Pushed only `violet/row297`, normal new-branch push. Packet sent as violet-0004 to
Sage, Dewey, Megavolt and Mos with its commit link and the two explicit scope
rulings. `git diff --cached --check` passed before the packet commit. Verified
14fe7513c and 4d8123b0a are ancestors; only the packet, this BUILD and append-only
session/build-log entries differ from 4d8123b0a. No product or test delta.
Independent inspection after the packet confirms the boundaries: runtime.mjs
constructs only giteaGrant/giteaKeys; broker grants.run routes only to Gitea;
credential reference dates are calendar days, and publication identity is Gitea
fork-based. A GitHub renewal consumer cannot safely reuse those assumptions
without the method-specific contract. Implementation waits on the required
Q-B3 reading and Sage's scope ruling, not on a routine coding choice.
## Implementation in progress (sage-3620; dewey-0931; megavolt-0206)
Both Q-B3 reads completed with no design blocker. Sage selected the App bot on
configured repositories, no accounts/forks, and protected mint/renewal/evidence/
revoke/expiry in this row. Consumer creation is excluded. DESIGN.md now names
R1-R5 and exact clock constants, plus method-specific CLI shapes and workflow
exclusion. PLAN.md records inline implementation. Tests were authored before
their implementations, but no red or green execution is claimed: Sage's Node/test
hold still applies. No test, syntax check through Node, mutant or suite has run.
Code in progress: private file/HTTPS/admin boundary; event-backed durable grants
and generation cleanup; callback-only renewal; broker/CLI/runtime dispatch;
additive reviewed schema support without changing role policy files. New tests
use a fake HTTPS API and generated scratch CA/key. Their fake origin is injected
only through tests; metadata still rejects every HTTP origin, including loopback.
### Consumer gap for Sage's follow-up row
No GitHub consumer, git helper, mirror push or publication is implemented. The
protected `Broker.githubWithToken` callback is the stop point. Gitea's generic
Credentials path has calendar-day dates and fork-specific identity; GitHub tokens
remain protected generation files rather than loading into that unguarded path.
A consumer row must call the protected function and honor expiry/cancellation.
The grant's approval block and README explicitly name this gap.
### Pre-existing test change, Mos authorized
`packages/business/tests/role.test.mjs:209`: exactly 1 insertion / 1 deletion.
Only the regex changes from `/service must be one of/` to `/unsupported.*scopes/`.
The refusal of `{service:"github",scopes:[]}` stays. Reason: sage-3620 authorizes
fixed-scope GitHub declarations; caller-provided scopes now fail on the unknown
key, rather than on an unknown service. Mos mos-1518 (cc Violet, 19:29:15Z) permits
this exact change through Sage. A new-file test in business/tests/github.test.mjs
preserves the closed-service-list check with `not-a-service` and the original
`/service must be one of/` pattern. No other old test has changed.
## Static pass before first implementation push
Additional new tests cover a known token left by an interrupted mint (no adoption,
remote cleanup), unsafe slot modes/symlinks, per-agent busy claim, admin rotate
identity/binding invalidation, fire cleanup/refusal and the lifecycle delete's
first revoke job. No existing fixture or existing test other than the authorized
regex changed. Static review corrected the grant.call method/op field collision
and added revocation sequence binding so an old unspent approval cannot revive a
revoked grant. Both corrections are unexecuted and remain unverified.
No idle GitHub startup sweep is emitted for a business with no GitHub residue.
This avoids changing unrelated runtime evidence. The callback's cancellation
signal is also aborted at the token safety deadline, not only on explicit revoke.
Static dependency inspection found createdCredentials in instance-settings.mjs
would generate an invalid day-based `credentials.github` reference for a new
instance. It now excludes this protected grant declaration, as it excludes host;
a new business/github.test.mjs case pins the human add behavior. This is local
schema integration needed by the new declaration, not new agent grant authority.
## Row198 moving base: 397ef58be (sage-3687, 2026-10-23)
Merged Bushroot's fix head `397ef58bebef2e265e6332bc3ede05de30c4624c`
intact after `ab6417475`. No rebase, amend or force push. His round-3 review
remains open; this is a moving, untested dependency, not acceptance evidence.
Row297's own source/test candidate is still `53a454185`.
Sage sage-3681 allocated the five-new-file first baseline/fix run to T6, about
21:55 UTC, with an explicit CLEAR still required. No Node, tests, browser,
webui build, suites or mutants have run in this session. Consumer stop/gap at
Broker.githubWithToken is accepted by sage-3681. Dewey reviews the row297 delta;
the eventual gate must use the latest intact row198 fix head.
## Static review dewey-0944 / dewey-0946 follow-up (UNTESTED)
Dewey found no source blocker in 53a454185 and confirmed the clean carried delta
at 5ca83173e. Required M1: add a per-path SHA-256 list for the 24 row297 paths,
then report that digest file's own SHA separately to avoid a self-reference.
The list will use git blob bytes at the final head sent for the gate.
Lifecycle-delete coverage now includes the second request completing exactly one
instances.set write after the protected revoke job. New tests are unexecuted.
N1: sweep closes an active generation at its conservative bound with reason
expired, preserving the durable grant for a later renewal. N2: installation calls
require an explicit valid token, with no App JWT fallback. N7: grants.run checks
keys/identifier before approval lookup. Each change has new-file regression
coverage, with Node execution still held for Sage CLEAR.
N3 is documented behavior, retained for Gitea parity: human admin remove can
invalidate cleanup while residues exist; serviceLeft remains until a matching
slot returns or conservative expiry. N4: official API-version source added to
DESIGN.md; public GitHub docs list 2026-03-10 as supported. N5: full event scans
remain acceptable at MVP volume; no cache introduced. N6 for Sage: preserve both
BUILD-LOG.md and docs/SESSIONS.md seat appends alongside refactor's appends when
landing; do not replace either append-only history.
Three additional mutants requested by Dewey have named new-file killers:
- accepting DELETE 401 as revoked: `mutant: closeToken must not count a 401 as proven revocation`;
- treating status 0/5xx as not-issued: `lost mint response stays visible through restart until conservative expiry equality` and `mutant: mint 500/503 is uncertain issuance and stays serviceLeft`;
- dropping recheck's controller.signal.aborted: `mutant: shutdown abort alone prevents a pending generation from applying` (no revocation event, so the signal is the only veto).
No mutant has run; every kill still needs gate evidence.
-221
View File
@@ -1,221 +0,0 @@
# Row 297: Q-B3 answer and proposed GitHub method contract
Violet, 2026-10-23. Issue #1803. Reviewer Dewey. Proposal for review before code.
Source contract: #436b r5 section 10, `agents/megavolt/work/436b/DESIGN.md`
at `1be535aca`. Row 236 is already built; this proposal changes none of its behavior.
Row 198 dependency: merged `14fe7513c`, then `4d8123b0a`, preserving every commit.
## Q-B3: S1-S6
| Item | Answer |
| --- | --- |
| S1 | Agree. Keep `{method, agent, rotateBy, target}`, with `method: "github"` and `target: {projects: [...]}`. Code fixes permissions; the caller cannot name permissions, repository IDs, installation IDs, or an API origin. Resolve projects from reviewed business configuration, and bind the resolved identities in the decision. |
| S2 | Agree. Keep `<configDir>/secrets/grants/<business>/github/admin`, human set only, never an agent service. For GitHub this contains an App RSA private key, not a PAT. Nonsecret metadata identifies the App, installation, account ID, API origin, set time and rotate-by day. One installation per business and method. Only Jason performs the live bootstrap. |
| S3 | Agree. Every network call, including preflight, renewal, verification and remote undo, goes through reserved `action.allowed` `grant.call` then `grant.result`, paired by `callId`. Preserve the grant actor and the kind filter, and use `grantDecision` outside the single consume record. No raw response, key, JWT, installation token, authorization header or token digest enters evidence. |
| S4 | Per method, as section 10 permits. GitHub installation tokens expire within one hour. Recommend a durable approved repository grant, with short-lived token generations renewed only by protected code inside that grant's unchanged binding and rotate-by deadline. Never treat the grant's rotate-by day as a token's expiry. Detailed proposal below. Gitea's mint/issue/settle sequence stays unchanged. |
| S5 | Agree with local-first order. Block new issuance/use and abort the agent's pending generation first; durably remove the active grant next; revoke each known token remotely last. Keep unresolved generations in `serviceLeft` and retry with sweep. An unreturned token cannot be revoked by ID; its bounded expiry must remain visible until elapsed. Do not uninstall or suspend a shared installation to revoke one agent. |
| S6 | Agree. Method-specific checks at admin set, every executor start/renewal, and remote undo. Verify App identity, installation identity/account, non-suspension and the required permissions. Pin repository IDs and verify minted reach across every page. No version-number analogy to Gitea; unsupported responses or absent capabilities refuse closed. |
## Provider facts
Checked against official GitHub documentation on 2026-10-23; these are documentation
findings, not live probes. No credential file was read and no authenticated API call was made.
- [Create an installation access token](https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app):
App JWT authentication; explicit `repository_ids` and `permissions` narrow issuance.
Omitting either uses the installation's broader defaults. Tokens expire after one hour.
Token format is opaque; no fixed-length or 40-character assumption.
- [Generate an App JWT](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app):
RS256 with bounded `iat`/`exp`, at most ten minutes ahead, authenticated with Bearer.
- [Installation endpoints](https://docs.github.com/en/rest/apps/installations):
`GET /installation/repositories` reports token-accessible repository IDs and total count.
`DELETE /installation/token` revokes the token used to authenticate that request.
This does not provide Gitea's recorded-token-ID deletion or `GET /token` identity contract.
## Approaches and recommendation
1. Mint once per Approve, store with its actual expiry, then require another Approve.
Smallest lifecycle but forces another human approval within an hour of each use.
2. Durable approved grant plus protected renewal, recommended. The approval names the
agent, exact project/repository binding, fixed permissions and latest renewal date.
Token expiry is separate. This needs renewal, interruption and revoke-race coverage.
3. A human PAT or copied Gitea account/user lifecycle is not proposed. It does not supply
the App issuance contract this brief calls out, and GitHub is not an account-creation
API equivalent to Gitea's site-admin path.
The recommendation is Violet's proposal, not an owner decision. Q-B3 must be read by
Dewey and Megavolt before implementation. Sage must settle the scope points below.
## Proposed per-method S4
The durable authority is the approved binding, not a long-lived token. Raise is chief-only;
Approve and admin-slot writes retain their existing human gates. Preflight checks and
binding recheck happen before consume, under an agent claim. Only consume has `decision`.
At grant time, resolve the configured projects to immutable repository IDs and bind the
definition, relevant role/project configuration and admin metadata. Show the App and
installation identity, repositories, fixed permissions, grant deadline and automatic
renewal behavior in the human approval block. Permission proposal: `contents: write`
plus GitHub's implicit `metadata: read`; no workflow, administration, issue or PR write
permission. Adding any permission requires a reviewed method change.
Before every generation: check the grant is active, unchanged, unexpired, the agent is
still eligible, and processing/revocation does not prohibit the operation. Re-run the
capability floor. Write the mint intent before POST and the result after it. POST always
includes the exact nonempty repository ID list and the fixed permission map. Verify the
returned expiry, permissions and all token-accessible repositories before making the
generation usable. A missing/invalid/past expiry or wider reach revokes the candidate and
refuses. Never use a stale token during a failed renewal.
Use the actual provider expiry as a timestamp with a safety margin. Token replacement
must be atomic with respect to revoke: a revoke that wins while mint/verify is pending
prevents publication of that candidate. Only one generation per agent runs at once.
New generations cannot widen the original approved repository IDs; changed configuration
requires a fresh decision. The App private key and JWT never enter the agent credential
store, and the worker never gets the App key or authority to renew.
There is no claim that a stored token can be identified by a Gitea-style token ID.
On restart, an interrupted generation is held out of use. Any recoverable token is
verified/revoked through its own installation authentication; an ambiguous generation
is not adopted as an applied grant. Where the token was minted but its response/file was
lost, record unresolved remote reach until the documented maximum lifetime has elapsed.
An unanswered mint uses a conservative expiry bound that includes the request deadline;
the implementation must document the clock/response assumptions rather than claim remote
revocation succeeded. A new generation never hides an older unresolved one.
The concrete token storage and consumer hook are still scope decisions below. A generic
`Credentials.issue` using only `YYYY-MM-DD` would lose the token lifetime and is unsuitable
without a method-specific timestamp guard on every use and restart load.
## Scope points for Sage, with reviewer input
These are unresolved integration boundaries found in the existing implementation:
1. Repository authority and identity: `SERVICES`, credential dates, role identity and
publish code currently implement Gitea/Vikunja/host, not GitHub. Recommend GitHub App
access to the configured exact repositories, with the App bot as service identity;
no creation of GitHub user accounts or automatic forks. If row 297 must reproduce
per-agent forks, that needs a separate repository/identity contract before code.
2. Renewal consumer: recommend protected on-demand renewal for the GitHub credential
consumer, with timestamp checks and no worker mint endpoint. Confirm whether row 297
owns that consumer integration (including GitHub git publication), or only the grant
executor/management surface. A stored expiring token with no guarded consumer is not
a complete renewal implementation.
## Acceptance and proposed test manifest
Fake API only; never a real GitHub token or a call to github.com/api.github.com. Bind a
loopback fake explicitly in tests; production origins come only from the admin contract.
Prefer new test files. No pre-existing test changes without Mos's OK through Sage.
Mirror row 236 X-G1 through X-G15 where meaningful: binding refusals and no writes;
admin floor/write-on-refusal; approval end to end; foreign/rebound identities; failure
matrix and revoke leftovers; interruption/restart; approval drift; call/result ordering
and secrecy; admin replacement invalidation; token replacement ordering; human gates,
single consume and busy; paginated exact reach; evidence reservation; fire/delete;
look-alike evidence ignored. Explicitly mark Gitea-only user creation/disable, version
floor, token-name and token-whoami cases as replaced by App/installation identity,
fixed permissions, expiry and generation checks, not silently dropped.
Additional expiry cases: expiry before use; past/malformed/missing or excessive lifetime
in mint response; controlled-clock renewal; renewal refusal after grant rotate-by;
changed installation/repository/definition/admin binding; failed renewal with no stale
use; restart with an interrupted mint; revoke during pending mint/verify; unknown remote
token visible until its conservative expiry bound; no shared-installation deletion.
Mutants mirror these claims: omit explicit IDs; omit permissions; skip identity/floor,
binding/expiry/reach check; read only one page; publish before verify; mint before intent;
result before call; skip claim; revive after revoke; keep expired token after renewal
failure; renew after grant deadline; clear unresolved reach early; perform remote undo
before local revocation; bypass human/reserved-evidence gates; log a provider secret.
Full touched suites, every `scripts/test-*.sh`, and mutants wait for Sage's CLEAR. Dewey
approves the final manifest. Row 297 lands only after row 198's own gate passes.
## Build design: rulings and review inputs (2026-10-23)
Sage sage-3620 accepts App bot access to exact configured repositories, no accounts
or forks, and row297 ownership of protected mint/renewal/evidence/revoke/expiry.
New GitHub mirror push, publication and credential helpers are excluded. Stop on
the protected side where the Gitea consumer path cannot carry GitHub safely; Sage
files the consumer gap. Dewey dewey-0931 and Megavolt megavolt-0206 read Q-B3 and
report no design blocker. Approach 2 is selected under these rulings.
### Binding and protected interface
Proposed additive configuration: a definition declares `{service: "github"}` with
no caller scopes; the business role declares `github: {projects: [...]}`, and each
such project has `remote` plus positive safe-integer `githubId`. These declarations
are separate from Gitea `identity` and fork-based `publish`. Unknown keys, including
account/fork requests, refuse. No role policy file or live configuration is changed.
The grant binding stores the exact project IDs, repository IDs and remote names,
role/definition digest and admin metadata digest. Renewal compares those immutable
snapshots to current configuration; it never adopts configuration changes as new
authority. GitHub scope approvals remain human-only (no Q228 scope fast path).
Expose renewal only as a trusted host function `withToken(business, agent, fn)` in
protected code, passing `(token, signal)` to the trusted callback. No worker or
socket verb returns a token or mints one without a durable approval. Token files
are generation-specific protected files, never evidence. Do not load these into
`Credentials`, whose calendar-day guards and Gitea helpers cannot enforce expiry.
That is the consumer gap: publication/credential-helper wiring needs another row.
### Dewey R1-R5 and Megavolt notes
- R1: Before paginated verification, require exact mint `permissions` equality to
`{contents: "write", metadata: "read"}`, `repository_selection === "selected"`,
and returned repository IDs equal to the bound set, without duplicate IDs.
Missing fields, subsets and supersets refuse and revoke the candidate. Mutants
accept extra permissions or extra repositories; both must be killed.
- R2: Production metadata accepts normalized HTTPS origins only; no HTTP or test
bypass. Tests run a fake HTTPS GitHub API with an injected scratch CA and optional
transport, following row236. A named admin-set test rejects HTTP before any call.
- R3 C1 replacement: pinned API version and required App/installation/token-response
shapes, refusing unavailable/unsupported capabilities. No Gitea version comparison.
C2 replacement: RSA key's signed JWT authenticates the configured App ID and the
installation belongs to it and is not suspended. C3 replacement: installation
permissions cover the fixed map; the issued token's map must equal it exactly.
C4 replacement: App ID, installation ID and account ID equal the slot metadata.
No claim that the App is a dedicated site-admin account with an empty repo list.
- R4 constants: `TOKEN_LIFETIME_MS = 3_600_000`, `CLOCK_SKEW_MS = 60_000`,
`TOKEN_MARGIN_MS = 60_000`, `CALL_TIMEOUT_MS = 15_000`. A generation is usable only
before `expiresAt - TOKEN_MARGIN_MS`. Mint expiry cannot exceed the request start
plus lifetime plus skew. Unknown mint reach remains until request start plus
request timeout plus lifetime plus skew. Equality at each deadline is expired.
These assume provider expiry is bounded by the documented lifetime and skew;
a request that never returns has no claim of proven remote revocation. Each
boundary has a controlled-clock test. JWT uses `iat = nowSeconds - 60` and
`exp = nowSeconds + 540` (less than the ten-minute maximum).
- R5: A changed project ID, remote, allowed-project list, role definition or admin
slot between generations refuses renewal, even if the replacement configuration
would independently grant access. Re-approval is required.
- Admin CLI: separate `--app-id`, `--installation-id`, `--account-id`, `--url`,
`--rotate-by` shape. RSA private key on bounded stdin; never misuse `--account`
for an installation ID. Rotation replaces a Jason-provided App key on stdin;
this method does not pretend it can manufacture/delete GitHub App private keys.
- Approval block and README explicitly exclude `.github/workflows/` writes and
account/fork creation. No workflow, issue, PR or administration permission is
granted. All fake JWT claims use the injected clock.
### Review follow-up (dewey-0944 / dewey-0946)
`API_VERSION = "2026-03-10"` is pinned from GitHub's official
[API Versions](https://docs.github.com/en/rest/about-the-rest-api/api-versions)
page, checked 2026-10-23. It lists that version as supported and specifies the
`X-GitHub-Api-Version` request header. This is a public-document check, not a
live API probe; fake tests do not reach GitHub.
`grants sweep github` may close an expired active generation once its conservative
`until` bound has elapsed. It deletes that generation's private file and records
`grant.token.closed` with reason `expired`; the durable grant stays renewable.
It must not close a live active generation early. Installation endpoints now
require an explicit valid installation token and cannot fall back to an App JWT.
Manual run validates keys and identifier before any approval lookup.
Admin remove intentionally retains the human's existing ability to remove the
local slot while grants or residues exist (Gitea parity). This invalidates future
use/renewal, but known residue cannot be remotely retried until a matching slot
returns; expiry still closes it at its conservative bound. Status/sweep do not
claim remote revocation in that case. The App itself and private keys are not
removed at GitHub. Event projection is a full read at MVP volume; no cache is
introduced whose invalidation could weaken revocation.
-99
View File
@@ -1,99 +0,0 @@
# Row297 verification manifest (UNTESTED)
Candidate pending tests. Dewey approves this manifest; Sage owns CLEAR and gate.
All listed tests are fake HTTPS API or scratch configuration. No live key/token,
no GitHub service contact, and no public consumer is installed. No Node/test run
has occurred in Violet's session. Static `git diff --check` is the only check run.
## Files and permission boundary
- New protected modules: bus/src/github-api.mjs (HTTPS, JWT, App slot and floor),
github-files.mjs (private atomic files), github-grant.mjs (event projection,
approval binding, renewal, generation files, revoke/recovery).
- Broker/runtime add the method to existing grant gates and lifecycle revocation;
no token-returning or renewal socket verb. Existing Gitea code paths remain.
- Business role/instance parsing: a role declaration `{service:"github"}` takes no scopes; role.github
names allowed projects; project.githubId pins its provider identity. Generic
token-reference schema stays closed to github. No role JSON policy is changed.
- CLI github-grants.mjs handles method-specific admin identity flags and bounded
stdin. grants.mjs dispatches the method without changing Gitea flag shapes.
- New tests: bus fake-github-api.mjs, github-api.test.mjs,
github-grant.test.mjs, github-broker.test.mjs; business github.test.mjs;
cli github-grants.test.mjs. README and packet/build records document the limits.
- One pre-existing test change: business/tests/role.test.mjs:209, exactly 1/1,
regex only. Mos mos-1518 authorization and replacement closed-list test are in
BUILD.md. No other pre-existing test is edited by Violet.
## Row236 X-G correspondence
| Row236 claim | GitHub replacement / named coverage |
| --- | --- |
| X-G1 binding, no writes | grant binding rejects scopes/account/fork keys; business binding refuses missing declaration/project/ID; R5 drift refuses before consume |
| X-G2 C1-C4 | C1 pinned API/required response shapes; C2 signed App authentication and App/installation identity/non-suspension; C3 installation permissions cover the fixed map; C4 App, installation and account IDs match metadata. API tests name identity, suspension, floor, HTTP and RSA refusals |
| X-G3 approved end to end | broker chief raise, human Yes, exact durable grant, protected callback, revoke, fake token gone |
| X-G4 foreign user | No user account lifecycle. Replaced by App/installation/account identity, exact configured repository IDs/names, no account/fork request |
| X-G5 failures/leftovers | invalid mint candidate revoked; remote delete failure is serviceLeft; failed renewal never calls consumer; revoke wins pending renewal |
| X-G6 restart | active generation needs its recorded binding/timestamp; lost response remains until controlled bound; interrupted call without result is visible; no interrupted generation adoption |
| X-G7 binding drift | project ID, remote, allowed-project list, definition and admin metadata between generations; preflight refusal unspent, restart spends interrupted |
| X-G8 calls/secrecy | intent and call visible while fake receives request; result after call; only consume has decision; no key/JWT/token in evidence |
| X-G9 admin rotation | Human replacement key, same App/installation/account/origin, setAt changes binding; no GitHub private-key creation/deletion claim |
| X-G10 replacement | new generation active before old token DELETE, old token removed afterward |
| X-G11 callers/claim/spend | human-only admin/run; chief-only raise; no socket renewal/token; old approval invalid after revoke; single consume across renewal |
| X-G12 reach | exact mint IDs/permissions/selected mode, paginated GET through page 2, repository names checked; wider GET reach refuses and deletes candidate |
| X-G13 reserved namespace | existing grant actor/trigger retained; new direct human insert refusal test; existing grant-migration tests cover shared reservation |
| X-G14 fire | GitHub local revoke; fire completes only after remote cleanup; remote leftover blocks fire. No shared installation suspend/uninstall |
| X-G15 evidence kind | a look-alike applied record under action.refused cannot establish a grant |
Gitea-only version ordering, user nonce/adoption/disable, token-name minting,
admin token minting and token-ID whoami settlement are replaced, not copied.
No Gitea account or fork API is used by the GitHub method.
## Dewey R1-R5
R1 tests mint permission extra/missing/higher keys, selected mode, repository
extra/missing/duplicate IDs before paginated verification. R2 tests admin HTTP
refusal with no loopback exemption; fake HTTPS injects a scratch CA. R3 maps C1-C4
above. R4 constants are lifetime 3,600,000 ms, skew/margin each 60,000 ms, request
15,000 ms; JWT clock claims, margin equality, excessive expiry and lost-response
bound equality have controlled-clock tests. R5 tests configuration changes between
generations, preserving the original binding rather than granting from new config.
## Runs requested, not yet authorized/executed
1. New-file red/green or baseline diagnostic run: `node --test` on the five new
test files. Tests were written first; the test hold prevented observing red
before implementation, so no TDD execution evidence is claimed.
2. Touched suites: business, bus, cli. Full every `scripts/test-*.sh` on Sage's gate.
If the gate includes webui, roster's required editor build applies; Violet
has not run a webui build or touched row198's inherited webui tests.
3. Mutants: run on a disposable copy only after a green baseline. Every survivor
is an open failure, not a waived requirement. Planned mutations: HTTPS origin,
RSA restriction, App/installation/account identity, permission floor, explicit
mint IDs/permissions, exact response maps/selected mode/IDs, expiry ceiling,
margin boundary, durable config/admin digests, revocation epoch, expiry bound,
intent/call ordering, local-before-remote revoke, evidence kind, auto-dispatch,
lifecycle inclusion and unknown-token retention. Actual kill evidence is pending.
## Remaining evidence gaps before acceptance
No test has run, so no pass count or mutant kill is claimed. New tests now cover
a known interrupted token, unsafe file modes/symlinks, busy claim, admin rotate
and the lifecycle-delete revoke job. Runtime verification may require fixes or
additional new-file coverage; lifecycle-delete now includes its second request
completing after the revoke job. These are in-scope gaps, not deferred acceptance. The consumer gap is separately
ruled out of scope in sage-3620 and recorded in BUILD.md.
## Byte manifest (Dewey M1)
`candidate-manifest.sha256` lists SHA-256 of the git blob bytes of the 24 paths
reviewed for row297, including this MANIFEST.md. It excludes itself to avoid a
self-reference. Its own SHA-256 and the final pushed head are reported together
in Violet's review handoff message. After any byte change, regenerate the list
and report a new checksum before the gate. Inherited row198 paths are excluded.
The three extra mutants from dewey-0944 are required: accept DELETE 401 as
revoked (killer `mutant: closeToken must not count a 401 as proven revocation`);
count 0/5xx as not-issued (lost-response test and `mutant: mint 500/503 is uncertain
issuance and stays serviceLeft`); drop the controller abort check (`mutant:
shutdown abort alone prevents a pending generation from applying`). All kill
evidence remains pending Sage CLEAR.
-69
View File
@@ -1,69 +0,0 @@
# GitHub grant method implementation plan
Goal: implement row297's protected GitHub App grant and renewal lifecycle.
Architecture: a GitHub transport/slot module and event-backed executor, integrated
through the broker's existing human grant gates. No GitHub consumer is added.
Stack: existing Node ESM, node:crypto/node:https/node:fs, append-only bus events.
Spec: DESIGN.md, including sage-3620, dewey-0931 and megavolt-0206.
Execution: Violet inline; Dewey reviews the final manifest. No subagent dispatch.
## Global constraints
Fake HTTPS API and scratch CA only. No credential files, live token, real API call,
Node or test execution before Sage CLEAR. No old-test edits without Mos approval.
Preserve row198 commits; push only violet/*; no amend or force push. No schema
migration for grant state: use reserved grant evidence with the existing actor.
## Review focus
- Lost mint responses remain visible until conservative expiry; never claim undo.
- Revocation racing renewal cannot publish a late token or restore the grant.
- Malformed provider responses cannot authorize a wider token.
- Restart cannot adopt an interrupted generation or changed configuration.
- Refusals and audit records cannot leak RSA/JWT/token values.
## Task 1: configuration, HTTPS transport and admin slot
Files: new bus github-api.mjs and github-files.mjs; modify business role/business
parsing; new business github.test.mjs and bus github-api.test.mjs/fake-github-api.mjs.
Interfaces: checkGrant, binding configuration checks, githubApi({configDir, ca,
transport, now}), adminMeta/setAdmin/removeAdmin/checkAdmin/mint/repos/revoke.
- [ ] Write tests first for HTTPS-only metadata, RSA-only input, App/installation/
account identity, suspension, permission floor and fixed mint response equality.
- [ ] Run red tests on Sage CLEAR; record the exact failure, no invented red claim.
- [ ] Implement strict additive config and slot/transport with no raw response audit.
- [ ] Run new tests green on CLEAR; no old tests changed.
## Task 2: durable executor and renewal
Files: new bus github-grant.mjs; new bus github-grant.test.mjs.
Interfaces: configure({store,business,event,approved,consume,grantable,working});
raiseBinding, run, withToken, revoke, sweep, status, reconcile, idle, close.
The broker supplies private approved/consume/event callbacks, not a worker verb.
- [ ] Write lifecycle/expiry/recovery tests before implementation, including all
R1-R5 cases, revoke races, config drift and lost-response bounds.
- [ ] Run red tests on CLEAR, then implement evidence projection, generation files,
exact reach, expiry-aware guarded use, local-first revoke and retryable cleanup.
- [ ] Verify no untrusted token-returning endpoint and no token in any evidence.
- [ ] Run green and mutant checks on CLEAR; record every unexecuted check honestly.
## Task 3: broker, runtime and CLI integration
Files: broker.mjs, runtime.mjs, cli grants.mjs; new github-broker.test.mjs and cli
github-grants.test.mjs; bus/cli README edits. Existing Gitea behavior stays intact.
- [ ] Write tests first for chief raise, human approval, rejected caller scopes,
admin human-only, run claim, status/revoke/sweep, fire/delete and restart.
- [ ] Dispatch github before existing Gitea branches; connect reserved writer and
consume, automatic run on human yes, reconcile, lifecycle revoke and shutdown.
- [ ] Add method-specific CLI admin flags and RSA stdin; never print secrets.
- [ ] Document fixed scope, workflow/account/fork refusals and consumer gap.
- [ ] Request CLEAR for touched suites and every scripts/test-*.sh; run only after
granted. Present actual diff/numstat, manifest and mutants to Dewey and Sage.
## Acceptance ledger
Static checks can run without Node. All red/green runs, suites, mutants and gate
are currently unexecuted. No success claim is implied by this plan.
@@ -1,24 +0,0 @@
6b54b05eeabaee822ad9ec96e63ba007c6d52b7e66100ab4dc051fa414af103f BUILD-LOG.md
e9dcab5e3125660f177989e6bf726da596149aa237c4f179fa24c13aefc009e1 agents/violet/work/queue-297/BUILD.md
156db930b2e455c7dc8deffdd48fd832d898b32e5c8136f014bd4af84f50f526 agents/violet/work/queue-297/DESIGN.md
99c99d4ca9bdf1397869247c454f45fd0c3fba8fb47b0b9bd424e8532b7d2453 agents/violet/work/queue-297/MANIFEST.md
929d9a5fc28bdcdfd5c7db5cb818186633954d5f60ad2a03e129d062bcb4f725 agents/violet/work/queue-297/PLAN.md
d599b0f0d4c248fba5a32b4f04022ac4676a4cb4029d32d29e0ab1f10ade9162 docs/SESSIONS.md
bed3be579c94c16f6ad840b298f589339e1e0d58e3fca13571e2262a3502aace packages/bus/README.md
e46abbba0514be8bad8c53ec3a0b19133746bb96318c13923d66e9f54c6b2a34 packages/bus/src/broker.mjs
1f09e6a8e3b814a475fb1f7a91bd6ee670e08d7c76399212134e107a2ec4b499 packages/bus/src/github-api.mjs
2f3fce1244446c4be93cdc8ab744de710758bdebb1106e09badd25fa57c355ff packages/bus/src/github-files.mjs
bca3836d62525620d9af372c0c2039e3405579290e8919a1f4f6a35baafee6da packages/bus/src/github-grant.mjs
83a2d294b87e900fcb8357724941be1475ce5ef6afde237a149eb5c138f087b2 packages/bus/src/runtime.mjs
c6e6884471a884663db9446f662669d0977009f6f2f24f2a388e29104c9cb960 packages/bus/tests/fake-github-api.mjs
9e0f43e5b25c3a77d746dbd1b956ff767406548fda2528cab51c8d5b38157e5b packages/bus/tests/github-api.test.mjs
8c78d2ad8aad12632ee40eea8212fd360281bc11b4ae900bf3e38f4618c5d6db packages/bus/tests/github-broker.test.mjs
544f15f07cfaa76394da8618d63573ef91a7c0ebf3f65a6f39339c015e4f96a5 packages/bus/tests/github-grant.test.mjs
87dc329f40f5783a4c2e85c9a1d8c94b6dc4889cdce7191e79af852ab251fac7 packages/business/src/business.mjs
c022a8aa32d84a189b585696b207001ba690660ed1c83c6038be4031703550a4 packages/business/src/instance-settings.mjs
a68518d83a039ecc82ddb4cfbbc6234df8eac384acc78499c94b0d0945a7717b packages/business/src/role.mjs
956981b7ab8f633054831a00133d44a43b5bf9be7c6fd6a326ce20b769c9f822 packages/business/tests/github.test.mjs
445c224530f5545fd4e1e734c7b36c407db6f1b182b6c6ef65e5c9d91fd16ac0 packages/business/tests/role.test.mjs
410b3c3e50c476de39720b0906dcf077739d029e293b444cc6e4a13bb24a9031 packages/cli/src/github-grants.mjs
9fd3589258b83e7cf2c5828811251d0d0a24087a8bded926a44144e3b4a07438 packages/cli/src/grants.mjs
2886fbc1c4c6f0dc22d365e76641f13b7e31f46123b9e7b3e280d2ff79ea52e9 packages/cli/tests/github-grants.test.mjs
+2 -2
View File
@@ -1593,5 +1593,5 @@ are never rewritten or removed; corrections are new entries.
2026-10-23T17:48:43Z | Scrooge (629b6cd3) | Row 252 Console inbox Approve and Deny (#1753) | Rebased onto 306 landing as 580037105 (range-diff all =), slot run bus 907/0 webui 410/0 cli 943/0, in-review revs 3608/3609, Webby r1 APPROVE, J1-J19 specs to Sage; Sage landed 45ea3fa0d (done rev 3616). Part 1 mutant results were never in BUILD.md; they went to Sage by T3 (scrooge-0252-mutant-specs).
2026-10-23 | Sage (1ef1e4f8) | Rows 308 and 252 landings, 571 Connections design amendment, Vikunja 597 | 308 (PRD 1.17, #1815) landed 30e6bb680 and 252 (#1753) landed 45ea3fa0d (replay of 580037105 onto 30e6bb680, range-diff all =), each after a full gate on the landed tree (252 also J1-J19, J11c and J18 equivalent); Gyro's 571 amendment (Fenton APPROVE) landed e8bb13df8; Vikunja 570, 435 and 597 done, canonical webui esbuild 0.28.2; queue revs 3589-3616 committed 59f013247
2026-10-23 | Sage (1ef1e4f8) | Vikunja 571 W build rows; rows 13, 42, 257 and 289 check | Filed the design's ten W rows as queue rows 309-318 (revs 3617-3626, owners set to unassigned 3627-3636, queue commit 293b42fee pushed); Vikunja 571 done (comment 994). Row 13's candidate fd72d2689 is on refactor, gate is Monday's row 7 ledger run; 42 paused under mos-1184; 257 post-v1; 289 waits on 214 and 288. Slot released 17:56Z.
2026-10-23 | Violet (6229eb89, Codex gpt-6.1-sol, mos-1498/Q245) | Row 297 GitHub grant method #1803 | Registered in violet/row297; merge-chained row198 14fe7513c then 4d8123b0a unchanged. Q-B3 S1-S6 proposal prepared for Dewey/Megavolt and Sage; no product code, credentials, node or tests; shared runs require CLEAR.
2026-10-23 | Violet (6229eb89) | Row297 #1803 protected GitHub grant candidate | Q-B3 read by Dewey/Megavolt, sage-3620 scope applied. Candidate and fake HTTPS tests written, no Node/tests run; one Mos-authorized old-test regex 1/1 (mos-1518). Review manifest and consumer gap recorded. Verification/gate remain pending Sage CLEAR; no acceptance or landing claim.
2026-10-23 | Beakley (88b97f65, Codex gpt-6.1-sol) | Row 217 #1718 takeover under Q245 | Registered beakley/row217; read brief/design and standing Sage rulings; awaiting Launchpad ref before implementation. Queue next refused linked worktree; reported to Sage. Untested, no Node/test run pending CLEAR.
-1
View File
@@ -586,4 +586,3 @@ git history + Gitea issues.
- 2026-09-26 (sage, lead): Jason's ruling: Sage leads the project, Darkwing is a collaborating seat, more seats to follow, development stays in T3 for now. The fleet Sage seat that took Invoice Ninja coordination outside Jason's instructions is being decommissioned; moving work onto the new stack is the fix. Suites all green. Rows 24 and 25 pushed (1685deb4..43d7574d). Open: about 11 days of uncommitted agent work in the tree (row 16 launch files, row 22, #1512, WUI evidence), 23 untracked aws-* skill directories in skills/ from 2026-09-21, and Jason's target for the next phase.
- 2026-10-13 (sage, lead): Jason's PM re-test on 664199f7 is finished (Mos; PM run rcc0f72be8b17; Vikunja #228). Paging, the Board's held roles, the Messages page and the #229 loop (coder push 010d30cb, review, approve, PM close) all worked; #230 and #231 are done, #232 stays open for the secrets-path probe. Two faults checked and dropped: the reviewer did run the tests before its verdict, and the PM's clone did hold the row 95 Board code. Two filed: row 104 (#1598) seat git identity, row 105 (#1599) a review request says whether a reviewer is running. Rows 96, 101-105 briefed; row 85 released to build. Row 85's live steps, any bus restart and row 102's public/ change each need Mos's go. Row 100 waits on Jason (Vikunja #257). Formatting, Jason's direction: no formatting row now. Chat output will be formatted in the TUI and WebUI the way chat interfaces usually do it, made our own; T3 Code's chat is his example. Carry it into the TUI and WebUI chat work.
- 2026-10-22 (bushroot, row 198 r3): the board runs in-process in the Console (webui, port 7332); control-board `serve` is retired and exits 2, and the Console refuses `--board`. Docs: packages/webui/README.md and packages/control-board/README.md now give the Console start and drop the 7331 serve text. Built on bushroot/row198-r3 from aca16404c; not run, awaiting Sage's CLEAR.
-56
View File
@@ -1071,59 +1071,3 @@ TERM concurrently, then KILL after five seconds and observe for two more.
Unknown targets remain visible and prevent resume. Existing run evidence is
not synthesized or rewritten. First activation stops old processing before
trusted initialization and aligns the copy and image in the human's window.
### GitHub App grants (row 297)
The `github` method uses the same chief raise, human Approve, reserved `grant.call`
then `grant.result`, and local-first revoke boundary as Gitea. Its tokens expire
within an hour, so the durable grant authorizes protected renewal up to its
`rotateBy` day. Every generation remains bound to the original configuration.
A changed definition, allowed-project list, repository ID/remote or admin metadata
requires another human decision. No account or fork is created.
A role definition declares `{ "service": "github" }` without scopes. Its business
role names `"github": { "projects": ["app"] }`, and that project supplies its HTTPS
`remote` and positive integer `githubId`. No generic `credentials.github` reference
is accepted. Code fixes the token permissions to `contents: write, metadata: read`,
with exact selected repository IDs. Workflow-file writes (`.github/workflows/`),
PR/issue writes and administration are excluded. A workflow push refusal is intended.
Jason alone sets the App private key, through bounded stdin, after creating and
installing the App. Agents do not perform this live step:
```sh
mosaic grants admin set github --url https://api.github.com --app-id APP_ID \
--installation-id INSTALLATION_ID --account-id ACCOUNT_ID --rotate-by YYYY-MM-DD --business BUSINESS
mosaic grants status github --business BUSINESS
mosaic grants revoke AGENT github --reason REASON --business BUSINESS
mosaic grants sweep github --business BUSINESS
```
Admin rotation takes a replacement RSA private key on stdin with `admin rotate
github --rotate-by DATE`. It preserves App/installation/account/origin identity and
invalidates old bindings. GitHub App key creation and deletion remain Jason's own
GitHub administration step. Admin remove removes the local slot; it does not claim
to delete the App or remotely revoke its private keys.
Only trusted protected code can call `githubWithToken(business, agent, fn)`. It
checks approval/configuration/expiry, renews when required, and passes the token
and cancellation signal to that trusted callback. No socket verb returns a token
or exposes renewal. Tokens stay in private generation files outside run evidence;
RSA keys, JWTs and token values are never audit fields. Revoke blocks use and
renewal before its remote undo. An interrupted mint with no recoverable token value
stays visible in `serviceLeft` until its conservative expiry bound; a new token
does not hide it. `sweep` retries known tokens and records expiry cleanup.
Consumer gap (sage-3620): this row supplies the protected method and renewal
function, but adds no GitHub mirror push, publication or credential helper. The
existing Gitea credential path has day-level dates and Gitea fork identity; those
cannot safely carry a one-hour GitHub token. GitHub publication therefore needs a
separate consumer row and is not enabled by approving this method.
`sweep` also closes an active generation's private file after its conservative
expiry bound, recording reason `expired` while keeping the durable grant. A
later guarded use renews from the same unchanged approval. Before that cleanup,
status can show an expired generation; its timestamp guard still refuses use.
Removing the admin slot while grants/residue exist invalidates renewal and stops
remote cleanup until a matching slot is restored or the conservative bound
elapses. Remove does not claim remote revocation or delete the App's keys.
+136 -117
View File
@@ -203,15 +203,26 @@ const canonical = (x) =>
Number.isFinite(Date.parse(x)) &&
new Date(x).toISOString() === x;
export class BusError extends Error {
constructor(code) {
// C2-06 (sage-3599): only the citation guard's refusals carry a reason, and the envelope passes it on.
constructor(code, reason) {
super(code);
this.code = code;
if (reason !== undefined) this.reason = reason;
}
}
const fail = (code) => {
throw new BusError(code);
};
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
// C2-06 (design 5.2, B7): the page store keeps a session's last 20 broker fetches, 200 KB of text each.
const PAGE_COUNT = 20;
const PAGE_BYTES = 200 * 1024;
// C2-06: the guard's check event takes an id derived from the message's, so a thread read finds it
// through the unique index on events.id (there is none on subject).
const checkEventId = (mid) => {
const h = createHash('sha256').update('message.checked:' + mid).digest('hex');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
};
const text = (x, max = 4096) => typeof x === 'string' && x.length > 0 && x.length <= max && !x.includes('\0');
const id = (x) => text(x, 160) && /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/.test(x);
function keys(x, allowed, required = []) {
@@ -389,6 +400,11 @@ export class Broker {
#credentialVerbs;
#project;
#library;
// C2-06 (design 5.2, B7): cap -> the session's kept web.fetch pages, in a library business only.
// In memory, dropped with the session.
#pages = new Map();
// C2-06: { s, check } while a guarded message.send or a thread.messages re-enters request().
#guardPass = null;
#usage;
#limits;
#harnessOn;
@@ -415,7 +431,6 @@ export class Broker {
// Row 236 (#436b): giteaGrant and giteaKeys from gitea-grant.mjs, or null, and the remote halves of
// section 5's restart rows and of revokes a restart cut short, for reconcileGitea.
#giteaGrant;
#githubGrant;
#giteaKeys;
#giteaUndo = [];
// Row 281 (#1786, d6 section 3b): each tracked Gitea promise and the business/agent it acts for, or
@@ -440,7 +455,7 @@ export class Broker {
#clock = 0;
#processing;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null, giteaGrant = null, giteaKeys = null, githubGrant = null, access = null, accessStamp = null, compactions = null }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
@@ -467,16 +482,6 @@ export class Broker {
this.#hostKeys = hostKeys;
this.#giteaGrant = giteaGrant;
this.#giteaKeys = giteaKeys;
this.#githubGrant = githubGrant;
githubGrant?.configure({
store,
business: (business) => this.#business(business),
event: (business, body) => store.transaction(() => this.#grantEvent(business, body, body.agent ?? null)),
approved: (business, decision) => this.#approvedGrant(business, decision),
consume: (business, decision) => store.transaction(() => this.#consumeGrant(business, decision, 'github')),
grantable: (business, agent) => this.#grantableAgent(business, agent),
working: () => { this.#processingWork(); return true; },
});
this.#access = access;
this.#accessStamp = accessStamp;
this.#compactions = compactions;
@@ -1414,7 +1419,10 @@ export class Broker {
this.#launcherReleased.delete(key);
this.#accessRevoked.delete(key);
for (const [cap, s] of this.#sessions)
if (!s.human && !s.reader && s.business === record.business && s.run === record.run) this.#sessions.delete(cap);
if (!s.human && !s.reader && s.business === record.business && s.run === record.run) {
this.#sessions.delete(cap);
this.#pages.delete(cap);
}
return this.#store.transaction(() => {
const h = this.#own(session);
const released = h?.op === 'claim' && h.holder_run === session.run;
@@ -1496,6 +1504,7 @@ export class Broker {
}
disconnect(cap) {
this.#sessions.delete(cap);
this.#pages.delete(cap);
}
identity(cap) {
const s = this.#session(cap);
@@ -2034,16 +2043,6 @@ export class Broker {
for (const m of this.#openRevokes(business)) this.#revokeUndo.push({ business, ...m });
// Row 236 (#436b r5 section 5): the Gitea grants' local part.
if (this.#giteaGrant && this.#giteaKeys) ended.push(...this.#giteaReconcileLocal(business));
if (this.#githubGrant) {
this.#githubGrant.reconcile(business);
for (const decision of this.#openGrantDecisions(business, 'github')) {
this.#store.transaction(() => {
const g = this.#consumeGrant(business, decision, 'github');
this.#grantEvent(business, { action: 'grant.failed', method: 'github', grantDecision: decision, agent: g.agent, code: 'interrupted' }, g.agent);
});
ended.push(decision);
}
}
this.#interruptedJobs(business);
}
return ended;
@@ -2223,8 +2222,6 @@ export class Broker {
for (const d of open) if (d.grant.method === 'host') hosts.add(d.grant.host);
for (const host of [...hosts].sort()) set.push({ label: 'host:' + host, kind: 'host', host });
if (this.#giteaPending(business, agent, open)) set.push({ label: 'gitea', kind: 'gitea' });
if (this.#githubGrant && (open.some((d) => d.grant.method === 'github') || this.#githubGrant.pending(business, agent)))
set.push({ label: 'github', kind: 'github' });
if (this.#hostGrant)
for (const { host } of this.#store.all('SELECT host FROM hosts WHERE business=? ORDER BY host', business)) {
if (hosts.has(host)) continue;
@@ -2243,10 +2240,9 @@ export class Broker {
.map((d) => ({ id: d.id, raise: this.#raised(business, d.id) }))
.filter(({ id, raise }) => {
const g = raise?.grant;
if (g?.agent !== agent || (g.method !== 'host' && g.method !== 'gitea' && g.method !== 'github')) return false;
if (g?.agent !== agent || (g.method !== 'host' && g.method !== 'gitea')) return false;
const r = this.#closed(id);
if (r && !(r.op === 'resolved' && r.choice === raise.approvalChoice)) return false;
if (g.method === 'github') return !this.#spent(business, id) && !this.#githubGrant?.decisionRevoked(business, g);
if (
this.#store.get(
`SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.grantDecision')=? AND json_extract(body,'$.action') IN ('grant.applied','grant.failed') LIMIT 1`,
@@ -2335,12 +2331,6 @@ export class Broker {
at = 'gitea';
grants.push(...(await this.#stepGGitea(business, agent, { job, reason, by, flag }, refuse)));
}
if (set.some((x) => x.kind === 'github')) {
at = 'github';
const result = await this.#githubGrant.revoke(business, agent, reason, by);
if (result.serviceLeft.length) refuse('github', 'github-left');
grants.push({ grant: 'github', granted: true });
}
for (const g of set.filter((x) => x.kind === 'line')) {
at = g.label;
const record = this.#hostRecord(business, g.host);
@@ -2590,15 +2580,6 @@ export class Broker {
this.#store.transaction(() => this.#agent(s));
if (a.action !== 'credential.mint' || 'spec_path' in a || 'spec_commit' in a) fail('invalid-request');
if (this.#business(s.business).roles[s.role]?.definition !== STAFF_TO) fail('grant-chief-only');
// Row297: the same chief/human grant gate, with a method-specific protected executor.
if (a.grant?.method === 'github') {
if (!this.#githubGrant) fail('github-not-configured');
return this.#store.transaction(() => {
const result = this.#dispatch(s, 'decision.raise', a, null, this.#githubGrant.raiseBinding(s.business, a.grant));
this.#secretCheck(result);
return result;
});
}
// Row 236 (#436b section 2): no network call at raise, so every check runs in the raise transaction.
if (a.grant?.method === 'gitea') {
if (!this.#giteaGrant) fail('gitea-not-configured');
@@ -2861,7 +2842,6 @@ export class Broker {
async grantsIdle() {
while (this.#grantJobs.size || this.#giteaRuns.size || this.#hostRuns.size)
await Promise.allSettled([...this.#grantJobs, ...this.#giteaRuns.keys(), ...[...this.#hostRuns.values()].flatMap((s) => [...s])]);
await this.#githubGrant?.idle();
}
// Section 4 steps 0 (the remote half) to 8. Never throws: the outcome is in the store and the
// notices, and the return value is 'applied', 'settle-pending' or the code grant.failed records.
@@ -3589,75 +3569,10 @@ export class Broker {
if (this.#business(s.business).roles[s.role]?.definition !== STAFF_TO) fail('grant-chief-only');
return s.role;
}
// Row297 trusted host API. No socket verb returns tokens or renews grants.
githubWithToken(business, agent, fn) {
if (!this.#githubGrant) fail('github-not-configured');
return this.#githubGrant.withToken(business, agent, fn);
}
async reconcileGithub() {
if (!this.#githubGrant) return;
for (const business of Object.keys(this.#businesses))
if (this.#githubGrant.status(business).serviceLeft.length) await this.#githubGrant.sweep(business, 'restart');
}
async closeGithub() { await this.#githubGrant?.close(); }
#startGithub(business, decision) {
if (!this.#githubGrant) fail('github-not-configured');
const g = this.#approvedGrant(business, decision);
if (g?.method !== 'github') fail('not-authorized');
const job = this.#track(this.#githubGrant.run(business, decision), business + '/' + g.agent);
job.then(
() => this.#store.transaction(() => this.#grantNotice(business, decision, `GitHub grant to ${g.agent} applied; protected renewal stays within the approved binding.`)),
(e) => this.#store.transaction(() => this.#grantNotice(business, decision, `GitHub grant to ${g.agent} failed: ${e instanceof BusError ? e.code : 'github-failed'}.`)),
).catch(() => {});
return job;
}
async #requestGithub(s, verb, a, by) {
if (!this.#githubGrant) fail('github-not-configured');
const manager = this.#githubGrant;
if (verb === 'grants.status' || verb === 'grants.sweep') {
keys(a, ['service'], ['service']);
return verb === 'grants.status' ? manager.status(s.business) : manager.sweep(s.business, by);
}
if (verb === 'grants.revoke') {
keys(a, ['service', 'agent', 'reason'], ['service', 'agent', 'reason']);
identifier(a.agent); string(a.reason, 500);
if (!Object.hasOwn(this.#business(s.business).roles, a.agent)) fail('credential-unknown');
if (this.#revoking.has(s.business + '/' + a.agent)) fail('grant-revoke-running');
return manager.revoke(s.business, a.agent, a.reason, by);
}
if (verb === 'grants.admin.remove') {
keys(a, ['service'], ['service']);
manager.api.removeAdmin(s.business);
this.#store.transaction(() => this.#grantEvent(s.business, { action: 'grant.admin', method: 'github', verb: 'remove', by }));
return { removed: true };
}
const replace = verb === 'grants.admin.rotate';
keys(a, replace ? ['service', 'rotateBy', 'token'] : ['service', 'url', 'appId', 'installationId', 'accountId', 'rotateBy', 'token'],
replace ? ['service', 'rotateBy', 'token'] : ['service', 'url', 'appId', 'installationId', 'accountId', 'rotateBy', 'token']);
const previous = replace ? manager.api.adminMeta(s.business) : null;
if (replace && !previous) fail('grant-admin-missing');
const meta = { url: replace ? previous.url : a.url, appId: replace ? previous.appId : a.appId,
installationId: replace ? previous.installationId : a.installationId, accountId: replace ? previous.accountId : a.accountId,
rotateBy: a.rotateBy, setAt: this.#now() };
const voided = this.#openGrantDecisions(s.business, 'github').length;
await manager.api.setAdmin(s.business, meta, a.token, manager.audit(s.business, 'admin:' + by), { replace });
this.#store.transaction(() => this.#grantEvent(s.business, { action: 'grant.admin', method: 'github', verb: replace ? 'rotate' : 'set', ...meta, voided, by }));
return { ...meta, voided };
}
// Row 236: the grants verbs. Async, because each one may call Gitea.
async #requestGrants(s, verb, a) {
try {
const by = this.#grantsCaller(s, verb);
if (verb === 'grants.run') {
keys(a, ['decision'], ['decision']); identifier(a.decision);
}
if (verb === 'grants.run' && this.#approvedGrant(s.business, a.decision)?.method === 'github') {
if (this.#spent(s.business, a.decision)) fail('decision-consumed');
const g = this.#approvedGrant(s.business, a.decision);
this.#startGithub(s.business, a.decision).catch(() => {});
return { decision: a.decision, agent: g.agent, started: true };
}
if (a.service === 'github') return await this.#requestGithub(s, verb, a, by);
if (!this.#giteaGrant || !this.#giteaKeys) fail('gitea-not-configured');
if (verb === 'grants.run') {
keys(a, ['decision'], ['decision']);
@@ -4771,10 +4686,19 @@ export class Broker {
return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) });
if (LIBRARY_VERBS.includes(request.verb))
return this.#requestLibrary(s, { verb: request.verb, args: structuredClone(args) });
// C2-06 (design 5.2): an agent's message to the human in a library business passes the citation
// guard first, outside any transaction, and thread.messages adds each path cite's `last`. Both
// then re-enter request() for the synchronous path below.
if (this.#guardPass?.s !== s) {
if (request.verb === 'message.send' && this.#guarded(s, args.to))
return this.#guardedSend(cap, s, request, structuredClone(args));
if (request.verb === 'thread.messages' && this.#libraryProject(s.business) && this.#library?.last)
return this.#withLast(cap, s, request);
}
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));
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set"].includes(request.verb)) {
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)) {
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
// file lists organization.change. It takes the same keys, runs the same write path and
// logs the same receipt; only the actor differs: the role definition and instance.
@@ -4820,6 +4744,12 @@ export class Broker {
keys(args, VETO_KEYS, VETO_KEYS);
this.#human(s);
this.#mayHuman(s, "veto.set", args.name);
}
// C2-06 M11: the human's replyLimits verb. The tester is refused by the gate above and an agent
// session by human-required; the writer's fence refuses any other actor.
else if (request.verb === "library.limits.set") {
keys(args, ["revision", "replyLimits"], ["revision", "replyLimits"]);
this.#human(s);
} else {
keys(args, ORGANIZATION_KEYS, ["revision"]);
// Row 194 (D2.4, B7): the chief can't change a kept instance's model, as that replaces it at
@@ -4898,8 +4828,6 @@ export class Broker {
throw new BusError('outcome-unknown');
}
this.#secretCheck(result);
if (verb === 'credentials.delete' && this.#githubGrant?.pending(s.business, a.agent))
this.#track(this.#githubGrant.revoke(s.business, a.agent, a.reason, s.tester ? 'tester' : s.human), s.business + '/' + a.agent).catch(() => {});
// Row 236 (#436b section 6): a gitea revoke, or a fire, of an agent this stack made a Gitea user for
// takes the grant away too. Credentials has done step 1. Step 2 runs here; step 3 and the fire's
// steps run once this returns.
@@ -5245,7 +5173,14 @@ export class Broker {
const context = { business: this.#business(s.business), project: args.project };
let result;
if (verb === 'library.file') result = await this.#library.readFile({ ...context, path: args.path });
else if (verb === 'library.status') result = await this.#library.status(context);
else if (verb === 'library.status') {
result = await this.#library.status(context);
// C2-06: the reply limits in force, read from the file now, and the keys that fell back.
if (this.#library.replyLimits) {
const { requireCitation, citationsPerReply, fallback, unreadable } = this.#library.replyLimits(s.business);
result = { ...result, replyLimits: { requireCitation, citationsPerReply, fallback, problem: unreadable ? 'reply-limits-unreadable' : null } };
}
}
else {
// A fetch that did not move served still answers with the code, so the refusal is recorded.
const { state, code } = await this.#library.sync(context, { accept: args.accept ?? null });
@@ -5259,6 +5194,78 @@ export class Broker {
throw this.#refused(s, Failure && error instanceof Failure ? new BusError(error.code) : error, request);
}
}
// C2-06: the business's library project, the first in file order, or null.
#libraryProject(business) {
return Object.entries(this.#business(business).projects ?? {}).find(([, p]) => p.kind === 'library')?.[0] ?? null;
}
// C2-06 (design 5.2): every agent session's message to the human in a business with a library project
// is guarded, whatever the sender's definition.
#guarded(s, to) {
return to === 'human' && !s.human && !s.tester && !s.user && !s.reader && !s.console && this.#libraryProject(s.business) !== null;
}
#reenter(cap, s, request, check) {
this.#guardPass = { s, check };
try {
return this.request(cap, request);
} finally {
this.#guardPass = null;
}
}
// As #requestProject: the git reads run outside any SQL transaction. A guard refusal is recorded with
// its code and reaches the caller with the guard's reason; the send then runs, and stores the check
// result in its own transaction.
async #guardedSend(cap, s, request, args) {
let check;
try {
this.#store.transaction(() => this.#agent(s));
if (!this.#library?.guard || !this.#library?.replyLimits) fail('project-not-configured');
if (typeof args.body !== 'string') fail('invalid-request');
const business = this.#business(s.business);
check = await this.#library.guard({ business, project: this.#libraryProject(s.business) }, args.body, {
limits: this.#library.replyLimits(s.business),
definition: business.roles[s.role]?.definition ?? null,
pages: this.#pages.get(cap) ?? [],
});
} catch (e) {
const Guard = this.#library?.GuardError;
throw this.#refused(s, Guard && e instanceof Guard ? new BusError(e.code, e.reason) : e, request);
}
return this.#reenter(cap, s, { verb: 'message.send', args }, check);
}
// C2-06 (sage-3595, morgana-0200): each path cite of a checked row gains `last`, the file's last commit
// at the served tip, one git call per distinct path. Rows without `check` are unchanged.
async #withLast(cap, s, request) {
const rows = this.#reenter(cap, s, request, null);
if (!rows.some((m) => m.check)) return rows;
const cites = (m) => {
try {
const c = JSON.parse(m.body).citations;
return Array.isArray(c) ? c : [];
} catch {
return [];
}
};
let last = new Map();
try {
const paths = rows.filter((m) => m.check).flatMap((m) => cites(m).filter((c) => typeof c?.path === 'string').map((c) => c.path));
last = await this.#library.last({ business: this.#business(s.business), project: this.#libraryProject(s.business) }, paths);
} catch {
last = new Map();
}
return rows.map((m) => {
if (!m.check) return m;
const body = cites(m);
return { ...m, check: { ...m.check, citations: m.check.citations.map((c, i) =>
c.scope === 'web' ? c : { ...c, last: last.get(body[i]?.path) ?? null }) } };
});
}
// C2-06 (B7): a fetch's text, kept for url cites in a library business only.
#keepPage(cap, s, url, page) {
if (!this.#sessions.has(cap) || !this.#libraryProject(s.business) || typeof page?.text !== 'string') return;
const text = Buffer.from(page.text).subarray(0, PAGE_BYTES).toString();
const kept = { url, finalUrl: typeof page.finalUrl === 'string' ? page.finalUrl : null, text };
this.#pages.set(cap, [...(this.#pages.get(cap) ?? []), kept].slice(-PAGE_COUNT));
}
// The eight task verbs reach the trusted S3 handler here. It runs outside any transaction, gets the
// cap to authorize and record through authorize() and recordTask(); refusals are recorded as in request().
async requestTask(cap, request, handler) {
@@ -5342,6 +5349,7 @@ export class Broker {
throw refuse(new BusError('web-failed'));
}
complete(out.summary);
if (verb === 'web.fetch') this.#keepPage(cap, s, args.url, out.result);
return { ...out.result, untrusted: true, notice: WEB_UNTRUSTED, source: verb };
}
// Row 210 (#1707, design D5): host.run, one sudo command on a registered host over ssh, for a
@@ -5468,7 +5476,7 @@ export class Broker {
by,
);
this.#routeSession(s.session);
return { role: s.role, run: s.run, thread: s.thread };
return { role: s.role, run: s.run, thread: s.thread, ...(this.#libraryProject(s.business) ? { library: true } : {}) };
}
if (
this.#store.get(
@@ -5492,7 +5500,8 @@ export class Broker {
by,
);
this.#routeWaiting(s.business, s.role);
return { role: s.role, run: s.run };
// C2-06 (C2-07): the runner's switch to the library texts and retry. Absent elsewhere.
return { role: s.role, run: s.run, ...(this.#libraryProject(s.business) ? { library: true } : {}) };
}
case 'role.release': {
keys(a, []);
@@ -5764,7 +5773,6 @@ export class Broker {
if (grant?.method === 'host-enroll') setImmediate(() => this.#runEnrollment(s.business, d.id));
if (grant?.method === 'host') setImmediate(() => this.#runHostGrant(s.business, d.id));
if (grant?.method === 'gitea') setImmediate(() => this.#startGitea(s.business, d.id));
if (grant?.method === 'github') setImmediate(() => Promise.resolve().then(() => this.#startGithub(s.business, d.id)).catch(() => {}));
// Row 115 (#1609): a close by anyone but the raising run tells the raising role, routed like message.send's.
// Row 133 (#1627): no messaging check here. decision.raise refused a route that can't message the
// raiser, definitions change only with a pinned copy and arbiters only through the business
@@ -5857,6 +5865,10 @@ export class Broker {
if (this.#userThread(s) && to === 'human') fail('user-thread-human');
this.#checkMessaging(s.business, this.#sender(s), testerThread && to === 'tester' ? 'human' : to);
string(a.body, 32768);
// C2-06: a guarded send is stored only through #guardedSend's pass, with its check result.
const guarded = this.#guarded(s, to);
if (guarded && this.#guardPass?.s !== s) fail('storage-refused');
const check = guarded ? this.#guardPass.check : null;
if (
a.class !== undefined &&
![
@@ -5903,6 +5915,9 @@ export class Broker {
a.corrects ?? null,
a.body,
);
// C2-06: the stored check result shares the message's transaction (sage-3595 item 3). No new
// event kind, which would be a schema step: the events kind CHECK is migrations/3b.sql:9.
if (check) this.#event(s, 'action.allowed', { action: 'message.checked', message: mid, check }, mid, checkEventId(mid));
const cancelled = this.#route(s.business, mid, to, thread);
const request = s.human
? this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid)
@@ -6522,11 +6537,15 @@ export class Broker {
)?.seq;
if (after === undefined) fail('message-not-found');
}
// C2-06 (row 225, morgana-0198): a row the citation guard checked carries the stored result.
return this.#store.all(
`SELECT m.* FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=? AND m.seq>? ORDER BY m.seq LIMIT ${limit}`,
session,
after,
);
).map((m) => {
const e = this.#store.get("SELECT body FROM events WHERE id=? AND kind='action.allowed' AND subject=?", checkEventId(m.id), m.id);
return e ? { ...m, check: JSON.parse(e.body).check } : m;
});
}
// Row 206 (193b2, #1704, design 5.3): the owner of a thread shares it read-only, or ends a share.
// A user's collaborator grant must still cover the thread, and a user target needs a live grant
+2 -1
View File
@@ -41,7 +41,8 @@ export class Client {
const r = JSON.parse(input.toString('utf8'));
if (r.ok === true) end(null, r.result);
else if (r.ok === false && typeof r.error === 'string' && /^[a-z-]{1,64}$/.test(r.error))
end(new BusError(r.error));
// C2-06 (sage-3599): the citation guard's reason, when the envelope has one.
end(new BusError(r.error, typeof r.reason === 'string' ? r.reason.slice(0, 300) : undefined));
else end(new BusError('invalid-response'));
} catch {
end(new BusError('invalid-response'));
-143
View File
@@ -1,143 +0,0 @@
// Row297 GitHub App HTTPS boundary. No PATs, user creation or fork calls.
import { createPrivateKey, sign } from 'node:crypto';
import { request } from 'node:https';
import { BusError } from './broker.mjs';
import { day } from './credentials.mjs';
import { githubDirectory, githubRead, githubWrite, githubUnlink } from './github-files.mjs';
export const FIXED_PERMISSIONS = Object.freeze({ contents: 'write', metadata: 'read' });
export const API_VERSION = '2026-03-10';
export const CALL_TIMEOUT_MS = 15000;
const META_KEYS = ['accountId', 'appId', 'installationId', 'rotateBy', 'setAt', 'url'];
const id = (v) => Number.isSafeInteger(v) && v > 0;
const fail = (code) => { throw new BusError(code); };
export function githubOrigin(value) {
try {
const url = new URL(value);
if (url.protocol !== 'https:' || url.username || url.password || value !== url.origin) fail('invalid-request');
return url.origin;
} catch { fail('invalid-request'); }
}
export function githubMeta(meta) {
if (!meta || typeof meta !== 'object' || Array.isArray(meta) || Object.keys(meta).sort().join() !== META_KEYS.join()) fail('invalid-request');
githubOrigin(meta.url);
if (![meta.appId, meta.installationId, meta.accountId].every(id) || !day(meta.rotateBy) ||
typeof meta.setAt !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(meta.setAt) || !Number.isFinite(Date.parse(meta.setAt)) || new Date(meta.setAt).toISOString() !== meta.setAt) fail('invalid-request');
return { ...meta };
}
export function githubRsa(text) {
try {
if (typeof text !== 'string' || text.length > 16384 || !/^-----BEGIN (RSA )?PRIVATE KEY-----\n/.test(text)) fail('credential-format');
const key = createPrivateKey(text);
if (key.asymmetricKeyType !== 'rsa' || key.asymmetricKeyDetails.modulusLength < 2048) fail('credential-format');
return key;
} catch { fail('credential-format'); }
}
export function githubJwt(key, appId, now) {
const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
const secs = Math.floor(now / 1000);
const input = encode({ alg: 'RS256', typ: 'JWT' }) + '.' + encode({ iat: secs - 60, exp: secs + 540, iss: String(appId) });
return input + '.' + sign('RSA-SHA256', Buffer.from(input), key).toString('base64url');
}
// Never follows redirects or returns headers/error text. Body limit and whole-call timeout.
export function sendGithub({ url, method, path, token, body, ca, timeoutMs = CALL_TIMEOUT_MS }) {
return new Promise((resolve) => {
let req, ended = false;
const end = (status = 0, json = null) => {
if (ended) return;
ended = true; clearTimeout(timer); resolve({ status, json });
};
const timer = setTimeout(() => { req?.destroy(); end(); }, timeoutMs);
try {
githubOrigin(url);
if (!path.startsWith('/') || path.startsWith('//')) fail('invalid-request');
const data = body === undefined ? null : Buffer.from(JSON.stringify(body));
req = request(new URL(path, url), { method, ca, agent: false, headers: {
accept: 'application/vnd.github+json', authorization: 'Bearer ' + token,
'x-github-api-version': API_VERSION, 'user-agent': 'Mosaic-grant',
...(data ? { 'content-type': 'application/json', 'content-length': String(data.length) } : {}),
} }, (res) => {
let size = 0; const chunks = [];
res.on('data', (part) => {
size += part.length;
if (size > 4 * 1024 * 1024) { req.destroy(); end(); }
else chunks.push(part);
});
res.on('end', () => {
let json = null;
try { json = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch {}
end(res.statusCode, json);
});
res.on('error', () => end());
});
req.on('error', () => end()); req.end(data ?? undefined);
} catch { end(); }
});
}
export function githubApi({ configDir, ca, transport = sendGithub, now = () => Date.now(), timeoutMs = CALL_TIMEOUT_MS }) {
const directory = (business, create = false) => githubDirectory(configDir, ['grants', business, 'github'], create);
const adminMeta = (business) => {
const text = githubRead(directory(business), 'admin.json');
if (text === null) return null;
try { return githubMeta(JSON.parse(text)); } catch { fail('credential-file'); }
};
const call = async (meta, key, log, method, path, { token, body, installation = false } = {}) => {
if (typeof log !== 'function') fail('not-configured');
if (installation && (typeof token !== 'string' || token.length < 16 || token.length > 16384 || !/^[-A-Za-z0-9._~]+$/.test(token))) fail('credential-format');
const authorization = installation ? token : githubJwt(key, meta.appId, now());
return log({ method, path }, () => transport({ url: meta.url, method, path, token: authorization, body, ca, timeoutMs }));
};
const check = async (meta, key, log) => {
if (meta.rotateBy <= new Date(now()).toISOString().slice(0, 10)) fail('grant-admin-rotation-due');
const app = await call(meta, key, log, 'GET', '/app');
if ([401, 403].includes(app.status)) fail('grant-admin-account');
if (app.status !== 200) fail('github-failed');
if (app.json?.id !== meta.appId) fail('grant-admin-account');
const install = await call(meta, key, log, 'GET', `/app/installations/${meta.installationId}`);
if (install.status !== 200) fail([401, 403, 404].includes(install.status) ? 'grant-admin-account' : 'github-failed');
const i = install.json;
if (i?.id !== meta.installationId || i.app_id !== meta.appId || i.account?.id !== meta.accountId || i.suspended_at !== null) fail('grant-admin-account');
if (!i.permissions || !Object.entries(FIXED_PERMISSIONS).every(([k, v]) => i.permissions[k] === v || (v === 'read' && i.permissions[k] === 'write'))) fail('grant-admin-scope');
};
const admin = (business) => {
const meta = adminMeta(business);
if (!meta) fail('grant-admin-missing');
const key = githubRsa(githubRead(directory(business), 'admin'));
return { meta, key };
};
return {
adminMeta,
async setAdmin(business, input, text, log, { replace = false } = {}) {
const meta = githubMeta(input); const key = githubRsa(text);
const old = adminMeta(business);
if (!replace && (old || githubRead(directory(business), 'admin') !== null)) fail('grant-admin-exists');
if (replace && (!old || ['url', 'appId', 'installationId', 'accountId'].some((k) => old[k] !== meta[k]))) fail('grant-admin-account');
await check(meta, key, log);
const dir = directory(business, true);
// Rotation invalidates approved bindings before replacing the signing key.
if (replace) githubWrite(dir, 'admin.json', JSON.stringify(meta) + '\n');
githubWrite(dir, 'admin', text.replace(/\n?$/, '\n'));
if (!replace) githubWrite(dir, 'admin.json', JSON.stringify(meta) + '\n');
return meta;
},
removeAdmin(business) {
const dir = directory(business);
githubUnlink(dir, 'admin.json'); githubUnlink(dir, 'admin');
},
async checkAdmin(business, log) { const { meta, key } = admin(business); await check(meta, key, log); return meta; },
mint(business, repositoryIds, log) {
const { meta, key } = admin(business);
return call(meta, key, log, 'POST', `/app/installations/${meta.installationId}/access_tokens`,
{ body: { repository_ids: repositoryIds, permissions: { ...FIXED_PERMISSIONS } } });
},
repos(business, token, page, log) {
const { meta, key } = admin(business);
return call(meta, key, log, 'GET', `/installation/repositories?per_page=30&page=${page}`, { token, installation: true });
},
revoke(business, token, log) {
const { meta, key } = admin(business);
return call(meta, key, log, 'DELETE', '/installation/token', { token, installation: true });
},
};
}
-64
View File
@@ -1,64 +0,0 @@
// Row297 protected files. Values never enter the bus event store.
import { closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync } from 'node:fs';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { BusError } from './broker.mjs';
import { privateDir } from './credentials.mjs';
const fail = () => { throw new BusError('credential-file'); };
const checkName = (name) => { if (typeof name !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(name) || name === '..') fail(); };
export function githubDirectory(configDir, parts, create = false) {
let dir = configDir;
for (const part of ['secrets', ...parts]) {
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(part) || part === '..') fail();
dir = join(dir, part);
if (!privateDir(dir, { missing: true })) {
if (!create) return null;
mkdirSync(dir, { mode: 0o700 });
}
privateDir(dir);
}
return dir;
}
export function githubRead(dir, name) {
checkName(name);
if (!dir) return null;
let fd;
try { fd = openSync(join(dir, name), constants.O_RDONLY | constants.O_NOFOLLOW); }
catch (e) { if (e.code === 'ENOENT') return null; fail(); }
try {
const stat = fstatSync(fd);
if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o777) !== 0o600 || stat.size > 65536) fail();
return readFileSync(fd, 'utf8');
} catch (e) { if (e instanceof BusError) throw e; fail(); }
finally { closeSync(fd); }
}
export function githubWrite(dir, name, text) {
checkName(name);
const target = join(dir, name);
// A foreign/symlink existing file is a refusal, never something to repair silently.
try {
const stat = lstatSync(target);
if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o777) !== 0o600) fail();
} catch (e) { if (e.code !== 'ENOENT') throw e; }
const tmp = target + '.' + randomUUID() + '.tmp';
let fd;
try {
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
writeSync(fd, text); fsyncSync(fd); closeSync(fd); fd = undefined;
renameSync(tmp, target);
const parent = openSync(dir, constants.O_RDONLY);
try { fsyncSync(parent); } finally { closeSync(parent); }
} finally {
if (fd !== undefined) closeSync(fd);
try { unlinkSync(tmp); } catch (e) { if (e.code !== 'ENOENT') throw e; }
}
}
export function githubUnlink(dir, name) {
if (!dir) return;
// Apply the same checks before deleting any protected file.
if (githubRead(dir, name) === null) return;
unlinkSync(join(dir, name));
const fd = openSync(dir, constants.O_RDONLY);
try { fsyncSync(fd); } finally { closeSync(fd); }
}
-298
View File
@@ -1,298 +0,0 @@
// Row297: protected, event-backed GitHub grants. No worker-facing token or renewal verb.
import { createHash, randomUUID } from 'node:crypto';
import { BusError, GRANT_EVIDENCE } from './broker.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { CREATIVE_DEFINITIONS, ID_PATTERN } from '../../business/src/vocabulary.mjs';
import { day } from './credentials.mjs';
import { FIXED_PERMISSIONS, CALL_TIMEOUT_MS } from './github-api.mjs';
import { githubDirectory, githubRead, githubWrite, githubUnlink } from './github-files.mjs';
export { CALL_TIMEOUT_MS };
export const TOKEN_LIFETIME_MS = 3600000;
export const CLOCK_SKEW_MS = 60000;
export const TOKEN_MARGIN_MS = 60000;
const PAGE_SIZE = 30;
const fail = (code) => { throw new BusError(code); };
const hash = (v) => createHash('sha256').update(canonicalJson(v)).digest('hex');
const isId = (v) => Number.isSafeInteger(v) && v > 0;
const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
const exact = (v, keys) => object(v) && Object.keys(v).sort().join() === [...keys].sort().join();
const tokenText = (v) => typeof v === 'string' && v.length >= 16 && v.length <= 16384 && /^[-A-Za-z0-9._~]+$/.test(v);
const sameIds = (rows, bound) => Array.isArray(rows) && rows.length === bound.length &&
rows.every((r) => isId(r?.id)) && new Set(rows.map((r) => r.id)).size === rows.length &&
rows.every((r) => bound.some((b) => b.id === r.id));
const identity = (m) => Object.fromEntries(['url', 'appId', 'installationId', 'accountId'].map((k) => [k, m[k]]));
const refusal = (e) => e instanceof BusError ? e.code : 'github-failed';
export function githubGrant({ api, configDir, now = () => Date.now(), timeoutMs = CALL_TIMEOUT_MS }) {
let context = null;
const claims = new Map();
const jobs = new Set();
const key = (b, a) => b + '/' + a;
const at = () => new Date(now()).toISOString();
const ctx = () => context ?? fail('github-not-configured');
const event = (business, body) => ctx().event(business, { ...body, method: 'github' });
const rows = (business) => ctx().store.all(
`SELECT seq,body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND (json_extract(body,'$.method')='github' OR json_extract(body,'$.operation')='grant.github') ORDER BY seq`, business,
).map((r) => ({ seq: r.seq, ...JSON.parse(r.body) }));
const state = (business) => {
const entries = rows(business), grants = new Map(), intents = new Map(), closed = new Set(), revoked = new Map();
for (const e of entries) {
if (['grant.applied', 'grant.renewed'].includes(e.action)) grants.set(e.agent, e);
if (e.action === 'grant.revoked') { grants.delete(e.agent); revoked.set(e.agent, e.seq); }
if (e.action === 'grant.step' && e.step === 'mint') intents.set(e.generation, e);
if (e.action === 'grant.token.closed') closed.add(e.generation);
}
return { entries, grants, intents, closed, revoked };
};
const log = (business, grantDecision, agent = null, generation = null) => async (op, fn) => {
const callId = randomUUID();
event(business, { action: 'grant.call', grantDecision, agent, generation, callId, op });
let result;
try { result = await fn(); } catch { result = { status: 0, json: null }; }
// Deliberately never include the response body or authorization header.
event(business, { action: 'grant.result', grantDecision, agent, generation, callId, status: result.status });
return result;
};
const tracked = (fn) => {
const job = (async () => fn())(); jobs.add(job);
job.finally(() => jobs.delete(job)).catch(() => {});
return job;
};
const claim = (business, agent) => {
const k = key(business, agent);
if (claims.has(k)) fail('grant-busy');
const controller = new AbortController(); claims.set(k, controller);
return controller;
};
const release = (business, agent, controller) => {
if (claims.get(key(business, agent)) === controller) claims.delete(key(business, agent));
};
const generationDir = (business, agent, create = false) => githubDirectory(configDir, ['grants', business, 'github', 'tokens', agent], create);
const readToken = (business, intent) => {
const text = githubRead(generationDir(business, intent.agent), intent.generation);
if (text === null) return null;
if (!tokenText(text.trimEnd()) || text !== text.trimEnd() + '\n') fail('credential-format');
return text.trimEnd();
};
const roleSnapshot = (business, agent) => {
if (typeof agent !== 'string' || !ID_PATTERN.test(agent)) fail('invalid-request');
const b = ctx().business(business);
const role = Object.hasOwn(b.roles ?? {}, agent) ? b.roles[agent] : null;
const definition = role && Object.hasOwn(b.definitions ?? {}, role.definition) ? b.definitions[role.definition] : null;
if (!definition?.credentials?.some((c) => exact(c, ['service']) && c.service === 'github') ||
CREATIVE_DEFINITIONS.includes(role.definition) || !exact(role.github, ['projects']) ||
!Array.isArray(role.github.projects) || !role.github.projects.length || role.github.projects.some((p) => typeof p !== 'string' || !ID_PATTERN.test(p)) ||
new Set(role.github.projects).size !== role.github.projects.length) fail('github-not-declared');
const meta = api.adminMeta(business);
if (!meta) fail('grant-admin-missing');
if (meta.rotateBy <= at().slice(0, 10)) fail('grant-admin-rotation-due');
const origin = meta.url === 'https://api.github.com' ? 'https://github.com' : meta.url;
const repositories = role.github.projects.map((project) => {
const entry = b.projects?.[project];
if (!isId(entry?.githubId) || typeof entry.remote !== 'string' || entry.kind === 'library') fail('remote-not-granted');
let url;
try { url = new URL(entry.remote); } catch { fail('remote-not-granted'); }
if (url.origin !== origin || url.protocol !== 'https:' || url.username || url.password || url.search || url.hash ||
!/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(url.pathname) || entry.remote !== url.origin + url.pathname) fail('remote-host-mismatch');
const name = url.pathname.slice(1).replace(/\.git$/, '');
if (name.split('/').some((p) => p === '.' || p === '..' || p === '')) fail('remote-not-granted');
return { project, id: entry.githubId, name, remote: entry.remote };
});
if (new Set(repositories.map((r) => r.id)).size !== repositories.length) fail('remote-not-granted');
return { meta, repositories, configSha256: hash({ definition, github: role.github, repositories }) };
};
const binding = (business, grant) => {
if (!exact(grant, ['method', 'agent', 'rotateBy', 'target']) || grant.method !== 'github' ||
!exact(grant.target, ['projects']) || !Array.isArray(grant.target.projects) || grant.target.projects.length < 1 ||
grant.target.projects.length > 50 || grant.target.projects.some((p) => typeof p !== 'string' || !ID_PATTERN.test(p)) ||
new Set(grant.target.projects).size !== grant.target.projects.length || !day(grant.rotateBy) || grant.rotateBy <= at().slice(0, 10)) fail('invalid-request');
ctx().grantable(business, grant.agent);
const snap = roleSnapshot(business, grant.agent);
const repositories = grant.target.projects.map((project) => snap.repositories.find((r) => r.project === project) ?? fail('remote-not-granted'));
const body = { ...structuredClone(grant), repositories, configSha256: snap.configSha256,
adminSha256: hash(snap.meta), admin: identity(snap.meta), revocationSeq: state(business).revoked.get(grant.agent) ?? 0 };
return { body, block: `\n\nGrant ${grant.agent} GitHub App ${snap.meta.appId}, installation ${snap.meta.installationId}, account ${snap.meta.accountId}.` +
repositories.map((r) => `\nRepository ${r.project}: ${r.remote} (id ${r.id}).`).join('') +
`\nPermissions: contents:write, metadata:read. Protected renewal until ${grant.rotateBy}.` +
'\nNo workflow-file writes (.github/workflows/), account creation or fork creation. No GitHub publication consumer is installed by this grant.' };
};
const recheck = (business, g, epoch, controller) => {
if (controller.signal.aborted || g.revocationSeq !== epoch || (state(business).revoked.get(g.agent) ?? 0) !== epoch) fail('revoked');
if (g.rotateBy <= at().slice(0, 10)) fail('grant-expired');
if (ctx().working && !ctx().working()) fail('processing-halted');
ctx().grantable(business, g.agent);
const snap = roleSnapshot(business, g.agent);
if (snap.configSha256 !== g.configSha256 || hash(snap.meta) !== g.adminSha256 ||
!sameIds(g.repositories, snap.repositories.filter((r) => g.target.projects.includes(r.project)))) fail('decision-mismatch');
};
const closeToken = async (business, intent, by) => {
if (state(business).closed.has(intent.generation)) return true;
const done = (reason) => {
githubUnlink(generationDir(business, intent.agent), intent.generation);
event(business, { action: 'grant.token.closed', agent: intent.agent, generation: intent.generation, reason, by });
return true;
};
if (now() >= Date.parse(intent.until)) return done('expired');
const token = readToken(business, intent);
if (!token) return false; // Lost response: only bounded expiry can close it.
try {
const meta = api.adminMeta(business);
if (!meta || hash(identity(meta)) !== hash(intent.admin)) return false;
const audit = log(business, intent.grantDecision, intent.agent, intent.generation);
await api.checkAdmin(business, audit);
const result = await api.revoke(business, token, audit);
if (result.status === 204) return done('revoked');
// Do not interpret a 401/403 as proof of revocation, especially with clock drift.
} catch {}
return false;
};
const mint = async (business, decision, g, controller, epoch, initial) => {
recheck(business, g, epoch, controller);
const audit = log(business, decision, g.agent);
await api.checkAdmin(business, audit);
recheck(business, g, epoch, controller);
if (initial) ctx().consume(business, decision);
const generation = randomUUID(), started = now();
const intent = { action: 'grant.step', step: 'mint', agent: g.agent, grantDecision: decision, generation,
until: new Date(started + timeoutMs + TOKEN_LIFETIME_MS + CLOCK_SKEW_MS).toISOString(), admin: g.admin };
event(business, intent);
const callLog = log(business, decision, g.agent, generation);
let candidate;
try {
const response = await api.mint(business, g.repositories.map((r) => r.id), callLog);
if (response.status !== 201) {
// An answered rejection cannot have minted a token. An unanswered call can.
if ([401, 403, 404, 422].includes(response.status))
event(business, { action: 'grant.token.closed', agent: g.agent, generation, reason: 'not-issued' });
fail('github-failed');
}
const body = response.json;
if (tokenText(body?.token)) {
candidate = body.token;
githubWrite(generationDir(business, g.agent, true), generation, candidate + '\n');
}
recheck(business, g, epoch, controller);
const expires = typeof body?.expires_at === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(body.expires_at) ? Date.parse(body.expires_at) : NaN;
if (!candidate || !object(body.permissions) || canonicalJson(body.permissions) !== canonicalJson(FIXED_PERMISSIONS) ||
body.repository_selection !== 'selected' || !sameIds(body.repositories, g.repositories) ||
!Number.isFinite(expires) || expires <= now() + TOKEN_MARGIN_MS || expires > started + TOKEN_LIFETIME_MS + CLOCK_SKEW_MS) fail('github-token-invalid');
const seen = [];
for (let page = 1; page <= 20; page++) {
const response = await api.repos(business, candidate, page, callLog);
recheck(business, g, epoch, controller);
if (response.status !== 200 || !object(response.json) || response.json.total_count !== g.repositories.length ||
!Array.isArray(response.json.repositories) || !response.json.repositories.length || response.json.repositories.length > PAGE_SIZE) fail('github-reach-unknown');
seen.push(...response.json.repositories);
if (seen.length >= response.json.total_count) break;
if (response.json.repositories.length < PAGE_SIZE) fail('github-reach-unknown');
}
if (!sameIds(seen, g.repositories) || seen.some((r) => !g.repositories.some((b) => b.id === r.id && b.name === r.full_name))) fail('github-reach-wider');
recheck(business, g, epoch, controller);
if (now() >= expires - TOKEN_MARGIN_MS) fail('github-token-invalid');
const old = state(business).grants.get(g.agent);
event(business, { action: initial ? 'grant.applied' : 'grant.renewed', agent: g.agent, grantDecision: decision,
generation, binding: g, expiresAt: new Date(expires).toISOString() });
if (old) {
const oldIntent = state(business).intents.get(old.generation);
if (oldIntent) await closeToken(business, oldIntent, 'replacement');
}
recheck(business, g, epoch, controller);
return { agent: g.agent, decision, generation, expiresAt: new Date(expires).toISOString() };
} catch (e) {
await closeToken(business, intent, 'failed');
event(business, { action: 'grant.failed', agent: g.agent, grantDecision: decision, generation, code: refusal(e) });
throw e instanceof BusError ? e : new BusError('github-failed');
}
};
const status = (business) => {
const s = state(business);
const active = new Set([...s.grants.values()].map((g) => g.generation));
return {
admin: api.adminMeta(business),
unclosedCalls: s.entries.filter((e) => e.action === 'grant.call' && !s.entries.some((r) => r.action === 'grant.result' && r.callId === e.callId))
.map((e) => ({ callId: e.callId, op: e.op, generation: e.generation })),
grants: [...s.grants.values()].map((g) => ({ agent: g.agent, decision: g.grantDecision,
generation: g.generation, repositoryIds: g.binding.repositories.map((r) => r.id), projects: g.binding.target.projects,
rotateBy: g.binding.rotateBy, expiresAt: g.expiresAt })),
serviceLeft: [...s.intents.values()].filter((i) => !active.has(i.generation) && !s.closed.has(i.generation))
.map((i) => ({ agent: i.agent, generation: i.generation, until: i.until })),
running: [...claims.keys()].filter((k) => k.startsWith(business + '/')).map((k) => ({ agent: k.slice(business.length + 1) })),
};
};
const manager = {
configure(c) { if (context) fail('github-already-configured'); context = c; },
raiseBinding: binding,
status,
decisionRevoked(business, g) { return g.revocationSeq !== (state(business).revoked.get(g.agent) ?? 0); },
pending(business, agent) {
const s = status(business);
return s.grants.some((g) => g.agent === agent) || s.serviceLeft.some((g) => g.agent === agent) || s.running.some((g) => g.agent === agent);
},
run(business, decision) { return tracked(async () => {
const g = ctx().approved(business, decision);
if (g?.method !== 'github') fail('not-authorized');
if (state(business).entries.some((e) => e.operation === 'grant.github' && e.decision === decision)) fail('decision-consumed');
const controller = claim(business, g.agent), epoch = state(business).revoked.get(g.agent) ?? 0;
try { return await mint(business, decision, g, controller, epoch, true); }
catch (e) {
// Preflight failures are unspent and retryable before restart, like row236.
if (!state(business).entries.some((e) => e.operation === 'grant.github' && e.decision === decision))
event(business, { action: 'grant.failed', agent: g.agent, grantDecision: decision, code: refusal(e), unspent: true });
throw e;
} finally { release(business, g.agent, controller); }
}); },
withToken(business, agent, fn) { return tracked(async () => {
const controller = claim(business, agent), epoch = state(business).revoked.get(agent) ?? 0;
try {
let g = state(business).grants.get(agent);
if (!g) fail('github-not-granted');
if (ctx().approved(business, g.grantDecision)?.method !== 'github') fail('not-authorized');
recheck(business, g.binding, epoch, controller);
if (now() >= Date.parse(g.expiresAt) - TOKEN_MARGIN_MS) {
await mint(business, g.grantDecision, g.binding, controller, epoch, false);
if (controller.signal.aborted) fail('revoked');
g = state(business).grants.get(agent);
if (!g) fail('revoked');
}
const intent = state(business).intents.get(g.generation);
const token = intent && readToken(business, intent);
if (!token) fail('credential-file');
recheck(business, g.binding, epoch, controller);
if (now() >= Date.parse(g.expiresAt) - TOKEN_MARGIN_MS) fail('github-token-expired');
const expiryTimer = setTimeout(() => controller.abort(), Math.max(0, Date.parse(g.expiresAt) - TOKEN_MARGIN_MS - now()));
try { return await fn(token, controller.signal); } finally { clearTimeout(expiryTimer); }
} finally { release(business, agent, controller); }
}); },
revoke(business, agent, reason, by) { return tracked(async () => {
if (typeof agent !== 'string' || !ID_PATTERN.test(agent) || typeof reason !== 'string' || !reason.length || reason.length > 500) fail('invalid-request');
claims.get(key(business, agent))?.abort();
event(business, { action: 'grant.revoked', agent, reason, by });
// The live claim owns cleanup of its in-flight candidate when it returns.
const s = state(business);
for (const intent of s.intents.values()) if (intent.agent === agent && !s.closed.has(intent.generation))
await closeToken(business, intent, by);
return { agent, serviceLeft: status(business).serviceLeft.filter((e) => e.agent === agent) };
}); },
sweep(business, by) { return tracked(async () => {
const s = state(business), active = new Set([...s.grants.values()].map((g) => g.generation));
for (const intent of s.intents.values())
if (!s.closed.has(intent.generation) && (!active.has(intent.generation) || now() >= Date.parse(intent.until)) && !claims.has(key(business, intent.agent))) await closeToken(business, intent, by);
event(business, { action: 'grant.swept', by, left: status(business).serviceLeft });
return status(business);
}); },
reconcile(business) {
// A generation with no applied/renewed record is never loaded or adopted.
const s = state(business);
for (const e of s.entries.filter((e) => e.operation === 'grant.github')) {
if (s.entries.some((r) => r.grantDecision === e.decision && ['grant.applied', 'grant.failed'].includes(r.action) && r.seq > e.seq)) continue;
const intent = [...s.intents.values()].findLast((i) => i.grantDecision === e.decision);
event(business, { action: 'grant.failed', grantDecision: e.decision, agent: intent?.agent ?? null, code: 'interrupted' });
}
},
async idle() { while (jobs.size) await Promise.allSettled([...jobs]); },
async close() { for (const c of claims.values()) c.abort(); await manager.idle(); },
api,
audit: log,
};
return manager;
}
+11 -9
View File
@@ -14,7 +14,7 @@ import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
import { instanceSettings } from '../../business/src/instance-settings.mjs';
import { organizationWriter, OrganizationError } from '../../business/src/organization.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../business/src/organization.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { loadRole } from '../../business/src/role.mjs';
@@ -25,8 +25,6 @@ import { webService } from './web.mjs';
import { hostExecutor, sweepRunDir } from './host-run.mjs';
import { grantKeys, hostGrant, hostReference, sweepHostKeys } from './host-grant.mjs';
import { giteaGrant, giteaKeys, giteaStart } from './gitea-grant.mjs';
import { githubApi } from './github-api.mjs';
import { githubGrant } from './github-grant.mjs';
import { verifyPrincipal } from './principal.mjs';
import { loadAccess } from '../../business/src/access.mjs';
import { inspectInterpreter, recordInterpreterStart } from './interpreter.mjs';
@@ -124,7 +122,7 @@ export async function startBroker({
|| (Object.hasOwn(librarySync, 'intervalMs') && !(Number.isSafeInteger(librarySync.intervalMs) && librarySync.intervalMs > 0))
|| (Object.hasOwn(librarySync, 'configDir') && typeof librarySync.configDir !== 'string')))
throw new BusError('invalid-host-request');
let store, credentials, server, adapter, processing, github = null, syncTimer = null, syncPass = null, accessTimer = null;
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null, accessTimer = null;
try {
const references = {};
for (const [business, b] of Object.entries(businesses)) {
@@ -273,7 +271,6 @@ export async function startBroker({
return false;
}
}
github = organization ? githubGrant({ api: githubApi({ configDir: organization.dir }), configDir: organization.dir }) : null;
broker = new Broker({
// FED R3: the access file is read per user request; a refusal leaves the user no grant.
access: access ? () => loadAccess(access.file, access.context) : null,
@@ -299,7 +296,6 @@ export async function startBroker({
// Row 236 (#1742): the Gitea grant path. The admin slot is <configDir>/secrets/grants/<business>/gitea.
giteaGrant: organization ? giteaGrant({ configDir: organization.dir }) : null,
giteaKeys: storeRoot ? giteaKeys({ credentials, storeRoot }) : null,
githubGrant: github,
usage,
limits,
store,
@@ -309,6 +305,13 @@ export async function startBroker({
status: context => libraryModule.libraryStatus({ ...context, dataRoot }),
sync: (context, { accept }) => processing.work(() => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept })),
LibraryError: libraryModule.LibraryError,
// C2-06: the citation guard, the reply limits read from the business file at each check (none
// configured reads as unreadable, so requireCitation is true), and thread.messages' `last`.
guard: (context, body, options) => libraryModule.guardReply({ ...context, dataRoot }, body, options),
replyLimits: id => libraryModule.resolveReplyLimits(organization
? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }).readRaw(id) : { readable: false }),
last: (context, paths) => libraryModule.lastCommits({ ...context, dataRoot }, paths),
GuardError: libraryModule.GuardError,
},
project: {
readSpec: context => projectModule.readSpec({ ...context, dataRoot }),
@@ -338,6 +341,8 @@ export async function startBroker({
else if(verb==='plugins.set')result=plugins.set(id,args,actor);
// Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer.
else if(verb==='veto.set')result=instances.veto(id,args,actor);
// C2-06 M11: the human's replyLimits, through the organization writer's fence.
else if(verb==='library.limits.set')result=setReplyLimits(organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),id,args,actor);
// Row 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context.
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
@@ -463,7 +468,6 @@ export async function startBroker({
broker.reconcileHosts();
// Row 236 (#436b r5 section 5): the Gitea grants' remote undo, settle and open revokes, likewise.
broker.reconcileGitea();
broker.reconcileGithub().catch(() => {});
// S6 launcher operations. The host decides capacity and the instance list; the broker
// authorizes role.launch and keeps the evidence, refusals included.
// A refusal is recorded against the caller, as request() records one.
@@ -654,7 +658,6 @@ export async function startBroker({
await stopSync();
await server.close();
await adapter?.close?.();
await github?.close();
await processing?.close();
store.close();
credentials.close();
@@ -664,7 +667,6 @@ export async function startBroker({
clearInterval(accessTimer);
await server?.close();
await adapter?.close?.();
await github?.close();
await processing?.close();
store?.close();
credentials?.close();
+23 -5
View File
@@ -7,6 +7,11 @@ const LIMIT = 65536;
// Row 148: a document save may carry up to 1 MiB of text. Only a request that names that verb near
// its start (the proof object comes first) may pass LIMIT; the parsed verb is checked again below.
const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.', '\\.')}"`);
// The refusal envelope. C2-06 (sage-3599): `reason` is added only when the error carries one, which
// only the citation guard's refusals do, so every other envelope is byte-identical to before.
const refusal = (e, other) => e instanceof BusError
? { ok: false, error: e.code, ...(typeof e.reason === 'string' ? { reason: e.reason } : {}) }
: { ok: false, error: other };
// One request per connection. There is deliberately no reconnect/retry or SQL verb.
// `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it.
// `web` is the trusted web service (web.mjs), or a function from a business id to one (or null); only
@@ -15,6 +20,7 @@ const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.
// host-not-configured.
// A request carries exactly one of cap, human (row S4), tester (row 110), user (FED R3) and console
// (row 252) proof.
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, authenticateUser = null, authenticateConsole = null, peerReader = null, timeout = 5000, tasks = null, web = null, host = null }) {
try {
lstatSync(path);
@@ -96,7 +102,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
.requestTask(cap, { verb: r.verb, args: r.args ?? {} }, tasks.handle)
.then(
(result) => finish({ ok: true, result }),
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'adapter-failed' }),
(e) => finish(refusal(e, 'adapter-failed')),
)
.finally(() => {
if (own) release();
@@ -114,7 +120,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
.requestWeb(cap, { verb: r.verb, args: r.args ?? {} }, service, { onAdmit: () => socket.setTimeout(service.timeout) })
.then(
(result) => finish({ ok: true, result }),
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'web-failed' }),
(e) => finish(refusal(e, 'web-failed')),
)
.finally(() => {
if (own) release();
@@ -130,7 +136,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
.requestHost(cap, { verb: r.verb, args: r.args ?? {} }, host, { onAdmit: (ms) => socket.setTimeout(ms + 10000) })
.then(
(result) => finish({ ok: true, result }),
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'host-failed' }),
(e) => finish(refusal(e, 'host-failed')),
)
.finally(() => {
if (own) release();
@@ -153,14 +159,26 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
socket.setTimeout(120_000);
Promise.resolve().then(() => broker.request(cap, { verb: r.verb, args: r.args ?? {} })).then(
result => { if (!socket.destroyed) finish({ ok: true, result }); },
e => { if (!socket.destroyed) finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' }); },
e => { if (!socket.destroyed) finish(refusal(e, 'invalid-envelope')); },
).finally(() => { if (own) release(); });
return;
}
const result = broker.request(cap, { verb: r.verb, args: r.args ?? {} });
// C2-06: a guarded message.send and a library business's thread.messages read Git first, so
// the broker answers them with a promise. Every other verb stays synchronous.
if (result instanceof Promise) {
const own = transient;
transient = null;
socket.setTimeout(120_000);
result.then(
value => { if (!socket.destroyed) finish({ ok: true, result: value }); },
e => { if (!socket.destroyed) finish(refusal(e, 'invalid-envelope')); },
).finally(() => { if (own) release(); });
return;
}
finish({ ok: true, result });
} catch (e) {
finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' });
finish(refusal(e, 'invalid-envelope'));
} finally {
if (transient) release();
}
@@ -0,0 +1,56 @@
// E4/sage-3668: real flock contention must not turn a valid cite into an invalid-cite refusal.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { ensureMirror, mirrorPath, SYNC_STATE_FILE, withLibraryLock } from '../../library/src/mirror.mjs';
import { guardReply } from '../../library/src/guard.mjs';
import { git } from '../../library/tests/helpers.mjs';
import { Client } from '../src/client.mjs';
import { serve } from '../src/server.mjs';
import { citationWorld, citedBody, fixtureCite } from './citation-helpers.mjs';
test('C2-06 held library lock gives the agent citation-unavailable and its retry reason', { timeout: 25000 }, async t => {
const f = citationWorld(t), dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
const context = { business: f.business, project: 'books', dataRoot };
await ensureMirror(context); const mirror = mirrorPath(context);
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, 'HEAD:refs/heads/served');
writeFileSync(join(mirror, SYNC_STATE_FILE), JSON.stringify({ version: 1, branch: 'main', served: f.commit,
lastFetch: null, lastError: null, diverged: null, problems: [] }), { mode: 0o600 });
f.adapter.guard = (ctx, body, opts) => guardReply({ ...ctx, dataRoot }, body, opts);
f.call(f.agent, 'role.claim');
const socket = join(f.root, 'availability.sock'), server = await serve({ broker: f.broker, path: socket });
t.after(() => server.close()); const client = new Client({ path: socket, cap: f.agent, timeout: 15000 });
const args = { to: 'human', body: citedBody([fixtureCite(f.commit)]) };
await client.call('message.send', args); // Establish that the cite and real mirror are valid.
const before = f.store.get('SELECT count(*) n FROM messages').n;
await withLibraryLock(mirror, async () => {
await assert.rejects(client.call('message.send', args), e => {
assert.equal(e.code, 'citation-unavailable');
assert.equal(e.reason, 'The library is busy or unavailable. Retry the answer when the library is available.');
return true;
});
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, before);
// A fixed fallback carries no library cite and must not need the contested lock.
await client.call('message.send', { to: 'human', body: '(no answer passed the citation check)' });
});
await client.call('message.send', args); // No lock mutation or lost valid cite after release.
});
test('C2-06 malformed sync state is availability failure, never a fabricated invalid cite', async t => {
const f = citationWorld(t), dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
const context = { business: f.business, project: 'books', dataRoot };
await ensureMirror(context); const mirror = mirrorPath(context);
writeFileSync(join(mirror, SYNC_STATE_FILE), '{broken', { mode: 0o600 });
await assert.rejects(guardReply(context, citedBody([fixtureCite(f.commit)]), {
limits: f.limits(), definition: 'coder', pages: [],
}), { code: 'citation-unavailable' });
});
test('C2-06 no mirror or freshly initialized empty mirror still means no served citation', async t => {
const f = citationWorld(t), dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
const context = { business: f.business, project: 'books', dataRoot };
const check = () => guardReply(context, citedBody([fixtureCite(f.commit)]), { limits: f.limits(), definition: 'coder', pages: [] });
await assert.rejects(check(), { code: 'citation-invalid' });
await ensureMirror(context); await assert.rejects(check(), { code: 'citation-invalid' });
});
+153
View File
@@ -0,0 +1,153 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { connect } from 'node:net';
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { Client } from '../src/client.mjs';
import { serve } from '../src/server.mjs';
import { FIXED_TEXTS, GuardError } from '../../library/src/guard.mjs';
import { git } from '../../library/tests/helpers.mjs';
import { organizationWriter, setReplyLimits } from '../../business/src/organization.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../business/tests/helpers.mjs';
import { resolveReplyLimits } from '../../library/src/limits.mjs';
import { citationWorld, citedBody, fixtureCite, guardCases } from './citation-helpers.mjs';
for (const definition of ['coder', 'research']) test(`C2-06 ${definition} send_message guard covers every table refusal; inserts nothing`, async t => {
const f = citationWorld(t, { definition }); f.call(f.agent, 'role.claim');
writeFileSync(f.settings, JSON.stringify({ replyLimits: { requireCitation: true } }));
for (const [code, body] of guardCases(f.commit)) {
const count = f.store.get('SELECT count(*) n FROM messages').n;
await assert.rejects(f.call(f.agent, 'message.send', { to: 'human', body }), e => e.code === code && e.reason.length <= 300);
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, count, code);
assert.equal(f.store.all("SELECT * FROM events WHERE json_extract(body,'$.action')='message.checked'").length, 0);
}
const sent = await f.call(f.agent, 'message.send', { to: 'human', body: citedBody([fixtureCite(f.commit)]) });
const row = f.store.get("SELECT body,subject FROM events WHERE json_extract(body,'$.action')='message.checked'");
assert.equal(row.subject, sent.id);
assert.deepEqual(JSON.parse(row.body).check, { citations: [{ scope: 'canon', agent: null, file: f.commit }] });
});
test('C2-06 stored check carries the file author trailer at an unrelated cited tip', async t => {
const f = citationWorld(t, { definition: 'research' }); f.call(f.agent, 'role.claim');
const path = 'Book One/Characters/Mara Vell.md';
writeFileSync(join(f.repo, path), readFileSync(join(f.repo, path), 'utf8') + 'A fixture note.\n');
git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'Fixture note\n\nMosaic-Agent: acme/scribe');
const file = git(f.repo, 'rev-parse', 'HEAD');
writeFileSync(join(f.repo, 'Inbox Note.md'), 'Unrelated fixture.\n'); git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'unrelated');
const cited = git(f.repo, 'rev-parse', 'HEAD'); f.setServed(cited);
assert.notEqual(cited, file);
const sent = await f.call(f.agent, 'message.send', { to: 'human', body: citedBody([fixtureCite(cited)]) });
const event = f.store.get("SELECT body FROM events WHERE subject=? AND json_extract(body,'$.action')='message.checked'", sent.id);
assert.deepEqual(JSON.parse(event.body).check, { citations: [{ scope: 'canon', agent: 'acme/scribe', file }] });
const [row] = await f.call(f.human, 'thread.messages', { thread: f.thread.id });
assert.deepEqual(row.check.citations, [{ scope: 'canon', agent: 'acme/scribe', file, last: file }]);
});
test('C2-06 check evidence and insert are atomic when storing evidence fails', async t => {
const f = citationWorld(t); f.call(f.agent, 'role.claim');
f.store.run(`CREATE TRIGGER fixture_check_refused BEFORE INSERT ON events
WHEN json_extract(NEW.body,'$.action')='message.checked' BEGIN SELECT RAISE(ABORT,'fixture check failure'); END`);
await assert.rejects(f.call(f.agent, 'message.send', { to: 'human', body: citedBody([fixtureCite(f.commit)]) }));
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, 0);
});
test('C2-06 live limits change both directions; unreadable file keeps checks and exact fixed texts', async t => {
const f = citationWorld(t); f.call(f.agent, 'role.claim');
const send = body => f.call(f.agent, 'message.send', { to: 'human', body });
await send('free');
writeFileSync(f.settings, JSON.stringify({ replyLimits: { requireCitation: true } }));
await assert.rejects(send('free'), { code: 'citation-missing' });
writeFileSync(f.settings, JSON.stringify({ replyLimits: { requireCitation: false } })); await send('free');
for (const invalid of ['{broken', 'null', '[]']) {
writeFileSync(f.settings, invalid);
const before = f.store.get('SELECT count(*) n FROM messages').n;
await assert.rejects(send('free'), { code: 'citation-missing' });
await assert.rejects(send(citedBody(Array(21).fill(fixtureCite(f.commit)))), { code: 'citations-over-limit' });
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, before);
for (const text of Object.values(FIXED_TEXTS)) await send(text);
assert.equal((await f.call(f.human, 'library.status', { project: 'books' })).replyLimits.problem, 'reply-limits-unreadable');
}
rmSync(f.settings);
await assert.rejects(send('free'), { code: 'citation-missing' });
});
test('C2-06 page store is session-local, caps 20 fetches and 200 KiB, drops at disconnect', async t => {
const f = citationWorld(t, { definition: 'research' }); f.call(f.agent, 'role.claim');
const fetch = async (n, text = `page ${n}`) => f.broker.requestWeb(f.agent, { verb: 'web.fetch', args: { url: `https://fixture.invalid/${n}` } },
{ handle: async () => ({ result: { text }, summary: { outcome: 'ok' } }) });
const send = (n, quote) => f.call(f.agent, 'message.send', { to: 'human', body: citedBody([{ url: `https://fixture.invalid/${n}`, quote }]) });
await fetch(0); await send(0, 'page 0');
for (let n = 1; n <= 20; n++) await fetch(n);
await assert.rejects(send(0, 'page 0'), { code: 'citation-unfetched' }); await send(1, 'page 1');
await fetch(21, 'x'.repeat(200 * 1024) + 'AFTER_CAP');
await send(21, 'xxxx'); await assert.rejects(send(21, 'AFTER_CAP'), { code: 'citation-unfetched' });
f.broker.disconnect(f.agent);
f.broker.endLaunch({ business: 'acme', run: 'coder-run', reason: 'fixture', exitCode: 0 });
const replacement = f.broker.bindLaunch({ ...f.launch, run: 'next-run' });
f.call(replacement, 'role.claim');
await assert.rejects(f.call(replacement, 'message.send', { to: 'human', body: citedBody([{ url: 'https://fixture.invalid/21', quote: 'xxxx' }]) }), { code: 'citation-unfetched' });
});
test('C2-06 transport passes safe capped guard reason; ordinary refusal envelope is byte-identical', async t => {
const f = citationWorld(t, { guard: async () => { throw new GuardError('citation-invalid', 'guard reason '.repeat(50)); } });
f.call(f.agent, 'role.claim'); const path = join(f.root, 'socket');
const server = await serve({ broker: f.broker, path }); t.after(() => server.close());
const client = new Client({ path, cap: f.agent });
await assert.rejects(client.call('message.send', { to: 'human', body: 'fixture' }), e => e.code === 'citation-invalid' && e.reason === ('guard reason '.repeat(50)).slice(0, 300));
const raw = await new Promise((resolve, reject) => {
const socket = connect(path); let text = ''; socket.on('error', reject); socket.on('data', b => { text += b; }); socket.on('end', () => resolve(text));
socket.on('connect', () => socket.write(JSON.stringify({ cap: f.agent, verb: 'unknown-fixture' }) + '\n'));
});
assert.equal(raw, '{"ok":false,"error":"unknown-verb"}\n');
});
test('C2-06 non-library businesses retain role.claim shape and unguarded free sends', async t => {
const f = citationWorld(t, { library: false });
assert.deepEqual(f.call(f.agent, 'role.claim'), { role: 'coder', run: 'coder-run' });
assert.ok(f.call(f.agent, 'message.send', { to: 'human', body: 'free' }).id);
});
test('C2-06 thread messages carry file/last evidence with one read per distinct path', async t => {
const f = citationWorld(t, { definition: 'research' }); f.call(f.agent, 'role.claim');
const path = 'Book One/Characters/Mara Vell.md';
await f.call(f.agent, 'message.send', { to: 'human', body: citedBody([fixtureCite(f.commit), fixtureCite(f.commit)]) });
await f.call(f.agent, 'message.send', { to: 'human', body: citedBody([fixtureCite(f.commit)]) });
const rows = await f.call(f.human, 'thread.messages', { thread: f.thread.id });
assert.equal(rows.length, 2); assert.deepEqual(f.reads, [path]);
for (const row of rows) for (const c of row.check.citations) {
assert.equal(c.file, f.commit); assert.equal(c.last, f.commit); assert.equal(c.agent, null);
}
writeFileSync(join(f.repo, path), 'A changed fixture.\n'); git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'changed fixture');
const changed = git(f.repo, 'rev-parse', 'HEAD'); f.setServed(changed); f.reads.length = 0;
const newer = await f.call(f.human, 'thread.messages', { thread: f.thread.id });
assert.deepEqual(f.reads, [path]); assert.equal(newer[0].check.citations[0].file, f.commit); assert.equal(newer[0].check.citations[0].last, changed);
});
test('C2-06 guarded request snapshot cannot change between check and insert', async t => {
let finish;
const f = citationWorld(t, { guard: () => new Promise(r => { finish = r; }) }); f.call(f.agent, 'role.claim');
const request = { verb: 'message.send', args: { to: 'human', body: 'checked fixture' } };
const sent = f.broker.request(f.agent, request); request.args.body = 'unchecked replacement';
finish(null); await sent;
assert.equal(f.store.get("SELECT body FROM messages WHERE from_role='coder'").body, 'checked fixture');
});
test('C2-06 library.limits.set is human-only and invalid settings leave file and receipts unchanged', async t => {
let writer;
const f = citationWorld(t, { organization: (id, verb, args, actor) => {
assert.equal(verb, 'library.limits.set'); return setReplyLimits(writer, id, args, actor);
} });
const dir = join(f.root, 'config'), file = writeJson(join(dir, 'businesses/acme.json'), businessDoc(f.root));
writer = organizationWriter({ dir, rolesDir: rolesCopy(f.root) });
f.adapter.replyLimits = () => resolveReplyLimits(writer.readRaw('acme'));
f.call(f.agent, 'role.claim');
await f.call(f.human, 'library.limits.set', { revision: writer.read('acme').revision, replyLimits: { requireCitation: true } });
await assert.rejects(f.call(f.agent, 'message.send', { to: 'human', body: 'free' }), { code: 'citation-missing' });
const before = readFileSync(file), log = readFileSync(writer.logFile('acme'));
const args = { revision: writer.read('acme').revision, replyLimits: { citationsPerReply: 0 } };
for (const [cap, code] of [[f.human, 'invalid-request'], [f.agent, 'human-required'], [f.tester, 'tester-refused']]) {
assert.throws(() => f.call(cap, 'library.limits.set', args), { code });
assert.deepEqual(readFileSync(file), before); assert.deepEqual(readFileSync(writer.logFile('acme')), log);
}
assert.throws(() => f.call(f.human, 'library.limits.set', { ...args, replyLimits: { invented: true } }), { code: 'invalid-request' });
});
+65
View File
@@ -0,0 +1,65 @@
// C2-06: real broker, real Git reads and fixture-only library. Reused by runner acceptance tests.
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { checkReply, GuardError, libraryReader } from '../../library/src/guard.mjs';
import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
import { resolveReplyLimits } from '../../library/src/limits.mjs';
import { mapping, repo, MARKER } from '../../library/tests/helpers.mjs';
export const citedBody = citations => JSON.stringify({ text: 'A fixture answer.', citations });
export const fixtureCite = (commit, extra = {}) => ({ path: 'Book One/Characters/Mara Vell.md', commit, lines: [9, 9], quote: 'Grey eyes.', ...extra });
export function citationWorld(t, { definition = 'coder', library = true, guard: overrideGuard, organization = null, sendAuthority = true } = {}) {
const r = repo(t), root = r.root, state = join(root, 'bus'); mkdirSync(state, { mode: 0o700 });
const store = new Store(state); t.after(() => store.close());
const map = mapping(), settings = join(root, 'business.json');
writeFileSync(settings, JSON.stringify({ replyLimits: { requireCitation: false, citationsPerReply: 20 } }));
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
...(library ? { projects: { books: map } } : {}),
arbiters: { technical: 'coder', delivery: 'coder' },
roles: { coder: { definition, web: true, authority: { withinRole: sendAuthority ? ['message.send'] : [], crossRole: [] } } } };
const limits = () => {
try { return resolveReplyLimits({ readable: true, document: JSON.parse(readFileSync(settings, 'utf8')) }); }
catch { return resolveReplyLimits({ readable: false }); }
};
let served = r.commit;
const reads = [];
const adapter = {
GuardError,
replyLimits: limits,
guard: overrideGuard ?? ((context, body, opts) => checkReply(body, {
limits: opts.limits, mapping: map, library: libraryReader(r.repo, served, map),
web: { allowed: ['companion', 'research'].includes(opts.definition), pages: opts.pages },
})),
last: async (context, paths) => {
const out = new Map(); for (const path of new Set(paths)) { reads.push(path); out.set(path, await libraryReader(r.repo, served, map).last(path)); } return out;
},
readFile: async () => ({}), status: async () => ({ project: 'books', served }),
};
const broker = new Broker({ store, businesses: { acme: business }, library: adapter, organization });
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
const project = THREAD_DEFINITIONS.includes(definition)
? broker.request(human, { verb: 'project.create', args: { name: 'Citation fixture' } }) : null;
const thread = project
? broker.request(human, { verb: 'thread.create', args: { project: project.id, name: 'Fixture thread', instance: 'coder' } }) : null;
const launch = { business: 'acme', role: 'coder', run: 'coder-run', harness: 'pi', ...(thread ? { thread: thread.thread ?? thread.id } : {}) };
const agent = broker.bindLaunch(launch);
const tester = broker.bindTester({ business: 'acme' });
const call = (cap, verb, args = {}) => broker.request(cap, { verb, args });
return { ...r, root, store, business, broker, agent, human, tester, settings, limits, call, adapter, reads, launch, thread, setServed: commit => { served = commit; } };
}
export function guardCases(commit) {
const good = fixtureCite(commit);
return [
['reply-invalid', JSON.stringify({ text: 'x', citations: [], invented: true })],
['citation-missing', 'Uncited fixture answer.'],
['citations-over-limit', citedBody(Array(21).fill(good))],
['citation-invalid', citedBody([{ ...good, quote: 'wrong fixture quote' }])],
['playground-cited-as-canon', citedBody([{ ...good, path: 'Book One/Playground/What If.md', lines: [4, 4], quote: MARKER, as: 'canon' }])],
['citation-cross-universe', citedBody([good, { ...good, path: 'Second World/Chapters/01.md', lines: [1, 1], quote: MARKER }])],
['citation-unfetched', citedBody([{ url: 'https://fixture.invalid/page', quote: 'no fetch' }])],
];
}
@@ -0,0 +1,29 @@
// E7: exercise startBroker's real live readRaw adapter, rather than replacing the library adapter.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { validateBusinessDocument } from '../../business/src/business.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../business/tests/helpers.mjs';
import { mappingEntry } from '../../library/tests/helpers.mjs';
test('C2-06 runtime reads changed and unreadable reply limits without restarting', async t => {
const root = mkdtempSync(join(tmpdir(), 'citation-runtime-')); t.after(() => rmSync(root, { recursive: true, force: true }));
const doc = businessDoc(root), rolesDir = rolesCopy(root), dir = join(root, 'config'), dataRoot = join(root, 'data');
doc.projects.books = mappingEntry(); doc.replyLimits = { requireCitation: false, citationsPerReply: 3 };
const file = writeJson(join(dir, 'businesses/acme.json'), doc);
const business = validateBusinessDocument(doc, file, { rolesDir }); mkdirSync(dataRoot, { mode: 0o700 });
const runtime = await startBroker({ dataRoot, businesses: { acme: business }, organization: { dir, rolesDir } });
t.after(() => runtime.close());
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
const status = () => runtime.broker.request(human, { verb: 'library.status', args: { project: 'books' } });
assert.deepEqual((await status()).replyLimits, { requireCitation: false, citationsPerReply: 3, fallback: [], problem: null });
doc.replyLimits = { requireCitation: true, citationsPerReply: 7 }; writeJson(file, doc);
assert.deepEqual((await status()).replyLimits, { requireCitation: true, citationsPerReply: 7, fallback: [], problem: null });
writeFileSync(file, '{broken');
assert.deepEqual((await status()).replyLimits, { requireCitation: true, citationsPerReply: 20, fallback: [], problem: 'reply-limits-unreadable' });
rmSync(file);
assert.equal((await status()).replyLimits.problem, 'reply-limits-unreadable');
});
-70
View File
@@ -1,70 +0,0 @@
// Row297: fake GitHub over HTTPS, scratch CA only. No live credentials or provider calls.
import { generateKeyPairSync, randomBytes, verify } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { createServer } from 'node:https';
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
export async function fakeGithubApi(t, dir, { now = () => Date.now() } = {}) {
const tls = scratchCa(dir);
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const key = privateKey.export({ type: 'pkcs8', format: 'pem' });
const tokens = new Map();
const requests = [];
const state = { appId: 10, installationId: 20, accountId: 30, suspended: false,
permissions: { contents: 'write', metadata: 'read' }, repositories: [{ id: 101, full_name: 'acme/app' }] };
let intercept = null;
const server = createServer({ key: tls.key, cert: tls.cert }, async (req, res) => {
const url = new URL(req.url, 'https://fake.test');
let body = '';
for await (const part of req) body += part;
let json;
try { json = body ? JSON.parse(body) : null; } catch { res.writeHead(400).end(); return; }
const auth = (req.headers.authorization ?? '').replace(/^Bearer /, '');
const bits = auth.split('.');
let jwt = null;
if (bits.length === 3) {
try {
if (verify('RSA-SHA256', Buffer.from(bits.slice(0, 2).join('.')), publicKey, Buffer.from(bits[2], 'base64url')))
jwt = JSON.parse(Buffer.from(bits[1], 'base64url'));
} catch {}
}
const token = tokens.get(auth);
const entry = { method: req.method, path: url.pathname, query: url.search, body: json,
jwt: jwt ? { ...jwt } : null, auth: jwt ? 'app' : token ? 'installation' : 'none' };
requests.push(entry);
const answer = (status, value) => {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(value === undefined ? '' : JSON.stringify(value));
};
if (intercept && await intercept(entry, answer, { req, res, tokens })) return;
if (url.pathname === '/installation/token' && req.method === 'DELETE' && token) {
tokens.delete(auth); answer(204); return;
}
if (url.pathname === '/installation/repositories' && token && Date.parse(token.expires_at) > now()) {
const page = Number(url.searchParams.get('page') ?? 1);
const per = Number(url.searchParams.get('per_page') ?? 100);
answer(200, { total_count: token.repositories.length, repositories: token.repositories.slice((page - 1) * per, page * per) }); return;
}
const secs = Math.floor(now() / 1000);
if (!jwt || String(jwt.iss) !== String(state.appId) || jwt.iat > secs || jwt.exp <= secs || jwt.exp > secs + 600) {
answer(401, {}); return;
}
if (url.pathname === '/app') { answer(200, { id: state.appId }); return; }
if (url.pathname === `/app/installations/${state.installationId}` && req.method === 'GET') {
answer(200, { id: state.installationId, app_id: state.appId, account: { id: state.accountId },
suspended_at: state.suspended ? '2026-10-23T00:00:00Z' : null, permissions: state.permissions }); return;
}
if (url.pathname === `/app/installations/${state.installationId}/access_tokens` && req.method === 'POST') {
const value = 'ghs_fake_' + randomBytes(24).toString('hex');
const result = { token: value, expires_at: new Date(now() + 3600000).toISOString(),
permissions: json.permissions, repository_selection: 'selected',
repositories: state.repositories.filter((r) => json.repository_ids?.includes(r.id)) };
tokens.set(value, structuredClone(result)); answer(201, result); return;
}
answer(404, {});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(async () => { server.closeAllConnections(); await new Promise((resolve) => server.close(resolve)); });
return { url: `https://127.0.0.1:${server.address().port}`, ca: readFileSync(tls.ca, 'utf8'),
key, tokens, requests, state, intercept: (fn) => { intercept = fn; } };
}
-113
View File
@@ -1,113 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { generateKeyPairSync } from 'node:crypto';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { githubApi, githubMeta, githubJwt, FIXED_PERMISSIONS } from '../src/github-api.mjs';
import { fakeGithubApi } from './fake-github-api.mjs';
const at = Date.parse('2026-10-23T12:00:00Z');
const refuses = (fn, code) => assert.throws(fn, (e) => e.code === code);
async function fixture(t) {
const dir = mkdtempSync(join(tmpdir(), 'github297-api-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const fake = await fakeGithubApi(t, join(dir, 'tls'), { now: () => at });
const cfg = join(dir, 'config'); mkdirSync(cfg, { mode: 0o700 });
const api = githubApi({ configDir: cfg, ca: fake.ca, now: () => at });
const meta = { url: fake.url, appId: 10, installationId: 20, accountId: 30,
rotateBy: '2026-11-23', setAt: new Date(at).toISOString() };
const calls = [];
const log = async (op, fn) => { calls.push(['call', op]); const r = await fn(); calls.push(['result', r.status]); return r; };
return { fake, api, meta, cfg, calls, log };
}
test('R2 admin metadata refuses HTTP without a loopback exception', () => {
refuses(() => githubMeta({ url: 'http://127.0.0.1:1234', appId: 1, installationId: 2, accountId: 3,
rotateBy: '2026-11-23', setAt: new Date(at).toISOString() }), 'invalid-request');
});
test('R4 JWT uses the controlled clock, RS256, 60s backdate and bounded expiry', () => {
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const encoded = githubJwt(privateKey, 10, at);
const [header, body] = encoded.split('.').slice(0, 2).map((s) => JSON.parse(Buffer.from(s, 'base64url')));
assert.equal(header.alg, 'RS256');
assert.equal(body.iat, at / 1000 - 60);
assert.equal(body.exp, at / 1000 + 540);
assert.equal(body.iss, '10');
});
test('admin set checks the fake HTTPS identity and stores private files only after checks', async (t) => {
const f = await fixture(t);
await f.api.setAdmin('demo', f.meta, f.fake.key, f.log);
assert.deepEqual(f.api.adminMeta('demo'), f.meta);
assert.equal(statSync(join(f.cfg, 'secrets/grants/demo/github/admin')).mode & 0o777, 0o600);
assert.equal(f.calls.length, 4);
assert.deepEqual(f.fake.requests.map((r) => r.path), ['/app', '/app/installations/20']);
assert.equal(JSON.stringify(f.calls).includes('PRIVATE KEY'), false);
});
for (const [name, mutate, code] of [
['App identity', (f) => { f.meta.appId = 11; }, 'grant-admin-account'],
['installation account identity', (f) => { f.meta.accountId = 31; }, 'grant-admin-account'],
['suspended installation', (f) => { f.fake.state.suspended = true; }, 'grant-admin-account'],
['permission floor', (f) => { f.fake.state.permissions.contents = 'read'; }, 'grant-admin-scope'],
]) test(`C1-C4 replacement: admin set refuses ${name} and writes no slot`, async (t) => {
const f = await fixture(t); mutate(f);
await assert.rejects(f.api.setAdmin('demo', f.meta, f.fake.key, f.log), (e) => e.code === code);
assert.equal(existsSync(join(f.cfg, 'secrets/grants/demo/github/admin')), false);
});
test('admin set refuses a non-RSA key without making an API call', async (t) => {
const f = await fixture(t);
const { privateKey } = generateKeyPairSync('ed25519');
await assert.rejects(f.api.setAdmin('demo', f.meta, privateKey.export({ type: 'pkcs8', format: 'pem' }), f.log), (e) => e.code === 'credential-format');
assert.equal(f.fake.requests.length, 0);
});
test('admin set refuses HTTP before any call, and refuses to overwrite a slot', async (t) => {
const f = await fixture(t);
await assert.rejects(f.api.setAdmin('demo', { ...f.meta, url: f.fake.url.replace('https:', 'http:') }, f.fake.key, f.log), (e) => e.code === 'invalid-request');
assert.equal(f.fake.requests.length, 0);
await f.api.setAdmin('demo', f.meta, f.fake.key, f.log);
await assert.rejects(f.api.setAdmin('demo', f.meta, f.fake.key, f.log), (e) => e.code === 'grant-admin-exists');
});
test('mint sends fixed permissions and explicit repository IDs using an App JWT', async (t) => {
const f = await fixture(t);
await f.api.setAdmin('demo', f.meta, f.fake.key, f.log);
const r = await f.api.mint('demo', [101], f.log);
assert.equal(r.status, 201);
const req = f.fake.requests.at(-1);
assert.deepEqual(req.body, { repository_ids: [101], permissions: FIXED_PERMISSIONS });
assert.equal(req.auth, 'app');
assert.equal(f.fake.requests.every((x) => !JSON.stringify(x).includes('PRIVATE KEY')), true);
});
test('C2 validates the App response ID even with a successful JWT authentication', async (t) => {
const f = await fixture(t);
f.fake.intercept(async (r, answer) => {
if (r.path === '/app') { answer(200, { id: 11 }); return true; }
return false;
});
await assert.rejects(f.api.setAdmin('demo', f.meta, f.fake.key, f.log), (e) => e.code === 'grant-admin-account');
});
test('admin slot refuses unsafe file modes and symbolic links', async (t) => {
const f = await fixture(t); await f.api.setAdmin('demo', f.meta, f.fake.key, f.log);
const metaFile = join(f.cfg, 'secrets/grants/demo/github/admin.json');
chmodSync(metaFile, 0o644);
assert.throws(() => f.api.adminMeta('demo'), (e) => e.code === 'credential-file');
chmodSync(metaFile, 0o600);
const keyFile = join(f.cfg, 'secrets/grants/demo/github/admin');
rmSync(keyFile); symlinkSync(metaFile, keyFile);
await assert.rejects(f.api.checkAdmin('demo', f.log), (e) => e.code === 'credential-file');
});
for (const method of ['repos', 'revoke']) test(`N2 ${method} refuses a missing installation token before any call`, async (t) => {
const f = await fixture(t); await f.api.setAdmin('demo', f.meta, f.fake.key, f.log);
const before = f.fake.requests.length;
const run = () => method === 'repos' ? f.api.repos('demo', undefined, 1, f.log) : f.api.revoke('demo', undefined, f.log);
await assert.rejects(Promise.resolve().then(run), (e) => e.code === 'credential-format');
assert.equal(f.fake.requests.length, before);
});
-158
View File
@@ -1,158 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker, GRANT_EVIDENCE } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { githubApi } from '../src/github-api.mjs';
import { githubGrant } from '../src/github-grant.mjs';
import { fakeGithubApi } from './fake-github-api.mjs';
async function fixture(t) {
const dir = mkdtempSync(join(tmpdir(), 'github297-broker-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const fake = await fakeGithubApi(t, join(dir, 'tls'));
const configDir = join(dir, 'cfg'); mkdirSync(configDir, { mode: 0o700 });
mkdirSync(join(dir, 'data'), { mode: 0o700 });
const store = new Store(join(dir, 'data')); t.after(() => store.close());
const api = githubApi({ configDir, ca: fake.ca });
const manager = githubGrant({ api, configDir });
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' },
roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send', 'role.fire'], crossRole: [] } }, pm: { definition: 'pm', ...none },
coder: { definition: 'coder', github: { projects: ['app'] }, ...none } },
definitions: { 'chief-of-staff': { roleVersion: 2, credentials: [] }, pm: { roleVersion: 2, credentials: [] },
coder: { roleVersion: 2, credentials: [{ service: 'github' }] } },
projects: { app: { remote: fake.url + '/acme/app', githubId: 101 } }, launch: { by: 'chief', instances: ['pm', 'coder'] }, vars: { 'bus.tester': true } };
const writes = [];
let broker;
const organization = (id, op, args) => {
writes.push(op);
if (op === 'instance.fire') business.launch.instances = business.launch.instances.filter((n) => n !== args.name);
if (op === 'instances.set' && args.op === 'delete') {
delete business.roles[args.instance];
business.launch.instances = business.launch.instances.filter((n) => n !== args.instance);
}
broker.reloadBusiness('demo', business);
return { revision: 'b'.repeat(64), transaction: 'fake-transaction' };
};
broker = new Broker({ store, businesses: { demo: business }, githubGrant: manager, organization });
const caps = { human: broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
tester: broker.bindTester({ business: 'demo' }) };
for (const role of ['chief', 'pm']) {
caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
await broker.request(caps[role], { verb: 'role.claim' });
}
const call = (who, verb, args) => broker.request(caps[who], { verb, args });
const admin = { service: 'github', url: fake.url, appId: 10, installationId: 20, accountId: 30,
rotateBy: '2099-01-01', token: fake.key };
await call('human', 'grants.admin.set', admin);
const raise = () => call('chief', 'decision.raise', { action: 'credential.mint', question: 'Grant GitHub?',
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
grant: { method: 'github', agent: 'coder', target: { projects: ['app'] }, rotateBy: '2098-01-01' } });
const idle = async () => { await new Promise((resolve) => setImmediate(resolve)); await broker.grantsIdle(); };
const events = () => store.all(`SELECT body FROM events WHERE business='demo' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((e) => JSON.parse(e.body));
return { fake, store, broker, manager, business, call, raise, idle, events, admin, writes };
}
test('chief raise and human Approve start GitHub without a Gitea admin or host executor', async (t) => {
const f = await fixture(t);
const d = await f.raise();
assert.equal(f.fake.tokens.size, 0);
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
const status = await f.call('chief', 'grants.status', { service: 'github' });
assert.equal(status.grants.length, 1);
assert.equal(status.grants[0].decision, d.id);
await f.call('human', 'grants.revoke', { service: 'github', agent: 'coder', reason: 'test' });
assert.equal(f.fake.tokens.size, 0);
assert.equal((await f.call('human', 'grants.status', { service: 'github' })).grants.length, 0);
});
test('GitHub admin and manual run are human-only; arbitrary agents cannot raise', async (t) => {
const f = await fixture(t);
for (const who of ['chief', 'tester']) {
await assert.rejects(Promise.resolve().then(() => f.call(who, 'grants.admin.remove', { service: 'github' })), (e) => e.code === 'human-required');
await assert.rejects(Promise.resolve().then(() => f.call(who, 'grants.run', { decision: 'unapproved' })), (e) => e.code === 'human-required');
}
await assert.rejects(Promise.resolve().then(() => f.call('pm', 'decision.raise', { action: 'credential.mint',
question: 'no', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
grant: { method: 'github', agent: 'coder', target: { projects: ['app'] }, rotateBy: '2098-01-01' } })), (e) => e.code === 'grant-chief-only');
});
test('changing repository config after raise refuses before consume, then restart spends interrupted', async (t) => {
const f = await fixture(t); const d = await f.raise();
f.business.projects.app.githubId = 102; f.broker.reloadBusiness('demo', f.business);
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
assert.equal(f.fake.tokens.size, 0);
assert.equal(f.events().some((e) => e.operation === 'grant.github'), false);
f.broker.reconcileGrants();
assert.equal(f.events().filter((e) => e.operation === 'grant.github' && e.decision === d.id).length, 1);
});
test('no socket verb exposes a token or protected renewal', async (t) => {
const f = await fixture(t);
for (const verb of ['grants.token', 'grants.renew', 'github.token'])
await assert.rejects(Promise.resolve().then(() => f.call('human', verb, { agent: 'coder' })), (e) => e.code === 'unknown-verb');
});
test('an open approval revoked before Yes cannot be used by the late automatic executor', async (t) => {
const f = await fixture(t); const d = await f.raise();
await f.call('human', 'grants.revoke', { service: 'github', agent: 'coder', reason: 'cancel' });
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
assert.equal(f.fake.tokens.size, 0);
assert.equal(f.manager.status('demo').grants.length, 0);
});
test('grant evidence remains reserved from an agent or direct human record insert', async (t) => {
const f = await fixture(t);
assert.throws(() => f.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('forged','2026-10-23T00:00:00Z','demo','action.allowed',NULL,NULL,NULL,?)",
JSON.stringify({ action: 'grant.applied', method: 'github', agent: 'coder' })));
assert.equal(f.manager.status('demo').grants.length, 0);
});
for (const failDelete of [false, true]) test(`fire revokes GitHub locally and ${failDelete ? 'stops on remote leftovers' : 'completes only after remote cleanup'}`, async (t) => {
const f = await fixture(t); const d = await f.raise();
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
if (failDelete) f.fake.intercept(async (r, answer) => { if (r.method === 'DELETE') { answer(503, {}); return true; } return false; });
await f.call('chief', 'instance.fire', { name: 'coder', reason: 'test' }); await f.idle();
assert.equal(f.manager.status('demo').grants.length, 0);
assert.equal(f.writes.includes('instance.fire'), !failDelete);
assert.equal(f.manager.status('demo').serviceLeft.length, failDelete ? 1 : 0);
});
test('lifecycle delete waits for the protected GitHub revoke job', async (t) => {
const f = await fixture(t); const d = await f.raise();
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
const { request } = await f.call('human', 'instances.set', { revision: 'b'.repeat(64), instance: 'coder', op: 'delete' });
assert.equal(f.broker.lifecycleTake({ business: 'demo', request }).go, true);
assert.throws(() => f.broker.lifecycleDelete({ business: 'demo', request }), (e) => e.code === 'grants-revoking');
f.broker.lifecycleDone({ business: 'demo', request, refusal: 'grants-revoking' });
await f.idle();
assert.equal(f.manager.status('demo').grants.length, 0);
assert.equal(f.fake.tokens.size, 0);
assert.equal(Object.hasOwn(f.business.roles, 'coder'), true);
const second = await f.call('human', 'instances.set', { revision: 'b'.repeat(64), instance: 'coder', op: 'delete' });
assert.equal(f.broker.lifecycleTake({ business: 'demo', request: second.request }).go, true);
const removed = f.broker.lifecycleDelete({ business: 'demo', request: second.request });
assert.equal(removed.retry, false);
assert.equal(removed.transaction, 'fake-transaction');
assert.equal(Object.hasOwn(f.business.roles, 'coder'), false);
assert.equal(f.writes.filter((op) => op === 'instances.set').length, 1);
});
test('admin rotate preserves installation identity and invalidates open approved bindings', async (t) => {
const f = await fixture(t); const d = await f.raise();
await f.call('human', 'grants.admin.rotate', { service: 'github', rotateBy: '2099-02-01', token: f.fake.key });
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
assert.equal(f.fake.tokens.size, 0);
assert.equal(f.manager.status('demo').admin.installationId, 20);
assert.equal(f.manager.status('demo').grants.length, 0);
});
for (const args of [{}, { decision: null }, { decision: 'bad/id' }, { decision: 'valid', extra: true }])
test('N7 manual run validates shape and identifier before approval lookup: ' + JSON.stringify(args), async (t) => {
const f = await fixture(t);
await assert.rejects(Promise.resolve().then(() => f.call('human', 'grants.run', args)), (e) => e.code === 'invalid-request');
assert.equal(f.fake.tokens.size, 0);
});
-412
View File
@@ -1,412 +0,0 @@
// Row297 contract tests. Run only under Sage CLEAR; fake HTTPS API, no live token.
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { Store } from '../src/store.mjs';
import { GRANT_EVIDENCE, writeGrantEvent } from '../src/broker.mjs';
import { githubApi, FIXED_PERMISSIONS } from '../src/github-api.mjs';
import { githubGrant, TOKEN_LIFETIME_MS, CLOCK_SKEW_MS, TOKEN_MARGIN_MS, CALL_TIMEOUT_MS } from '../src/github-grant.mjs';
import { fakeGithubApi } from './fake-github-api.mjs';
import { githubDirectory, githubWrite } from '../src/github-files.mjs';
const START = Date.parse('2026-10-23T12:00:00Z');
const rejectCode = (p, code) => assert.rejects(p, (e) => e.code === code);
async function fixture(t, options = {}) {
const dir = mkdtempSync(join(tmpdir(), 'github297-grant-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
let time = START;
const now = () => time;
const fake = await fakeGithubApi(t, join(dir, 'tls'), { now });
const configDir = join(dir, 'config'); mkdirSync(configDir, { mode: 0o700 });
mkdirSync(join(dir, 'data'), { mode: 0o700 });
const store = new Store(join(dir, 'data'));
t.after(() => store.close());
const business = { id: 'demo', roles: { coder: { definition: 'coder', github: { projects: ['app'] } } },
definitions: { coder: { roleVersion: 2, credentials: [{ service: 'github' }] } },
projects: { app: { remote: fake.url + '/acme/app', githubId: 101 } } };
const approved = new Map();
const event = (id, body) => store.transaction(() => writeGrantEvent(store, new Date(now()).toISOString(), id, body, body.agent ?? null));
const api = githubApi({ configDir, ca: fake.ca, now, timeoutMs: options.timeoutMs ?? CALL_TIMEOUT_MS });
const meta = { url: fake.url, appId: 10, installationId: 20, accountId: 30,
rotateBy: '2026-11-23', setAt: new Date(now()).toISOString() };
const log = async (op, fn) => fn(); // Initial admin fixture; executor calls use reserved event writer.
await api.setAdmin('demo', meta, fake.key, log);
const spent = new Set();
let working = true;
const create = () => {
const manager = githubGrant({ api, configDir, now, timeoutMs: options.timeoutMs ?? CALL_TIMEOUT_MS });
manager.configure({ store, business: () => business, event,
approved: (id, decision) => approved.get(decision) ?? null,
consume: (id, decision) => {
if (spent.has(decision)) throw Object.assign(new Error('decision-consumed'), { code: 'decision-consumed' });
spent.add(decision); event(id, { operation: 'grant.github', decision }); return approved.get(decision);
}, grantable: () => {}, working: () => working });
return manager;
};
const manager = create();
const grant = { method: 'github', agent: 'coder', target: { projects: ['app'] }, rotateBy: '2026-11-20' };
const raise = () => {
const decision = randomUUID();
approved.set(decision, manager.raiseBinding('demo', grant).body);
return decision;
};
const events = () => store.all(`SELECT body FROM events WHERE business='demo' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((r) => JSON.parse(r.body));
const apply = async () => { const decision = raise(); await manager.run('demo', decision); return decision; };
return { dir, configDir, store, business, fake, api, manager, meta, grant, now, raise, apply,
events, approved, spent, create, advance: (ms) => { time += ms; }, working: (v) => { working = v; } };
}
test('R4 lifetime, skew, margin and request bounds are explicit constants', () => {
assert.equal(TOKEN_LIFETIME_MS, 3600000);
assert.equal(CLOCK_SKEW_MS, 60000);
assert.equal(TOKEN_MARGIN_MS, 60000);
assert.equal(CALL_TIMEOUT_MS, 15000);
});
test('grant binding cannot name scopes, accounts or fork creation', async (t) => {
const f = await fixture(t);
for (const extra of [{ scopes: ['all'] }, { account: 'foreign' }, { fork: true }])
assert.throws(() => f.manager.raiseBinding('demo', { ...f.grant, ...extra }), (e) => e.code === 'invalid-request');
assert.equal(f.events().length, 0);
});
test('initial approval creates an exact durable grant and guarded token use', async (t) => {
const f = await fixture(t);
const decision = await f.apply();
const [g] = f.manager.status('demo').grants;
assert.equal(g.agent, 'coder');
assert.equal(g.decision, decision);
assert.deepEqual(g.repositoryIds, [101]);
assert.equal(g.expiresAt, new Date(START + TOKEN_LIFETIME_MS).toISOString());
let used = false;
await f.manager.withToken('demo', 'coder', async (token, signal) => {
used = f.fake.tokens.has(token); assert.equal(signal.aborted, false);
});
assert.ok(used);
const events = f.events();
const calls = events.filter((e) => e.action === 'grant.call');
assert.ok(calls.length >= 4);
for (const call of calls) {
const result = events.find((e) => e.action === 'grant.result' && e.callId === call.callId);
assert.ok(result);
assert.ok(events.indexOf(result) > events.indexOf(call));
}
assert.equal(events.filter((e) => Object.hasOwn(e, 'decision')).length, 1);
const text = JSON.stringify(events);
for (const token of f.fake.tokens.keys()) assert.ok(!text.includes(token));
assert.ok(!text.includes('PRIVATE KEY'));
assert.ok(!text.includes('Bearer'));
});
for (const [label, mutation] of [
['extra permission', (b) => { b.permissions.workflows = 'write'; }],
['higher metadata level', (b) => { b.permissions.metadata = 'write'; }],
['missing permission', (b) => { delete b.permissions.metadata; }],
['all repositories', (b) => { b.repository_selection = 'all'; }],
['extra repository', (b) => { b.repositories.push({ id: 102, full_name: 'acme/other' }); }],
['missing repository', (b) => { b.repositories = []; }],
['duplicate repository', (b) => { b.repositories.push({ ...b.repositories[0] }); }],
['past expiry', (b) => { b.expires_at = new Date(START).toISOString(); }],
['missing expiry', (b) => { delete b.expires_at; }],
['excessive lifetime', (b) => { b.expires_at = new Date(START + TOKEN_LIFETIME_MS + CLOCK_SKEW_MS + 1).toISOString(); }],
]) test(`R1/R4 mint refuses ${label} before any guarded use and revokes candidate`, async (t) => {
const f = await fixture(t);
let repoReads = 0;
f.fake.intercept(async (r, answer, { tokens }) => {
if (r.path === '/installation/repositories') repoReads++;
if (r.method !== 'POST') return false;
const token = 'ghs_fake_candidate_12345678901234567890';
const body = { token, expires_at: new Date(START + TOKEN_LIFETIME_MS).toISOString(),
permissions: { ...FIXED_PERMISSIONS }, repository_selection: 'selected', repositories: [{ id: 101, full_name: 'acme/app' }] };
tokens.set(token, structuredClone(body)); mutation(body); answer(201, body); return true;
});
await rejectCode(f.apply(), 'github-token-invalid');
assert.equal(f.manager.status('demo').grants.length, 0);
assert.equal(repoReads, 0);
assert.equal(f.fake.tokens.size, 0);
});
test('R4 renewal starts at the safety margin and uses a new token under the same decision', async (t) => {
const f = await fixture(t); const decision = await f.apply();
const old = [...f.fake.tokens.keys()][0];
f.advance(TOKEN_LIFETIME_MS - TOKEN_MARGIN_MS - 1);
await f.manager.withToken('demo', 'coder', () => {});
assert.equal(f.fake.tokens.size, 1);
f.advance(1);
await f.manager.withToken('demo', 'coder', (token) => { assert.ok(token !== old); });
assert.equal(f.fake.tokens.has(old), false);
assert.equal(f.manager.status('demo').grants[0].decision, decision);
assert.equal(f.events().filter((e) => e.operation === 'grant.github').length, 1);
});
for (const [label, change] of [
['project repository ID', (b) => { b.projects.app.githubId = 102; }],
['project remote', (b) => { b.projects.app.remote += '-changed'; }],
['allowed projects', (b) => { b.roles.coder.github.projects.push('other'); }],
['definition', (b) => { b.definitions.coder.title = 'changed'; }],
]) test(`R5 config change to ${label} refuses renewal and never uses old token`, async (t) => {
const f = await fixture(t); await f.apply();
change(f.business); f.advance(TOKEN_LIFETIME_MS);
let used = false;
await rejectCode(f.manager.withToken('demo', 'coder', () => { used = true; }), 'decision-mismatch');
assert.equal(used, false);
});
test('renewal refuses after the durable grant deadline or processing halt', async (t) => {
const f = await fixture(t); await f.apply();
f.working(false);
await rejectCode(f.manager.withToken('demo', 'coder', () => {}), 'processing-halted');
f.working(true); f.advance(40 * 86400000);
await rejectCode(f.manager.withToken('demo', 'coder', () => {}), 'grant-expired');
});
test('local revoke wins a pending renewal; candidate is revoked and grant never revives', async (t) => {
const f = await fixture(t); await f.apply(); f.advance(TOKEN_LIFETIME_MS);
let release, entered;
const held = new Promise((resolve) => { entered = resolve; });
const gate = new Promise((resolve) => { release = resolve; });
f.fake.intercept(async (r) => {
if (r.method === 'POST') { entered(); await gate; }
return false;
});
const pending = f.manager.withToken('demo', 'coder', () => assert.fail('late token used'));
// Attach the rejection handler before releasing the network call.
const refused = rejectCode(pending, 'revoked');
await held;
await f.manager.revoke('demo', 'coder', 'test', 'jason');
assert.equal(f.manager.status('demo').grants.length, 0);
release(); await refused;
assert.equal(f.manager.status('demo').grants.length, 0);
assert.equal(f.fake.tokens.size, 0);
});
test('revoke blocks guarded use before a remote delete completes; failure is serviceLeft', async (t) => {
const f = await fixture(t); await f.apply();
let localGone = false;
f.fake.intercept(async (r, answer) => {
if (r.method === 'DELETE') { localGone = f.manager.status('demo').grants.length === 0; answer(503, {}); return true; }
return false;
});
await f.manager.revoke('demo', 'coder', 'test', 'jason');
assert.ok(localGone);
await rejectCode(f.manager.withToken('demo', 'coder', () => assert.fail('revoked token used')), 'github-not-granted');
assert.equal(f.manager.status('demo').serviceLeft.length, 1);
f.fake.intercept(null); await f.manager.sweep('demo', 'jason');
assert.equal(f.fake.tokens.size, 0);
assert.equal(f.manager.status('demo').serviceLeft.length, 0);
});
test('lost mint response stays visible through restart until conservative expiry equality', async (t) => {
const f = await fixture(t);
f.fake.intercept(async (r, answer, { req }) => {
if (r.method === 'POST') { req.socket.destroy(); return true; }
return false;
});
await rejectCode(f.apply(), 'github-failed');
const status = f.manager.status('demo');
assert.equal(status.serviceLeft.length, 1);
const until = Date.parse(status.serviceLeft[0].until);
assert.equal(until, START + CALL_TIMEOUT_MS + TOKEN_LIFETIME_MS + CLOCK_SKEW_MS);
const restarted = f.create(); restarted.reconcile('demo');
f.advance(until - f.now() - 1); await restarted.sweep('demo', 'jason');
assert.equal(restarted.status('demo').serviceLeft.length, 1);
f.advance(1); await restarted.sweep('demo', 'jason');
assert.equal(restarted.status('demo').serviceLeft.length, 0);
});
// Kills removal of the approval/revocation boundary check.
test('a revoke invalidates an older unspent approval, even when no token was issued', async (t) => {
const f = await fixture(t); const decision = f.raise();
await f.manager.revoke('demo', 'coder', 'cancel approval', 'jason');
await rejectCode(f.manager.run('demo', decision), 'revoked');
assert.equal(f.spent.has(decision), false);
assert.equal(f.fake.tokens.size, 0);
});
test('verification reads every page and binds repository IDs to their configured names', async (t) => {
const f = await fixture(t);
const projects = [];
f.fake.state.repositories = [];
for (let i = 0; i < 31; i++) {
const p = 'repo' + i; projects.push(p);
f.business.projects[p] = { githubId: 1000 + i, remote: f.fake.url + '/acme/' + p };
f.fake.state.repositories.push({ id: 1000 + i, full_name: 'acme/' + p });
}
f.business.roles.coder.github.projects = projects;
f.grant.target.projects = projects;
await f.apply();
assert.deepEqual(f.fake.requests.filter((r) => r.path === '/installation/repositories').map((r) => r.query),
['?per_page=30&page=1', '?per_page=30&page=2']);
assert.equal(f.manager.status('demo').grants[0].repositoryIds.length, 31);
});
test('verification refuses wider reach and deletes the candidate', async (t) => {
const f = await fixture(t);
f.fake.intercept(async (r, answer) => {
if (r.path !== '/installation/repositories') return false;
answer(200, { total_count: 2, repositories: [{ id: 101, full_name: 'acme/app' }, { id: 102, full_name: 'acme/foreign' }] });
return true;
});
await rejectCode(f.apply(), 'github-reach-unknown');
assert.equal(f.fake.tokens.size, 0);
assert.equal(f.manager.status('demo').grants.length, 0);
});
test('token replacement is usable before old-token delete and not after grant revocation', async (t) => {
const f = await fixture(t); await f.apply();
const oldGeneration = f.manager.status('demo').grants[0].generation;
f.fake.intercept(async (r) => {
if (r.method === 'DELETE') {
const active = f.manager.status('demo').grants[0];
assert.ok(active.generation !== oldGeneration);
assert.equal(f.fake.tokens.size, 2);
}
return false;
});
f.advance(TOKEN_LIFETIME_MS - TOKEN_MARGIN_MS);
await f.manager.withToken('demo', 'coder', () => {});
assert.equal(f.fake.tokens.size, 1);
});
test('failed renewal never calls the consumer with the previous token', async (t) => {
const f = await fixture(t); await f.apply();
f.advance(TOKEN_LIFETIME_MS - TOKEN_MARGIN_MS);
f.fake.intercept(async (r, answer) => {
if (r.method === 'POST') { answer(503, {}); return true; }
return false;
});
let called = false;
await rejectCode(f.manager.withToken('demo', 'coder', () => { called = true; }), 'github-failed');
assert.equal(called, false);
});
test('admin metadata replacement invalidates an approved durable binding', async (t) => {
const f = await fixture(t); await f.apply();
await f.api.setAdmin('demo', { ...f.meta, setAt: new Date(START + 1).toISOString() }, f.fake.key, async (op, fn) => fn(), { replace: true });
f.advance(TOKEN_LIFETIME_MS);
await rejectCode(f.manager.withToken('demo', 'coder', () => {}), 'decision-mismatch');
});
test('preflight identity or permission failure is unspent and a retry can run before restart', async (t) => {
const f = await fixture(t); const decision = f.raise();
f.fake.state.permissions.contents = 'read';
await rejectCode(f.manager.run('demo', decision), 'grant-admin-scope');
assert.equal(f.spent.has(decision), false);
f.fake.state.permissions.contents = 'write';
await f.manager.run('demo', decision);
assert.equal(f.spent.has(decision), true);
});
test('an active generation restarts only under its recorded durable approval and timestamp', async (t) => {
const f = await fixture(t); await f.apply();
const restarted = f.create(); restarted.reconcile('demo');
await restarted.withToken('demo', 'coder', (token) => { assert.ok(f.fake.tokens.has(token)); });
f.advance(TOKEN_LIFETIME_MS);
f.business.projects.app.githubId = 102;
await rejectCode(restarted.withToken('demo', 'coder', () => {}), 'decision-mismatch');
});
test('look-alike applied evidence under another kind cannot make a live grant', async (t) => {
const f = await fixture(t); const decision = f.raise();
f.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.refused',NULL,'grant',NULL,?)",
randomUUID(), new Date(START).toISOString(), 'demo', JSON.stringify({ action: 'grant.applied', method: 'github',
agent: 'coder', grantDecision: decision, generation: randomUUID(), binding: f.approved.get(decision), expiresAt: new Date(START + TOKEN_LIFETIME_MS).toISOString() }));
assert.equal(f.manager.status('demo').grants.length, 0);
await rejectCode(f.manager.withToken('demo', 'coder', () => {}), 'github-not-granted');
});
test('mint intent and grant.call are durable before the fake sees each request', async (t) => {
const f = await fixture(t); const observed = [];
f.fake.intercept(async (r) => {
const events = f.events();
const call = events.findLast((e) => e.action === 'grant.call' && e.method === 'github' && e.op.method === r.method && e.op.path === r.path + r.query);
observed.push(Boolean(call && !events.some((e) => e.action === 'grant.result' && e.callId === call.callId)));
if (r.method === 'POST') observed.push(events.some((e) => e.action === 'grant.step' && e.step === 'mint'));
return false;
});
await f.apply();
assert.ok(observed.length >= 5);
assert.ok(observed.every(Boolean));
});
test('an interrupted service call without its result stays visible after restart', async (t) => {
const f = await fixture(t); const callId = randomUUID();
writeGrantEvent(f.store, new Date(START).toISOString(), 'demo', { action: 'grant.call', method: 'github', callId,
grantDecision: null, agent: null, generation: null, op: { method: 'GET', path: '/app' } });
const restarted = f.create(); restarted.reconcile('demo');
assert.deepEqual(restarted.status('demo').unclosedCalls.map((e) => e.callId), [callId]);
});
test('restart never adopts a known token from an interrupted mint and sweep revokes it', async (t) => {
const f = await fixture(t); const decision = f.raise(), generation = randomUUID();
const result = await f.api.mint('demo', [101], async (op, fn) => fn());
writeGrantEvent(f.store, new Date(START).toISOString(), 'demo', { operation: 'grant.github', decision });
writeGrantEvent(f.store, new Date(START).toISOString(), 'demo', { action: 'grant.step', method: 'github', step: 'mint', agent: 'coder',
grantDecision: decision, generation, until: new Date(START + CALL_TIMEOUT_MS + TOKEN_LIFETIME_MS + CLOCK_SKEW_MS).toISOString(), admin: f.approved.get(decision).admin });
githubWrite(githubDirectory(f.configDir, ['grants', 'demo', 'github', 'tokens', 'coder'], true), generation, result.json.token + '\n');
const restarted = f.create(); restarted.reconcile('demo');
assert.equal(restarted.status('demo').grants.length, 0);
await rejectCode(restarted.withToken('demo', 'coder', () => {}), 'github-not-granted');
await restarted.sweep('demo', 'restart');
assert.equal(f.fake.tokens.size, 0);
assert.equal(restarted.status('demo').serviceLeft.length, 0);
});
test('the per-agent claim refuses a second guarded use while the first callback is live', async (t) => {
const f = await fixture(t); await f.apply();
let release, entered;
const held = new Promise((r) => { entered = r; }), gate = new Promise((r) => { release = r; });
const first = f.manager.withToken('demo', 'coder', async () => { entered(); await gate; });
await held;
await rejectCode(f.manager.withToken('demo', 'coder', () => assert.fail('second consumer')), 'grant-busy');
release(); await first;
await f.manager.withToken('demo', 'coder', () => {});
});
test('N1 sweep closes an expired active generation while preserving its renewable grant', async (t) => {
const f = await fixture(t); const decision = await f.apply();
const active = f.manager.status('demo').grants[0];
const file = join(f.configDir, 'secrets/grants/demo/github/tokens/coder', active.generation);
const intent = f.events().find((e) => e.action === 'grant.step' && e.generation === active.generation);
f.advance(Date.parse(intent.until) - f.now() - 1); await f.manager.sweep('demo', 'jason');
assert.ok(existsSync(file));
f.advance(1); await f.manager.sweep('demo', 'jason');
assert.equal(existsSync(file), false);
assert.equal(f.manager.status('demo').grants[0].decision, decision);
assert.ok(f.events().some((e) => e.action === 'grant.token.closed' && e.generation === active.generation && e.reason === 'expired'));
await f.manager.withToken('demo', 'coder', (token) => assert.ok(f.fake.tokens.has(token)));
assert.ok(f.manager.status('demo').grants[0].generation !== active.generation);
});
test('mutant: closeToken must not count a 401 as proven revocation', async (t) => {
const f = await fixture(t); await f.apply();
f.fake.intercept(async (r, answer) => { if (r.method === 'DELETE') { answer(401, {}); return true; } return false; });
await f.manager.revoke('demo', 'coder', 'test', 'jason');
assert.equal(f.manager.status('demo').serviceLeft.length, 1);
assert.equal(f.fake.tokens.size, 1);
});
for (const status of [500, 503]) test(`mutant: mint ${status} is uncertain issuance and stays serviceLeft`, async (t) => {
const f = await fixture(t);
f.fake.intercept(async (r, answer) => { if (r.method === 'POST') { answer(status, {}); return true; } return false; });
await rejectCode(f.apply(), 'github-failed');
assert.equal(f.manager.status('demo').serviceLeft.length, 1);
assert.equal(f.events().some((e) => e.action === 'grant.token.closed' && e.reason === 'not-issued'), false);
});
test('mutant: shutdown abort alone prevents a pending generation from applying', async (t) => {
const f = await fixture(t); const decision = f.raise();
let release, entered;
const held = new Promise((resolve) => { entered = resolve; }), gate = new Promise((resolve) => { release = resolve; });
f.fake.intercept(async (r) => { if (r.method === 'POST') { entered(); await gate; } return false; });
const pending = f.manager.run('demo', decision);
const refused = rejectCode(pending, 'revoked');
await held;
const closed = f.manager.close(); release();
await refused; await closed;
assert.equal(f.manager.status('demo').grants.length, 0);
assert.equal(f.fake.tokens.size, 0);
});
+5 -19
View File
@@ -21,7 +21,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
// How long a thread's session stays up with no turn before the launcher stops
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
export const THREAD_IDLE_SECONDS = 900;
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired"];
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits"];
// The config directory: next to the system config, $MOSAIC_CONFIG or
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
@@ -129,7 +129,7 @@ function checkRoles(roles, file, rolesDir, business) {
requireId(instance, `${where} instance name`);
const at = `${where}.${instance}`;
requireObject(entry, at);
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish", "hosts", "github"], at);
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish", "hosts"], at);
requireId(entry.definition, `${at}.definition`);
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition);
definitions[entry.definition] = definition;
@@ -137,7 +137,7 @@ function checkRoles(roles, file, rolesDir, business) {
const services = definition.credentials.map((c) => c.service);
if (entry.hosts !== undefined) refuse(`${at}.hosts ${HOSTS_MOVED}`);
// A host.<id> key reference comes from the agent's grant record at bus start (r6 X4), never from here.
const kinds = services.filter((s) => s !== HOST_SERVICE && s !== "github");
const kinds = services.filter((s) => s !== HOST_SERVICE);
let tracker = null;
if (services.includes("vikunja")) {
if (entry.tracker === undefined) refuse(`${at} needs "tracker" with its Vikunja bot, because ${entry.definition} uses vikunja`);
@@ -166,14 +166,6 @@ function checkRoles(roles, file, rolesDir, business) {
if (!out[instance].identity || !services.includes("gitea")) refuse(`${at}.publish needs identity and a gitea definition`);
out[instance].publish = requireDistinctList(entry.publish, `${at}.publish`, (p) => requireId(p, `${at}.publish entry`), { nonEmpty: true });
}
// Row297: App grants have fixed scopes and protected token storage, not a generic credential ref.
if (entry.github !== undefined) {
if (!services.includes("github")) refuse(`${at}.github needs a github definition`);
requireObject(entry.github, `${at}.github`);
rejectUnknownKeys(entry.github, ["projects"], `${at}.github`);
out[instance].github = { projects: requireDistinctList(entry.github.projects, `${at}.github.projects`,
(p) => requireId(p, `${at}.github.projects entry`), { nonEmpty: true }) };
}
checkPersonaVars(out[instance].vars, entry.definition, at);
}
return { roles: out, definitions };
@@ -275,7 +267,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
projects[id] = checkMapping(entry, `${file} projects.${id}`, { fail: (code, message) => refuse(`${code}: ${message}`) });
continue;
}
rejectUnknownKeys(entry, ["root", "repo", "remote", "githubId"], `${file} projects.${id}`);
rejectUnknownKeys(entry, ["root", "repo", "remote"], `${file} projects.${id}`);
if (typeof entry.root !== "string" || !isAbsolute(entry.root) || normalize(entry.root) !== entry.root || entry.root.includes("\0")) {
refuse(`${file} projects.${id}.root must be a normalized absolute path`);
}
@@ -285,16 +277,10 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
}
// Row 199 (#1697, design D9 step 2): the upstream; a granted agent's work goes to its fork.
if (Object.hasOwn(entry, "remote")) httpsUrl(entry.remote, `${file} projects.${id}.remote`, { repo: true });
if (entry.githubId !== undefined) requirePositiveInt(entry.githubId, `${file} projects.${id}.githubId`);
projects[id] = { root: entry.root, ...(entry.githubId !== undefined ? { githubId: entry.githubId } : {}), ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}),
projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}),
...(Object.hasOwn(entry, "remote") ? { remote: entry.remote } : {}) };
}
for (const [instance, role] of Object.entries(roles)) {
for (const project of role.github?.projects ?? []) {
const p = projects[project];
if (!p || p.kind === 'library' || !p.remote || !Number.isSafeInteger(p.githubId) || p.githubId <= 0)
refuse(`${file} roles.${instance}.github names ${project}, which needs a repository remote and githubId`);
}
for (const project of role.publish ?? []) {
if (!Object.hasOwn(projects, project)) refuse(`${file} roles.${instance}.publish names ${project}, which the business file doesn't declare`);
if (projects[project].kind === 'library') refuse(`${file} roles.${instance}.publish names library project ${project}; library repositories cannot be published by agents`);
+1 -1
View File
@@ -108,7 +108,7 @@ function definitionFor(rolesDir, name, { credentials = false } = {}) {
// Vikunja bot the human names. The add makes no Vikunja account. Any other definition refuses it.
function createdCredentials(old, instance, definition, args) {
// webby-0094 N2: a host grant is no token reference (business.mjs checkRoles drops it too).
const services = definition.credentials.map(c => c.service).filter(service => service !== HOST_SERVICE && service !== "github");
const services = definition.credentials.map(c => c.service).filter(service => service !== HOST_SERVICE);
const out = {};
if (services.includes("vikunja")) {
if (!Object.hasOwn(args, "tracker")) {
+44 -1
View File
@@ -8,6 +8,7 @@ import { join, dirname } from "node:path";
import { randomUUID } from "node:crypto";
import { businessFilePath, validateBusinessDocument } from "./business.mjs";
import { requireId, sha256, canonicalJson } from "./util.mjs";
import { checkReplyLimits } from "../../library/src/limits.mjs";
export class OrganizationError extends Error {
constructor(code, message = code) {
@@ -80,6 +81,33 @@ function changes(old, next, path = "") {
}];
}
// C2-06 M11: replyLimits is the human's alone. Any setter that changes it refuses unless the actor is
// the business's human as the file stood before the write, and no agent or the tester under that
// name. Compared as M9 compares projects, on the document and again on what lands on disk.
function replyLimitsFence(old, document, actor) {
if (canonicalJson(document?.replyLimits) === canonicalJson(old.replyLimits)) return;
const agents = Object.entries(old.roles ?? {}).map(([instance, entry]) => `${entry?.definition}.${instance}`);
if (actor === old.human && actor !== "tester" && !agents.includes(actor)) return;
// The reason names each changed key and its value in force (the brief's M11).
const plain = v => v !== null && typeof v === "object" && !Array.isArray(v);
const before = old.replyLimits, after = document?.replyLimits;
const named = plain(before) && plain(after)
? [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(k => canonicalJson(before[k]) !== canonicalJson(after[k]))
.map(k => `replyLimits.${k} is ${canonicalJson(before[k]) ?? "unset"}`)
: [`replyLimits is ${canonicalJson(before) ?? "unset"}`];
fail("organization-reply-limits-human-only", `${named.join(", ").slice(0, 240)}; only the human changes it`);
}
// C2-06: library.limits.set, the human's verb. It replaces the whole replyLimits object; a key left out
// is its default. An unknown key or a value outside the table refuses invalid-request, and the write's
// M11 fence refuses any actor but the human.
export function setReplyLimits(writer, id, args, actor) {
const replyLimits = checkReplyLimits(args?.replyLimits);
if (replyLimits === null || typeof args?.revision !== "string") fail("invalid-request", "replyLimits takes requireCitation and citationsPerReply only");
const { document } = writer.read(id);
return writer.write(id, { ...document, replyLimits }, { revision: args.revision, actor });
}
export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
const evidence = join(dir, "businesses", ".history");
const logFile = id => join(evidence, id, "changes.jsonl");
@@ -93,6 +121,19 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
return { document, revision: sha256(bytes) };
}
// C2-06: the citation guard reads replyLimits fresh on every check, from the raw file, so a business
// file that fails validation still yields its limits. { readable: false } when the file is missing,
// unreadable, not JSON or not an object.
function readRaw(id) {
try {
const document = JSON.parse(regular(businessFilePath(id, dir)));
return document !== null && typeof document === "object" && !Array.isArray(document)
? { readable: true, document } : { readable: false };
} catch {
return { readable: false };
}
}
function withLock(id, fn) {
const parent = join(dir, "businesses");
directory(evidence);
@@ -191,6 +232,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
// mapping, including library playgrounds and hybrid universes.
if (canonicalJson(document?.projects) !== canonicalJson(old.projects))
fail('organization-projects-protected');
replyLimitsFence(old, document, actor);
validateBusinessDocument(document, file, { rolesDir });
const bytes = JSON.stringify(document, null, 2) + "\n";
// Check and log exactly what lands on disk, for every setter: a value JSON
@@ -198,6 +240,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
const written = JSON.parse(bytes);
if (canonicalJson(written.projects) !== canonicalJson(old.projects))
fail('organization-projects-protected');
replyLimitsFence(old, written, actor);
validateBusinessDocument(written, file, { rolesDir });
const after = sha256(bytes);
const transaction = randomUUID();
@@ -245,5 +288,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
});
}
return { logFile, read, write, record, recover: id => withLock(id, () => recover(id)) };
return { logFile, read, readRaw, write, record, recover: id => withLock(id, () => recover(id)) };
}
+3 -5
View File
@@ -131,15 +131,13 @@ function checkCredentials(credentials) {
requireObject(entry, `role credentials[${i}]`);
// Row 210 (#1707, design D1): "host" takes no scopes; the business file names the hosts.
const host = entry.service === HOST_SERVICE;
const github = entry.service === "github";
rejectUnknownKeys(entry, (host || github) ? ["service"] : ["service", "scopes"], `role credentials[${i}]`);
if (!host && !github && !SERVICES.includes(entry.service)) {
refuse(`role credentials[${i}].service must be one of: ${[...SERVICES, HOST_SERVICE, "github"].join(", ")}`);
rejectUnknownKeys(entry, host ? ["service"] : ["service", "scopes"], `role credentials[${i}]`);
if (!host && !SERVICES.includes(entry.service)) {
refuse(`role credentials[${i}].service must be one of: ${[...SERVICES, HOST_SERVICE].join(", ")}`);
}
if (seen.has(entry.service)) refuse(`role credentials name ${entry.service} twice`);
seen.add(entry.service);
if (host) return { service: HOST_SERVICE };
if (github) return { service: "github" };
const scopes = entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes);
return { service: entry.service, scopes };
});
-73
View File
@@ -1,73 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { loadRole, validateBusinessDocument } from '../src/index.mjs';
import { instanceSettings } from '../src/instance-settings.mjs';
import { businessDoc, rolesCopy, systemFor, tmp, writeJson } from './helpers.mjs';
function fixture(t) {
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
const file = join(rolesDir, 'coder.json');
const definition = JSON.parse(readFileSync(file, 'utf8'));
definition.credentials.push({ service: 'github' });
writeJson(file, definition);
const doc = businessDoc(root);
doc.roles.coder.github = { projects: ['stack'] };
doc.projects.stack.remote = 'https://github.com/acme/stack';
doc.projects.stack.githubId = 101;
const validate = () => validateBusinessDocument(doc, join(root, 'businesses/acme.json'), { rolesDir });
return { root, rolesDir, file, definition, doc, validate };
}
test('GitHub fixed declaration and repository binding load without generic token references', (t) => {
const f = fixture(t);
assert.deepEqual(loadRole(f.rolesDir, 'coder').credentials.at(-1), { service: 'github' });
const b = f.validate();
assert.deepEqual(b.roles.coder.github, { projects: ['stack'] });
assert.equal(b.projects.stack.githubId, 101);
assert.equal(Object.hasOwn(b.roles.coder.credentials, 'github'), false);
});
test('GitHub declaration refuses caller scopes and role account/fork requests', (t) => {
const f = fixture(t);
f.definition.credentials.at(-1).scopes = ['all']; writeJson(f.file, f.definition);
assert.throws(() => loadRole(f.rolesDir, 'coder'), /unsupported.*scopes/);
delete f.definition.credentials.at(-1).scopes; writeJson(f.file, f.definition);
for (const field of ['account', 'fork']) {
f.doc.roles.coder.github[field] = true;
assert.throws(f.validate, new RegExp('unsupported.*' + field));
delete f.doc.roles.coder.github[field];
}
});
test('GitHub bindings refuse absent declaration, unknown project and missing repository ID', (t) => {
const f = fixture(t);
delete f.doc.projects.stack.githubId;
assert.throws(f.validate, /githubId/);
f.doc.projects.stack.githubId = 101;
f.doc.roles.coder.github.projects = ['missing'];
assert.throws(f.validate, /github.*missing/);
f.doc.roles.coder.github.projects = ['stack'];
f.definition.credentials.pop(); writeJson(f.file, f.definition);
assert.throws(f.validate, /github.*definition/);
});
// mos-1518: preserve closed-service-list coverage when github becomes a fixed declaration.
test('a service outside the closed list still refuses with service must be one of', (t) => {
const f = fixture(t); f.definition.credentials.at(-1).service = 'not-a-service';
writeJson(f.file, f.definition);
assert.throws(() => loadRole(f.rolesDir, 'coder'), /service must be one of/);
});
test('human add of a GitHub-declaring definition creates only generic service references', (t) => {
const f = fixture(t), dir = join(f.root, 'config');
const file = writeJson(join(dir, 'businesses/acme.json'), f.doc);
const settings = instanceSettings({ dir, rolesDir: f.rolesDir, system: systemFor(f.root) });
settings.set('acme', { revision: settings.read('acme').revision, instance: 'coder-two', op: 'add', definition: 'coder',
tracker: { bot: 'bot-acme-coder-two', botId: 40 } }, 'jason');
const entry = JSON.parse(readFileSync(file, 'utf8')).roles['coder-two'];
assert.deepEqual(Object.keys(entry.credentials).sort(), ['gitea', 'vikunja']);
assert.equal(Object.hasOwn(entry, 'github'), false);
});
@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { organizationWriter, setReplyLimits } from '../src/organization.mjs';
import { validateBusinessDocument } from '../src/business.mjs';
import { businessDoc, rolesCopy, tmp, writeJson } from './helpers.mjs';
function fixture(t, human = 'jason') {
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const doc = businessDoc(root); doc.human = human; doc.replyLimits = { requireCitation: false, citationsPerReply: 20 };
const dir = join(root, 'config'), file = writeJson(join(dir, 'businesses/acme.json'), doc), rolesDir = rolesCopy(root);
const writer = organizationWriter({ dir, rolesDir });
const next = structuredClone(doc); next.vars['tracker.pollSeconds'] = 61;
writer.write('acme', next, { revision: writer.read('acme').revision, actor: human });
return { root, file, writer, rolesDir, log: writer.logFile('acme') };
}
function refused(f, actor, change) {
const before = readFileSync(f.file), log = readFileSync(f.log), { document, revision } = f.writer.read('acme');
change(document);
assert.throws(() => f.writer.write('acme', document, { revision, actor }), { code: 'organization-reply-limits-human-only' });
assert.deepEqual(readFileSync(f.file), before); assert.deepEqual(readFileSync(f.log), log);
}
test('C2-06 M11 human replacement works; omitted settings return to defaults', t => {
const f = fixture(t);
setReplyLimits(f.writer, 'acme', { revision: f.writer.read('acme').revision, replyLimits: { requireCitation: true } }, 'jason');
assert.deepEqual(f.writer.read('acme').document.replyLimits, { requireCitation: true });
setReplyLimits(f.writer, 'acme', { revision: f.writer.read('acme').revision, replyLimits: {} }, 'jason');
assert.deepEqual(f.writer.read('acme').document.replyLimits, {});
});
test('C2-06 M11 agents and tester cannot change settings, including by changing human', t => {
const f = fixture(t);
for (const actor of ['chief-of-staff.chief', 'tester', 'coder.coder'])
refused(f, actor, d => { d.replyLimits.requireCitation = true; });
refused(f, 'chief-of-staff.chief', d => { d.human = 'chief-of-staff.chief'; d.replyLimits.requireCitation = true; });
const dotted = fixture(t, 'coder.coder');
refused(dotted, 'coder.coder', d => { d.replyLimits.citationsPerReply = 1; });
});
test('C2-06 M11 serialization cannot replace settings after the first fence', t => {
const f = fixture(t);
refused(f, 'chief-of-staff.chief', d => {
Object.defineProperty(d.replyLimits, 'toJSON', { value: () => ({ requireCitation: true }) });
});
});
// E1: unlike tester/role actors, mallory passes the other actor-shape exclusions.
// Comparing document.human instead of old.human must fail this case.
test('C2-06 M11 an unrelated actor cannot appoint itself human and change reply limits', t => {
const f = fixture(t);
refused(f, 'mallory', d => { d.human = 'mallory'; d.replyLimits.requireCitation = true; });
});
test('C2-06 invalid setter values preserve the business and changes.jsonl', t => {
const f = fixture(t), before = readFileSync(f.file), log = readFileSync(f.log);
for (const replyLimits of [{ citationsPerReply: 0 }, { citationsPerReply: 1001 }, { requireCitation: 'true' }, { invented: true }, []]) {
assert.throws(() => setReplyLimits(f.writer, 'acme', { revision: f.writer.read('acme').revision, replyLimits }, 'jason'), { code: 'invalid-request' });
assert.deepEqual(readFileSync(f.file), before); assert.deepEqual(readFileSync(f.log), log);
}
});
test('C2-06 validator accepts hand-edited settings; raw reader fails closed on invalid files', t => {
const f = fixture(t), doc = f.writer.read('acme').document;
for (const replyLimits of ['true', 1, null, [true], {}, { requireCitation: 'true' }, { citationsPerReply: 0 }]) {
const next = { ...doc, replyLimits };
assert.doesNotThrow(() => validateBusinessDocument(next, f.file, { rolesDir: f.rolesDir }));
writeJson(f.file, next); assert.deepEqual(f.writer.readRaw('acme').document.replyLimits, replyLimits);
}
for (const bad of ['{broken', 'null', '[]', '1']) {
writeFileSync(f.file, bad); assert.deepEqual(f.writer.readRaw('acme'), { readable: false });
}
rmSync(f.file); assert.deepEqual(f.writer.readRaw('acme'), { readable: false });
});
+1 -1
View File
@@ -206,7 +206,7 @@ test("credentials: Vikunja scopes are a group-to-verbs map from the grantable li
});
test("credentials: services", () => {
refuses(() => check({ credentials: [{ service: "github", scopes: [] }] }), /unsupported.*scopes/);
refuses(() => check({ credentials: [{ service: "github", scopes: [] }] }), /service must be one of/);
refuses(() => check({ credentials: [{ service: "gitea", scopes: ["read:issue"] }, { service: "gitea", scopes: ["read:user"] }] }), /twice/);
refuses(() => check({ credentials: [{ service: "gitea", scopes: ["read:issue"], token: "x" }] }), /unsupported role credentials\[0\] key/);
assert.deepEqual(check({ credentials: [] }).credentials, []);
+6
View File
@@ -959,6 +959,12 @@ writer adds `since`, the time the limit was set, and keeps it on later saves.
A patch that sends a different `since` is refused. Only these paths are token
limits; the agent limits above are never read as one, whatever their names.
`mosaic limits replies --file LIMITS [--business ID]` replaces the citation settings
through the human-only `library.limits.set` verb. LIMITS is a JSON object with optional
`requireCitation` (boolean, default false) and `citationsPerReply` (integer 1–1000,
default 20). Omitted keys use their defaults; unknown keys and invalid values refuse.
The next library reply uses the new settings without restarting the bus.
`mosaic limits reset WORD... [--business ID]` moves one token limit's `since`
to now, so its count restarts. The path is one word per level, as the plain
output prints it: `mosaic limits reset tokens models anthropic claude-opus-5-5`.
-43
View File
@@ -1,43 +0,0 @@
// Row297 CLI shapes. Only the broker parses/uses the RSA private key.
import { CliError } from './errors.mjs';
const USAGE = 'mosaic grants status|sweep github [--json] [--tester] | revoke AGENT github --reason R | admin set github --url HTTPS_ORIGIN --app-id ID --installation-id ID --account-id ID --rotate-by DATE | admin rotate github --rotate-by DATE | admin remove github';
const SHAPES = {
status: { n: 1, flags: ['--json', '--tester'], need: [] },
sweep: { n: 1, flags: ['--tester'], need: [] },
revoke: { n: 2, flags: ['--tester'], need: ['--reason'] },
'admin set': { n: 1, flags: [], need: ['--url', '--app-id', '--installation-id', '--account-id', '--rotate-by'] },
'admin rotate': { n: 1, flags: [], need: ['--rotate-by'] },
'admin remove': { n: 1, flags: [], need: [] },
};
const usage = () => { throw new CliError(USAGE, 4); };
async function stdinKey(stdin) {
let text = '';
for await (const chunk of stdin) { text += chunk; if (text.length > 16384) throw new CliError('refused: App key on stdin is too long', 2); }
if (!text.trim()) throw new CliError('refused: no App key on stdin', 4);
return text;
}
const positive = (text) => {
if (typeof text !== 'string' || !/^[1-9][0-9]*$/.test(text) || !Number.isSafeInteger(Number(text))) usage();
return Number(text);
};
function format(status) {
const lines = [status.admin ? `admin: App ${status.admin.appId}, installation ${status.admin.installationId}, account ${status.admin.accountId} on ${status.admin.url}, rotate by ${status.admin.rotateBy}` : 'admin: none'];
for (const g of status.grants) lines.push(`grant\t${g.agent}\t${g.projects.join(',')}\texpires ${g.expiresAt}\trotate by ${g.rotateBy}\t${g.decision}`);
for (const g of status.serviceLeft) lines.push(`left\t${g.agent}\t${g.generation}\tuntil ${g.until}`);
return lines.join('\n') + '\n';
}
export async function githubGrantsCommand(parsed, verb, rest, io, callFor) {
const shape = Object.hasOwn(SHAPES, verb) ? SHAPES[verb] : null;
const v = parsed.values;
if (!shape || rest.length !== shape.n || [...parsed.flags].some((f) => !shape.flags.includes(f)) ||
Object.keys(v).some((k) => k !== '--business' && !shape.need.includes(k)) || shape.need.some((k) => !Object.hasOwn(v, k))) usage();
let args = { service: 'github' };
if (verb === 'revoke') args = { ...args, agent: rest[0], reason: v['--reason'] };
if (verb === 'admin set') args = { ...args, url: v['--url'], appId: positive(v['--app-id']), installationId: positive(v['--installation-id']), accountId: positive(v['--account-id']), rotateBy: v['--rotate-by'] };
if (verb === 'admin rotate') args.rotateBy = v['--rotate-by'];
if (verb === 'admin set' || verb === 'admin rotate') args.token = await stdinKey(io.stdin);
try {
const result = await callFor(parsed)('grants.' + verb.replace(' ', '.'), args);
io.stdout.write(verb === 'status' && !parsed.flags.has('--json') ? format(result) : JSON.stringify(result, null, 2) + '\n');
} catch (e) { if (e instanceof CliError) throw e; throw new CliError(e.message, 2); }
}
+2 -5
View File
@@ -4,16 +4,14 @@
// and sweep the human's or, with --tester, the tester's. admin set reads the admin token on stdin, never
// argv, and nothing here prints a token: the answers carry ids and names only.
import { CliError } from "./errors.mjs";
import { githubGrantsCommand } from "./github-grants.mjs";
export const GRANTS_USAGE =
"mosaic grants status gitea [--json] [--tester] [--business ID] | run DECISION [--business ID] | " +
"revoke AGENT gitea --reason R [--tester] [--business ID] | sweep gitea [--tester] [--business ID] | " +
"admin set gitea --url URL --account NAME --rotate-by DATE [--business ID] | " +
"admin rotate gitea --rotate-by DATE | admin rotate gitea --finish | admin remove gitea | " +
"status|sweep github | revoke AGENT github --reason R | admin set github --url URL --app-id ID --installation-id ID --account-id ID --rotate-by DATE | admin rotate github --rotate-by DATE | admin remove github";
"admin rotate gitea --rotate-by DATE | admin rotate gitea --finish | admin remove gitea";
const MAX_TOKEN = 16384;
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by", "--app-id", "--installation-id", "--account-id"];
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by"];
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
@@ -74,7 +72,6 @@ export async function grantsCommand(args, io, callFor) {
verb = `admin ${rest[0] ?? ""}`;
rest = rest.slice(1);
}
if (verb !== "run" && rest.at(-1) === "github") return githubGrantsCommand(parsed, verb, rest, io, callFor);
// Own keys only, so `constructor` is a usage error, not a verb.
const shape = Object.hasOwn(SHAPES, verb ?? "") ? SHAPES[verb] : null;
if (
+11 -4
View File
@@ -7,9 +7,10 @@ import { CliError } from "./errors.mjs";
import { migrateLaunch } from "../../business/src/migrate-launch.mjs";
// Row 155 (#1649, Q169): a PATCH may carry `assistants`, the assistant's vars, tracker and definition.
// C2-06 M11: `replies --file LIMITS` replaces replyLimits with the file's object, at the current revision.
export const LIMITS_USAGE = "mosaic limits [--business ID] [--json] [--tester] | set --file PATCH [--business ID] | " +
"reset PATH... [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " +
"PATCH keys: revision, limits, models, instances, reset, assistants";
"reset PATH... [--business ID] | replies --file LIMITS [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " +
"PATCH keys: revision, limits, models, instances, reset, assistants; LIMITS keys: requireCitation, citationsPerReply";
export const DAILY_NOTE = "Daily quotas count only this stack's own use of the account, not its use anywhere else.";
@@ -67,8 +68,9 @@ export async function limitsCommand(args, io, callFor) {
io.stdout.write(JSON.stringify(converted, null, 2) + "\n");
return;
}
if (!["view", "set", "reset"].includes(verb) || values["--models"] || values["--roles-dir"] ||
(verb !== "set" && values["--file"]) || (verb === "set" && !values["--file"]) ||
const fileVerb = verb === "set" || verb === "replies";
if (!["view", "set", "reset", "replies"].includes(verb) || values["--models"] || values["--roles-dir"] ||
(!fileVerb && values["--file"]) || (fileVerb && !values["--file"]) ||
(verb === "reset" && parsed.positional.length < 2)) {
throw new CliError(LIMITS_USAGE, 4);
}
@@ -78,6 +80,11 @@ export async function limitsCommand(args, io, callFor) {
// result's `tokens` lists each token limit: counted, not-enforced or check-failed.
let result;
if (verb === "set") result = await call("limits.set", JSON.parse(readFileSync(values["--file"], "utf8")));
else if (verb === "replies") {
const replyLimits = JSON.parse(readFileSync(values["--file"], "utf8"));
const { revision } = await call("limits", {});
result = await call("library.limits.set", { revision, replyLimits });
}
else if (verb === "reset") {
const { revision } = await call("limits", {});
result = await call("limits.set", { revision, reset: [parsed.positional.slice(1)] });
-42
View File
@@ -1,42 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { main } from '../src/cli.mjs';
import { io } from './helpers.mjs';
const setup = () => {
const calls = [];
const transport = () => async (verb, args) => { calls.push({ verb, args }); return { grants: [], serviceLeft: [], admin: null }; };
return { calls, deps: { system: { dataRoot: '/nonexistent' }, transport, testerTransport: transport } };
};
test('GitHub admin set uses separate identity flags and bounded stdin', async () => {
const f = setup(), x = io(); const fakeKey = '-----BEGIN PRIVATE KEY-----\nfake-only-not-a-real-key\n-----END PRIVATE KEY-----\n';
x.stdin.end(fakeKey);
await main(['grants', 'admin', 'set', 'github', '--business', 'demo', '--url', 'https://api.github.com',
'--app-id', '10', '--installation-id', '20', '--account-id', '30', '--rotate-by', '2099-01-01'], x, f.deps);
assert.equal(f.calls.length, 1);
assert.deepEqual(f.calls[0], { verb: 'grants.admin.set', args: { service: 'github', url: 'https://api.github.com',
appId: 10, installationId: 20, accountId: 30, rotateBy: '2099-01-01', token: fakeKey } });
assert.ok(!x.out.text.includes(fakeKey) && !x.err.text.includes(fakeKey));
});
test('GitHub rotate replaces the human-supplied key rather than Gitea token minting', async () => {
const f = setup(), x = io(); x.stdin.end('fake-only-key-value');
await main(['grants', 'admin', 'rotate', 'github', '--business', 'demo', '--rotate-by', '2099-01-01'], x, f.deps);
assert.equal(f.calls[0].verb, 'grants.admin.rotate');
assert.equal(f.calls[0].args.service, 'github');
assert.equal(f.calls[0].args.rotateBy, '2099-01-01');
});
test('GitHub management dispatch and malformed flag shapes', async () => {
const f = setup();
await main(['grants', 'status', 'github', '--business', 'demo', '--json'], io(), f.deps);
await main(['grants', 'revoke', 'coder', 'github', '--business', 'demo', '--reason', 'test'], io(), f.deps);
await main(['grants', 'sweep', 'github', '--business', 'demo'], io(), f.deps);
assert.deepEqual(f.calls.map((c) => c.verb), ['grants.status', 'grants.revoke', 'grants.sweep']);
for (const args of [
['admin', 'set', 'github', '--account', '20'], ['admin', 'rotate', 'github', '--finish'],
['status', 'github', '--app-id', '10'], ['admin', 'remove', 'github', '--tester'],
['admin', 'set', 'github', '--url', 'https://api.github.com', '--app-id', 'NaN', '--installation-id', '20', '--account-id', '30', '--rotate-by', '2099-01-01'],
]) await assert.rejects(main(['grants', ...args, '--business', 'demo'], io(), f.deps), (e) => e.exitCode === 4);
assert.equal(f.calls.length, 3);
});
+23
View File
@@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { limitsCommand } from '../src/limits.mjs';
test('C2-06 CLI replies reads revision and sends only the JSON settings to the human verb', async t => {
const dir = mkdtempSync(join(tmpdir(), 'reply-cli-')); t.after(() => rmSync(dir, { recursive: true, force: true }));
const file = join(dir, 'settings.json'); writeFileSync(file, JSON.stringify({ requireCitation: true, citationsPerReply: 7 }));
const calls = [], output = [];
await limitsCommand(['replies', '--file', file, '--business', 'acme'], { stdout: { write: s => output.push(s) } }, parsed => {
assert.equal(parsed.values['--business'], 'acme');
return async (verb, args) => { calls.push([verb, args]); return verb === 'limits' ? { revision: 'r1' } : { revision: 'r2' }; };
});
assert.deepEqual(calls, [['limits', {}], ['library.limits.set', { revision: 'r1', replyLimits: { requireCitation: true, citationsPerReply: 7 } }]]);
assert.match(output.join(''), /r2/);
});
test('C2-06 CLI replies requires a file and refuses tester writes before a bus call', async () => {
for (const args of [['replies'], ['replies', '--tester', '--file', 'unused'], ['replies', '--file', 'unused', '--models', 'unused']])
await assert.rejects(limitsCommand(args, { stdout: { write() {} } }, () => { throw Error('bus touched'); }), { exitCode: 4 });
});
+1 -1
View File
@@ -142,7 +142,7 @@ test('real pinned install loads bus and Console; same-copy mosaic talk passes pr
host.stdout.on('data',c=>hostOut+=c);host.stderr.on('data',c=>hostErr+=c);
try{await until(()=>hostOut.includes('bus host up')||host.exitCode!==null);assert.match(hostOut,/bus host up/,hostErr);}
finally{if(host.exitCode===null&&host.signalCode===null){const done=once(host,'close');host.kill('SIGTERM');await done;}}
const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr='';
const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr='';
web.stdout.on('data',c=>webOut+=c);web.stderr.on('data',c=>webErr+=c);
try{await until(()=>/Mosaic Console: http:/.test(webOut)||web.exitCode!==null);const port=webOut.match(/127\.0\.0\.1:(\d+)/)?.[1];assert.ok(port,webErr);assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status,200);}
finally{if(web.exitCode===null&&web.signalCode===null){const done=once(web,'close');web.kill();await done;}}
-58
View File
@@ -851,64 +851,6 @@ test('T44 row 180 item 2: --plan lists tester-port-invalid and tester-port-targe
});
});
// Row 198 (design item 8, B2; sage-2834 ruling a; sage-2837): --board is dropped for every op, because
// webui-service.sh install refuses the flag. The reason follows the target's cli.mjs: a target that
// parses --board is pre-198 (7331 retired); a target that names it only in its refusal runs the board
// in the Console. The plan names each drop. setCli rewrites a shared copy's cli.mjs and restores it after.
const BOARD='--board http://127.0.0.1:9';
const REFUSES_BOARD=`// fixture Console, never run\n// serve accepts '--tester-port'\nif (flag === '--board') throw new Error('--board retired: the board runs in the Console (row 198)');\n`;
const PARSES_BOARD=`// fixture Console, never run\n// serve accepts '--tester-port'\nelse if (flag === '--board') board = value;\n`;
const setCli=(t,id,text)=>{const f=join(COPIES,id,'packages/webui/src/cli.mjs'),orig=readFileSync(f,'utf8');chmodSync(f,0o644);writeFileSync(f,text);t.after(()=>{chmodSync(f,0o644);writeFileSync(f,orig);chmodSync(f,0o444)})};
const withBoard=(e)=>{const q=pointer(e);q.console=[...q.console,...BOARD.split(' ')];writeFileSync(e.pointer,JSON.stringify(q)+'\n')};
test('row 198 item 8 (M12): a re-pin plan drops a --board the target Console does not parse, and its Console line has none',async(t)=>{
const e=await mk(t);await seed(e);setCli(t,C1,REFUSES_BOARD);withBoard(e);
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
const r=await run(e,'activate',C1,'acme','--plan');
assert.equal(r.code,0,r.out+r.err);assert.equal(r.err,'');
assert.ok(r.out.split('\n').includes(`drop ${BOARD}; the target Console does not parse it, so the board runs in the Console`),r.out);
assert.doesNotMatch(r.out.split('\n').find((l)=>l.startsWith('Console: ')),/--board/);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
});
test('row 198 item 8 (M13, activate): an activate to a pre-198 copy that parses --board drops the unit\'s --board, and the install and unit have none',async(t)=>{
// Sage sage-2837: a target that parses --board is pre-198, and webui-service.sh install still refuses the flag.
// The unit carries the flag before, as a pre-198 Console does. The plan is checked first, then a real run.
const e=await mk(t);await seed(e);setCli(t,C1,PARSES_BOARD);
edit(join(e.units,CON),'--business acme',`--business acme ${BOARD}`);withBoard(e);
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
let r=await run(e,'activate',C1,'acme','--plan');
assert.equal(r.code,0,r.out+r.err);assert.equal(r.err,'');
const lines=r.out.split('\n');
assert.ok(lines.includes(`drop ${BOARD}; the 7331 board is retired, so this Console runs without the board`),r.out);
assert.doesNotMatch(lines.find((l)=>l.startsWith('Console: ')),/--board/);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
assert.ok(r.out.split('\n').includes(`drop ${BOARD}; the 7331 board is retired, so this Console runs without the board`),r.out);
assert.equal(pointer(e).copy,C1);assert.equal(pointer(e).console.includes('--board'),false);
assert.doesNotMatch(consoleText(e),/--board/);
});
test('row 198 item 8 (M13, rollback): a rollback drops the unit\'s --board even for a copy that parses it, and the install and unit have none',async(t)=>{
// Sage sage-2834 ruling (a), sage-2837: webui-service.sh install refuses --board, so a rollback drops it and
// names the drop with the retired-board reason. The activation runs without --board; the unit then carries it,
// and the rollback target B parses it, which must not keep the flag. The plan is checked first, then a real run.
const e=await mk(t);setCli(t,B,PARSES_BOARD);
await seed(e);
let r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
edit(join(e.units,CON),'--business acme',`--business acme ${BOARD}`);withBoard(e);
const before=fp(e);
r=await run(e,'rollback','acme','--plan');assert.equal(r.code,0,r.out+r.err);
const lines=r.out.split('\n');
assert.ok(lines.includes(`drop ${BOARD}; the 7331 board is retired, so this Console runs without the board`),r.out);
assert.doesNotMatch(lines.find((l)=>l.startsWith('Console: ')),/--board/);
assert.deepEqual(fp(e),before);
r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).copy,B);assert.equal(pointer(e).console.includes('--board'),false);
assert.doesNotMatch(consoleText(e),/--board/);
assert.match(stepText(e),/drop --board http:\/\/127\.0\.0\.1:9; the 7331 board is retired/);
});
// Row 181 (#1679, P19 390): COUNTED and STORE_JS as the script under test has them. The fixture
// tables carry only seq and at, which is all the count reads.
const srText=readFileSync(SR,'utf8');
+12 -17
View File
@@ -100,22 +100,14 @@ service transitions or authorize replacing a running board.
```
node src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
node src/cli.mjs serve [same flags] [--port N] [--host 127.0.0.1]
```
`scan` runs once and writes the status files.
`serve` is retired (row 198). It exits 2 with a pointer to the Console. The
board now runs in-process inside the Console (`packages/webui`), behind the
Console's own sign-in. Start it with:
```
node packages/webui/src/cli.mjs serve
```
Then open `http://127.0.0.1:7330/` in a browser. The board's routes are
`/api/board` and the two writes `/api/seen` and `/api/reply`, which need
`--send` on the public name. The page fetches `/api/board` every 10 seconds;
each fetch re-runs the scan, so the page is never staler than that timer.
`scan` runs once and writes the status files. `serve` starts a small local
web server: open `http://127.0.0.1:7331/` in a browser. The page fetches
`/api/board` every 10 seconds; each fetch re-runs the scan, so the page is
never staler than that timer. There is no login, so the server refuses to
bind to anything but a loopback address.
- `--config PATH` — path to the system config file. Defaults to
`~/.config/mosaic-dev/config.json`. This file must exist and name an
@@ -132,8 +124,11 @@ each fetch re-runs the scan, so the page is never staler than that timer.
first column is `*` for a row waiting on you, `s` for a seen row, or blank
otherwise. The final summary line reads
`board: <dir> (N sessions, N waiting on you, N seen)`.
Routes (row 198: served by the Console, `packages/webui`, not by a board port;
the bodies and refusals below are unchanged):
- `--port N` — (`serve`) port to listen on. Default `7331`; `0` picks a free port.
- `--host ADDR` — (`serve`) loopback address to bind. Default `127.0.0.1`.
Any non-loopback address is refused.
Routes served:
- `GET /` — the page.
- `GET /api/board` — rescan, returns `index.json`.
@@ -175,7 +170,7 @@ so the seat's reaction shows without waiting for the next tick.
## Exit codes
- `0` — scan completed and status files were written.
- `0` — scan completed and status files were written, or the server stopped cleanly.
- `2` — refused: bad config, missing/invalid `dataRoot`, or bad arguments.
The message on stderr says why.
-78
View File
@@ -1,78 +0,0 @@
// The control board's routes as plain async functions (row 198). The Console
// (packages/webui) calls these in-process behind its own auth. Nothing here
// opens a socket or binds a port: the 7331 writer is retired.
//
// Every method returns a promise and uses the async child-process paths (B1), so
// a slow tmux call never blocks the Console's event loop.
//
// Write counter (decision 10, Webby N1). A scan must not serve a pre-write view.
// index() joins an in-flight scan only when that scan started at the current
// counter value. markSeen() and reply() bump the counter and never join a scan.
import { scanAsync, markSeen as writeMark, loadRegistrations, runAsync } from "./scan.mjs";
import { replyToRowAsync, conversationResponse, DEFAULT_AGENT_SEND } from "./serve.mjs";
import { createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs";
// onEvent(name) reports "write", "bump", "send" and "scan" in order. Production
// passes nothing; the order test (board.test.mjs) reads it to pin the sequence.
export function createBoard({ specs, boardDir, seatsDir = null, discordDataRoot = null, isAlive, isPidAlive, now = () => new Date(), agentSend = DEFAULT_AGENT_SEND, exec = runAsync, conversationReader = null, host, onEvent = () => {} }) {
let writes = 0;
let inflight = null;
const scanFresh = () => {
onEvent("scan");
return scanAsync(specs, { isAlive, isPidAlive, exec, boardDir, now, seatsDir, discordDataRoot });
};
const reader = conversationReader ?? createReader({ roots: () => rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations) });
// Joins a scan only when it started after the last write and is still running
// (see header). A settled scan, fulfilled or rejected, is cleared so the next
// index() starts a fresh one; the clear is on both branches, so a rejection is
// not left unhandled.
function index() {
if (inflight && inflight.generation === writes) return inflight.promise;
const generation = writes;
const promise = scanFresh();
const current = { generation, promise };
inflight = current;
const clear = () => { if (inflight === current) inflight = null; };
promise.then(clear, clear);
return promise;
}
// Pinned order (Webby, Mos mos-1052): write, then bump the counter, then scan.
// The scan sees the mark, and no scan started before the bump can be joined.
async function markSeenRoute(body) {
writeMark(boardDir, { project: body.project, agent: body.agent, lastActivity: body.lastActivity, seen: body.seen ?? true });
onEvent("write");
writes++;
onEvent("bump");
return scanFresh();
}
// Pinned order: send, then bump the counter, then scan. The planning scan
// before the send is a read and is not part of the pinned sequence. A refusal
// runs no send, so it writes nothing: no bump and no post-send scan. Only a
// send that reached the exec runs the rest. The post-send scan rewrites the
// derived board files, so a reply's effects reach them the same way a mark does.
async function reply(body) {
const planned = await scanFresh();
let sent = false;
const sendExec = (...args) => {
sent = true;
return exec(...args);
};
const out = await replyToRowAsync({ index: planned, key: body.agent, text: body.text, agentSend, exec: sendExec, now, host });
if (!sent) return out;
onEvent("send");
writes++;
onEvent("bump");
await scanFresh();
return out;
}
function conversation(url) {
return conversationResponse(reader, url);
}
return { index, markSeen: markSeenRoute, reply, conversation };
}
+18 -6
View File
@@ -1,18 +1,17 @@
#!/usr/bin/env node
// Usage:
// node packages/control-board/src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
// `serve` is retired (row 198): the board runs in-process inside the Console.
// node packages/control-board/src/cli.mjs serve [same flags] [--port N] [--host 127.0.0.1]
// Exit 0 on success, 2 on a config refusal or bad usage.
import { join } from "node:path";
import { homedir } from "node:os";
import { loadConfig, defaultConfigPath, discoverRepoAgents, discoverFleetAgents, scan, tmuxInspect, ConfigError } from "./scan.mjs";
import { startServer } from "./serve.mjs";
const USAGE = "usage: mosaic-board scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]";
const RETIRED = "the control board no longer serves a port (retired on 7331, row 198). Open the board in the Console (webui, port 7332) instead.";
const USAGE = "usage: mosaic-board scan|serve [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print] [--port N] [--host 127.0.0.1]";
function parseArgs(argv) {
if (argv[0] === "serve") throw new ConfigError(RETIRED);
const opts = { command: argv[0], config: defaultConfigPath(), repo: process.cwd(), fleet: join(homedir(), ".mosaic", "fleet", "agents"), liveness: "tmux", print: false };
const opts = { command: argv[0], config: defaultConfigPath(), repo: process.cwd(), fleet: join(homedir(), ".mosaic", "fleet", "agents"), liveness: "tmux", print: false, port: 7331, host: "127.0.0.1" };
for (let i = 1; i < argv.length; i++) {
const a = argv[i];
const next = () => {
@@ -24,9 +23,13 @@ function parseArgs(argv) {
else if (a === "--fleet") opts.fleet = next();
else if (a === "--liveness") opts.liveness = next();
else if (a === "--print") opts.print = true;
else if (a === "--port") {
opts.port = Number(next());
if (!Number.isInteger(opts.port) || opts.port < 0 || opts.port > 65535) throw new ConfigError("--port must be an integer 0..65535");
} else if (a === "--host") opts.host = next();
else throw new ConfigError(`unknown argument: ${a}`);
}
if (opts.command !== "scan") throw new ConfigError(USAGE);
if (opts.command !== "scan" && opts.command !== "serve") throw new ConfigError(USAGE);
if (!["tmux", "assume-alive"].includes(opts.liveness)) throw new ConfigError(`unknown liveness mode: ${opts.liveness}`);
return opts;
}
@@ -38,6 +41,15 @@ async function main() {
const isAlive = opts.liveness === "tmux" ? tmuxInspect : () => true;
const boardDir = join(dataRoot, "board");
const seatsDir = join(dataRoot, "seats");
if (opts.command === "serve") {
const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive, seatsDir, discordDataRoot: dataRoot });
const addr = server.address();
process.stdout.write(`control board: http://${opts.host}:${addr.port}/ (${specs.length} agents; board files in ${boardDir}; Ctrl-C to stop)\n`);
const stop = () => server.close(() => process.exit(0));
process.on("SIGINT", stop);
process.on("SIGTERM", stop);
return;
}
const index = scan(specs, { boardDir, isAlive, seatsDir, discordDataRoot: dataRoot });
for (const line of index.discoveryErrors) process.stderr.write(`${line}\n`);
for (const line of index.registrationErrors) process.stderr.write(`registration skipped: ${line}\n`);
+605
View File
@@ -0,0 +1,605 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Control board</title>
<style>
:root{
--canvas:#f4f6f8;--surface:#fff;--raised:#e9edf1;--text:#16202a;--muted:#5a6774;
--line:#e2e7ec;--action:#1f5f8b;--onAction:#fff;--accent:#c96a2b;
--success:#2e7d4f;--warning:#b26a00;--danger:#b3261e;
--font:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--radius:8px;--radius-lg:14px;
}
@media (prefers-color-scheme: dark){
:root{
--canvas:#11161c;--surface:#182029;--raised:#1f2833;--text:#e7edf3;--muted:#94a3b0;
--line:#2a343f;--action:#6ba6d6;--onAction:#0b1116;--accent:#e08a4c;
--success:#57b586;--warning:#e0a63c;--danger:#e58077;
}
}
*,*::before,*::after{box-sizing:border-box}
html{font-family:var(--font);background:var(--canvas);color:var(--text);line-height:1.45}
body{margin:0 auto;max-width:1100px;padding:16px}
h1,h2,h3{margin:0 0 .4em;line-height:1.2;font-weight:600}
h1{font-size:1.5rem}h2{font-size:1.1rem;margin-top:1.4em}h3{font-size:.95rem}
:focus-visible{outline:3px solid var(--action);outline-offset:2px;border-radius:4px}
.page-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 16px}
.head-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.status-line{margin:0;color:var(--muted);font-size:.9rem}
.btn{font:inherit;font-weight:600;font-size:.85rem;min-height:34px;padding:6px 12px;border-radius:var(--radius);
border:1px solid var(--line);background:var(--surface);color:var(--text);cursor:pointer}
.btn:hover{background:var(--raised)}
.error-banner{margin:12px 0;padding:10px 14px;border-radius:var(--radius);border:1px solid var(--danger);
background:color-mix(in srgb,var(--danger) 10%,var(--surface));color:var(--danger);font-size:.9rem}
.empty{color:var(--muted);font-style:italic}
#seenDetails summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:8px}
#seenDetails summary::-webkit-details-marker{display:none}
#seenDetails summary::before{content:"\25B8";color:var(--muted);font-size:.9em}
#seenDetails[open] summary::before{content:"\25BE"}
#seenDetails summary h2{display:inline;margin:0}
.table-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:var(--radius-lg);background:var(--surface)}
table{width:100%;border-collapse:collapse;font-size:.88rem}
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top}
th{font-size:.72rem;text-transform:uppercase;letter-spacing:.03em;color:var(--muted);font-weight:600;white-space:nowrap}
tbody tr:last-child td{border-bottom:none}
tr.is-offline{opacity:.55}
.badge{display:inline-flex;align-items:center;gap:5px;font-size:.76rem;font-weight:600;padding:2px 9px;
border-radius:99px;border:1px solid transparent;white-space:nowrap}
.badge::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}
.badge-ok{color:var(--success);background:color-mix(in srgb,var(--success) 12%,var(--surface))}
.badge-warn{color:var(--warning);background:color-mix(in srgb,var(--warning) 12%,var(--surface))}
.badge-danger{color:var(--danger);background:color-mix(in srgb,var(--danger) 12%,var(--surface))}
.badge-muted{color:var(--muted);background:var(--raised)}
.badge-accent{color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,var(--surface))}
.row-toggle{font:inherit;font-weight:600;background:none;border:0;padding:2px 0;color:var(--action);
cursor:pointer;text-align:left}
.row-toggle:hover{text-decoration:underline}
.seen-toggle{font:inherit;font-size:.76rem;background:none;border:1px solid var(--border);border-radius:6px;
padding:1px 8px;color:var(--action);cursor:pointer;margin-left:6px;vertical-align:middle}
.seen-toggle:hover{border-color:var(--action)}
.seen-tag{font-size:.72rem;color:var(--muted);margin-left:6px;vertical-align:middle}
.msg-error{color:var(--danger)}
.msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.relaunch-notice{display:block;max-width:36ch;overflow-wrap:anywhere;white-space:normal}
.model{display:block;color:var(--muted);font-size:.75em;white-space:nowrap}
.task-text{display:block;max-width:28ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.unknown{color:var(--muted);font-style:italic}
.reply-form{display:flex;gap:.4em;align-items:center;max-width:60ch}
.reply-form input{flex:1;min-width:12ch;font:inherit;padding:.2em .4em}
.reply-receipt{display:block;margin-top:.3em;font-size:.85em}
.reply-ok{color:var(--muted)}
.source{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
.set-by{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
.detail-row td{background:var(--raised)}
.detail-list{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:.85rem;font-family:var(--mono)}
.detail-list dt{color:var(--muted);font-family:var(--font)}
.detail-list dd{margin:0;overflow-wrap:anywhere}
.project-group{margin-bottom:20px}
.project-group-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px}
.group-toggles{display:flex;flex-wrap:wrap;gap:14px}
.project-group-head label{font-size:.85rem;color:var(--muted);display:flex;align-items:center;gap:6px}
.offline-note{margin:6px 0 0;font-size:.82rem;color:var(--muted)}
.page-footer{margin-top:24px;padding-top:12px;border-top:1px solid var(--line);color:var(--muted);font-size:.85rem}
</style>
</head>
<body>
<header class="page-head">
<h1>Control board</h1>
<div class="head-controls">
<p id="headerStatus" class="status-line" aria-live="polite">Updated never · next refresh in 10s</p>
<button id="refreshBtn" class="btn" type="button">Refresh</button>
<button id="pauseBtn" class="btn" type="button" aria-pressed="false">Pause</button>
</div>
</header>
<div id="errorBanner" class="error-banner" role="alert" hidden></div>
<main id="main">
<section aria-labelledby="waiting-h">
<h2 id="waiting-h">Waiting on you</h2>
<div id="waitingBody"></div>
</section>
<section aria-labelledby="seen-h">
<details id="seenDetails">
<summary><h2 id="seen-h">Seen</h2></summary>
<div id="seenBody"></div>
</details>
</section>
<section aria-labelledby="projects-h">
<h2 id="projects-h">By project</h2>
<div id="projectsBody"></div>
</section>
</main>
<footer id="footer" class="page-footer"></footer>
<script>
(function () {
"use strict";
var STATES = ["working", "waiting", "error", "offline", "idle", "unknown"];
var BADGE_CLASS = { waiting: "badge-warn", error: "badge-danger", working: "badge-ok", offline: "badge-muted", idle: "badge-muted", unknown: "badge-accent" };
var REFRESH_MS = 10000;
var headerStatus = document.getElementById("headerStatus");
var refreshBtn = document.getElementById("refreshBtn");
var pauseBtn = document.getElementById("pauseBtn");
var errorBanner = document.getElementById("errorBanner");
var waitingBody = document.getElementById("waitingBody");
var seenBody = document.getElementById("seenBody");
var seenHeading = document.getElementById("seen-h");
var projectsBody = document.getElementById("projectsBody");
var footer = document.getElementById("footer");
var main = document.getElementById("main");
var lastData = null;
var paused = false;
var fetching = false;
var timerId = null;
var secondsLeft = REFRESH_MS / 1000;
var hideOfflineState = {};
var hideSeenState = {};
// Open detail panels survive a refresh. Keyed by section, project and agent
// because the same agent can appear in both the waiting list and its group.
var openDetails = {};
var rowIdx = 0;
function esc(v) {
if (v === null || v === undefined) return "";
return String(v).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function humanAge(s) {
if (s === null || s === undefined) return "—";
if (s < 60) return s + "s";
var m = Math.floor(s / 60);
if (m < 60) return m + "m";
var h = Math.floor(m / 60);
if (h < 24) return h + "h";
return Math.floor(h / 24) + "d";
}
function timeAgo(iso) {
if (!iso) return "never";
var ms = Date.now() - Date.parse(iso);
if (Number.isNaN(ms)) return "never";
var s = Math.floor(ms / 1000);
if (s < 5) return "just now";
if (s < 60) return s + "s ago";
var m = Math.floor(s / 60);
if (m < 60) return m + "m ago";
var h = Math.floor(m / 60);
if (h < 24) return h + "h ago";
return Math.floor(h / 24) + "d ago";
}
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
function badge(state) {
var cls = BADGE_CLASS[state] || "badge-muted";
return '<span class="badge ' + cls + '">' + esc(state) + "</span>";
}
// A "Seen" button on rows that need you; a "seen" tag plus "Unsee" on rows
// you have already marked. The mark clears itself when the agent writes again.
function seenControl(rec) {
var needsYou = rec.state === "waiting" || rec.state === "error";
if (!needsYou || !rec.lastActivity) return "";
var attrs = ' data-project="' + esc(rec.project) + '" data-agent="' + esc(rec.agent) + '" data-last="' + esc(rec.lastActivity) + '"';
if (rec.seen) {
return '<span class="seen-tag">seen</span><button type="button" class="seen-toggle" data-seen="false"' + attrs + ' title="Put this row back under Waiting on you">Unsee</button>';
}
return '<button type="button" class="seen-toggle" data-seen="true"' + attrs + ' title="I have read this; hide it from Waiting on you until the agent writes again">Seen</button>';
}
var SOURCE_LABEL = { "registration": "registered", "first-user-message": "first message", "tmux-pane": "tmux pane", "session-cwd": "session cwd", "workspace-git-root": "git root" };
function sourceLabel(source) { return SOURCE_LABEL[source] || source; }
function sourceTag(source) { return source ? '<span class="source" title="source: ' + esc(source) + '">' + esc(sourceLabel(source)) + "</span>" : ""; }
function fromSource(source) { return source ? " (from " + esc(sourceLabel(source)) + ")" : ""; }
// Who set a registered task (#1511): shown only when the task on the row is
// the registered one (the scan gives null otherwise). It is what the
// caller claimed when it ran `mosaic seat task`, not a verified identity,
// and it never changes what the row lets you do.
function setByTag(rec) { return rec.taskSetBy ? '<span class="set-by" title="set by (as claimed by the caller, not verified): ' + esc(rec.taskSetBy) + '">set by ' + esc(rec.taskSetBy) + "</span>" : ""; }
function setByText(rec) { return rec.taskSetBy ? esc(rec.taskSetBy) + " (as claimed by the caller, not verified)" : "—"; }
// The model a seat is running, e.g. "gpt-6-astra" with the provider in the
// hover title. modelText() already escapes, so the title takes it as is.
// Empty when the log has not named one yet.
function modelTag(rec) { return rec.model ? '<span class="model" title="' + modelText(rec) + '">' + esc(rec.model) + "</span>" : ""; }
function modelText(rec) { return rec.model ? (rec.provider ? esc(rec.provider) + "/" : "") + esc(rec.model) : "unknown"; }
// Reply-from-board (#1505): a box and Send only where the board has an
// address, i.e. a live registration naming a tmux session. Receipts and
// unsent drafts are kept per row id so a refresh does not lose them.
var replyReceipts = {};
var replyDrafts = {};
function canReply(rec) {
if (rec.connector) return false;
var reg = rec.registered;
return !!(reg && reg.alive !== false && reg.tmux && reg.tmux.session);
}
function replyReceipt(id) {
var r = replyReceipts[id];
if (!r) return "";
if (r.delivered) return '<span class="reply-receipt reply-ok">delivered ' + esc(r.sentAt) + " to tmux " + esc(r.session) + "</span>";
return '<span class="reply-receipt msg-error" style="white-space:normal;max-width:none">failed' + (r.exitCode === null || r.exitCode === undefined ? "" : " (exit " + esc(r.exitCode) + ")") + ": " + esc(r.stderr || r.error || "no output") + "</span>";
}
function replyControl(rec) {
var id = [rec.project, rec.agent].join("/");
if (rec.connector) return '<span class="unknown">Board replies disabled for Discord connectors</span>';
if (!canReply(rec)) return '<span class="unknown">reply needs a registered seat</span>' + replyReceipt(id);
return '<form class="reply-form" data-id="' + esc(id) + '">' +
'<input type="text" class="reply-text" maxlength="2000" autocomplete="off" aria-label="Reply to ' + esc(rec.agent) + '" placeholder="Reply to ' + esc(rec.agent) + '" value="' + esc(replyDrafts[id] || "") + '">' +
'<button type="submit">Send</button></form>' + replyReceipt(id);
}
function registeredText(reg) {
if (!reg) return "no (not started through mosaic launch)";
var parts = ["started " + esc(reg.startedAt || "—")];
if (reg.updatedAt) parts.push("task updated " + esc(reg.updatedAt));
if (reg.harness) parts.push("harness " + esc(reg.harness));
if (reg.pid) parts.push("pid " + esc(reg.pid));
if (reg.tmux && reg.tmux.session) parts.push("tmux " + esc(reg.tmux.session) + (reg.tmux.socket ? " (socket " + esc(reg.tmux.socket) + ")" : ""));
if (reg.layout) parts.push(esc(reg.layout) + " layout");
if (reg.alive === false) parts.push("stale: pid " + esc(reg.pid) + " is gone, so the derived values are shown");
return parts.join("; ");
}
function relaunchNotice(rec) { return rec.relaunchedAt ? 'relaunched at ' + esc(rec.relaunchedAt) + ', no messages since' : ''; }
function buildRowPair(rec, showProject) {
var idx = rowIdx++;
var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/");
var open = !!openDetails[key];
var cls = "session-row" + (rec.state === "offline" ? " is-offline" : "");
var notice = relaunchNotice(rec);
var msg = notice ? '<span class="relaunch-notice">' + notice + '</span>' : rec.state === "error" && rec.lastError
? '<span class="msg-error" title="' + esc(rec.lastError) + '">' + esc(rec.lastError) + "</span>"
: '<span class="msg-text" title="' + esc(rec.lastAssistantText || "") + '">' + esc(rec.lastAssistantText || "—") + "</span>";
var projectCell = showProject ? "<td>" + esc(rec.project) + "</td>" : "";
// Gate A fields: from the seat's registration (mosaic launch), else derived
// from the log and tmux, else "unknown". Never guessed. The source tag on
// the row says which one is shown.
var task = rec.task
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>" + sourceTag(rec.taskSource) + setByTag(rec)
: '<span class="unknown">unknown</span>';
var activeProject = rec.activeProject
? '<span title="' + esc(rec.workspace || "") + '">' + esc(rec.activeProject) + "</span>" + sourceTag(rec.activeProjectSource)
: '<span class="unknown" title="' + esc(rec.workspace || "") + '">unknown</span>';
var main =
'<tr class="' + cls + '">' +
projectCell +
'<td><button type="button" class="row-toggle" data-idx="' + idx + '" data-key="' + esc(key) + '" aria-expanded="' + (open ? "true" : "false") + '" aria-controls="detail-' + idx + '">' + esc(rec.agent) + "</button>" + modelTag(rec) + "</td>" +
"<td>" + badge(rec.state) + (rec.connector ? '<span class="unknown"> · ' + (rec.connector.braked === true ? 'braked (STOP)' : rec.connector.braked === false ? 'not braked' : 'brake unknown') + ' · owner ' + esc(rec.connector.ownerState) + '</span>' : '') + seenControl(rec) + "</td>" +
"<td>" + (notice ? '<span class="relaunch-notice">' + notice + '</span>' : esc(humanAge(rec.ageSeconds))) + "</td>" +
"<td>" + task + "</td>" +
"<td>" + activeProject + "</td>" +
"<td>" + msg + "</td>" +
"</tr>";
var span = showProject ? 7 : 6;
var tmux = rec.tmux && rec.tmux.session
? esc(rec.tmux.session) + (rec.tmux.socket ? " (socket " + esc(rec.tmux.socket) + ")" : "")
: "—";
var detail =
'<tr class="detail-row" id="detail-' + idx + '"' + (open ? "" : " hidden") + '><td colspan="' + span + '">' +
'<dl class="detail-list">' +
"<dt>Session ID</dt><dd>" + esc(rec.sessionId || "—") + "</dd>" +
"<dt>Session file</dt><dd>" + esc(rec.sessionFile || "—") + "</dd>" +
"<dt>Task</dt><dd>" + (rec.task ? esc(rec.task) : "unknown") + fromSource(rec.taskSource) + "</dd>" +
"<dt>Task set by</dt><dd>" + setByText(rec) + "</dd>" +
"<dt>Active project</dt><dd>" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + fromSource(rec.activeProjectSource) + "</dd>" +
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + fromSource(rec.workspaceSource) + "</dd>" +
"<dt>Model</dt><dd>" + modelText(rec) + "</dd>" +
"<dt>Registered</dt><dd>" + registeredText(rec.registered) + "</dd>" +
"<dt>Reply</dt><dd>" + replyControl(rec) + "</dd>" +
"<dt>Session cwd</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
(notice ? "<dt>Current activity</dt><dd>" + notice + "</dd>" : "") +
"<dt>" + (notice ? "Historical last activity" : "Last activity") + "</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
"<dt>Scanned at</dt><dd>" + esc(rec.scannedAt || "—") + "</dd>" +
"<dt>Skipped lines</dt><dd>" + esc(rec.skippedLines) + "</dd>" +
"<dt>" + (notice ? "Historical last message" : "Last message") + "</dt><dd>" + esc(rec.lastAssistantText || "—") + "</dd>" +
"<dt>" + (notice ? "Historical last error" : "Last error") + "</dt><dd>" + esc(rec.lastError || "—") + "</dd>" +
"</dl></td></tr>";
return main + detail;
}
// Rows you marked Seen, in one place, so they can be found and put back.
// Collapsed by default; the open/closed choice survives a refresh.
function renderSeen(data) {
var list = (data.sessions || []).filter(function (r) { return r.seen; });
list.sort(function (a, b) {
var aa = a.ageSeconds === null || a.ageSeconds === undefined ? Infinity : a.ageSeconds;
var bb = b.ageSeconds === null || b.ageSeconds === undefined ? Infinity : b.ageSeconds;
return aa - bb;
});
seenHeading.textContent = "Seen (" + list.length + ")";
if (list.length === 0) {
seenBody.innerHTML = '<p class="empty">Nothing is marked seen. Rows you mark come back here, and return to "Waiting on you" on their own when the agent writes again.</p>';
return;
}
var rows = list.map(function (r) { return buildRowPair(r, true); }).join("");
seenBody.innerHTML =
'<div class="table-wrap"><table><thead><tr>' +
"<th scope=\"col\">Project</th><th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
"</tr></thead><tbody>" + rows + "</tbody></table></div>";
}
function renderWaiting(data) {
var list = (data.sessions || []).filter(function (r) { return r.waitingOnYou; });
list.sort(function (a, b) {
var ae = a.state === "error" ? 0 : 1, be = b.state === "error" ? 0 : 1;
if (ae !== be) return ae - be;
var aa = a.ageSeconds === null || a.ageSeconds === undefined ? Infinity : a.ageSeconds;
var bb = b.ageSeconds === null || b.ageSeconds === undefined ? Infinity : b.ageSeconds;
return aa - bb;
});
if (list.length === 0) {
waitingBody.innerHTML = '<p class="empty">Nothing is waiting on you.</p>';
return;
}
var rows = list.map(function (r) { return buildRowPair(r, true); }).join("");
waitingBody.innerHTML =
'<div class="table-wrap"><table><thead><tr>' +
"<th scope=\"col\">Project</th><th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
"</tr></thead><tbody>" + rows + "</tbody></table></div>";
}
function projectOrder(a, b) {
if (a === "fleet" && b === "fleet") return 0;
if (a === "fleet") return 1;
if (b === "fleet") return -1;
return a.localeCompare(b);
}
function renderProjects(data) {
var sessions = data.sessions || [];
var byProject = {};
sessions.forEach(function (r) {
(byProject[r.project] = byProject[r.project] || []).push(r);
});
var projects = Object.keys(byProject).sort(projectOrder);
if (projects.length === 0) {
projectsBody.innerHTML = '<p class="empty">No agents are configured.</p>';
return;
}
projectsBody.innerHTML = projects.map(function (project) {
var group = byProject[project].slice().sort(function (a, b) { return a.agent.localeCompare(b.agent); });
if (!(project in hideOfflineState)) hideOfflineState[project] = true;
if (!(project in hideSeenState)) hideSeenState[project] = true;
var hideOffline = hideOfflineState[project];
var hideSeen = hideSeenState[project];
var offlineHidden = 0, seenHidden = 0;
var visible = group.filter(function (r) {
if (hideOffline && r.state === "offline") { offlineHidden += 1; return false; }
if (hideSeen && r.seen) { seenHidden += 1; return false; }
return true;
});
var rows = visible.map(function (r) { return buildRowPair(r, false); }).join("");
var noteParts = [];
if (offlineHidden > 0) noteParts.push(offlineHidden + " offline hidden");
if (seenHidden > 0) noteParts.push(seenHidden + " seen hidden");
var note = noteParts.length > 0 ? '<p class="offline-note">' + noteParts.join(" \u00b7 ") + "</p>" : "";
var headCount = visible.length < group.length ? visible.length + " of " + group.length : String(group.length);
return (
'<div class="project-group">' +
'<div class="project-group-head"><h3>' + esc(project) + ' <span>(' + headCount + ")</span></h3>" +
'<div class="group-toggles">' +
'<label><input type="checkbox" class="hide-offline-toggle" data-project="' + esc(project) + '" ' + (hideOffline ? "checked" : "") + "> Hide offline</label>" +
'<label><input type="checkbox" class="hide-seen-toggle" data-project="' + esc(project) + '" ' + (hideSeen ? "checked" : "") + "> Hide seen</label>" +
"</div></div>" +
'<div class="table-wrap"><table><thead><tr>' +
"<th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
"</tr></thead><tbody>" + (rows || '<tr><td colspan="6" class="empty">No agents.</td></tr>') + "</tbody></table></div>" +
note + "</div>"
);
}).join("");
}
function renderFooter(data) {
var counts = data.counts || {};
var parts = STATES.map(function (s) { return cap(s) + " " + (counts[s] || 0); }).join(" · ");
parts += " · Seen " + ((data.seen || []).length);
footer.innerHTML = "<p>" + esc(parts) + " — generated " + esc(timeAgo(data.generatedAt)) + " (" + esc(data.generatedAt || "") + ")</p>";
}
// A refresh rebuilds the tables. If Jason is typing a reply, put the
// caret back where it was so the periodic refresh does not steal it.
function withReplyFocus(render) {
var active = document.activeElement;
var focus = active && active.classList && active.classList.contains("reply-text")
? { id: active.form.dataset.id, pos: active.selectionStart }
: null;
render();
if (!focus) return;
var forms = main.querySelectorAll('.reply-form[data-id="' + focus.id.replace(/"/g, '\\"') + '"]');
var input = forms.length ? forms[0].querySelector(".reply-text") : null;
if (!input) return;
input.focus();
try { input.setSelectionRange(focus.pos, focus.pos); } catch (e) {}
}
function renderAll() {
withReplyFocus(function () {
rowIdx = 0;
renderWaiting(lastData);
renderSeen(lastData);
renderProjects(lastData);
renderFooter(lastData);
});
}
function renderHeaderStatus() {
var updated = lastData ? timeAgo(lastData.generatedAt) : "never";
var refresh = paused ? "auto-refresh paused" : "next refresh in " + secondsLeft + "s";
headerStatus.textContent = "Updated " + updated + " · " + refresh;
}
function showError(msg) {
errorBanner.hidden = false;
errorBanner.textContent = "Could not refresh: " + msg + ". Showing the last known data.";
}
function hideError() {
errorBanner.hidden = true;
errorBanner.textContent = "";
}
function scheduleNextFetch() {
clearTimeout(timerId);
if (paused) return;
secondsLeft = REFRESH_MS / 1000;
timerId = setTimeout(runFetch, REFRESH_MS);
}
function runFetch() {
if (fetching) return;
fetching = true;
fetch("/api/board", { cache: "no-store" })
.then(function (res) {
return res.json().catch(function () {
throw new Error("the server sent a response that was not valid JSON");
}).then(function (body) {
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
return body;
});
})
.then(function (body) {
lastData = body;
hideError();
renderAll();
})
.catch(function (err) {
console.error("control board refresh failed", err);
showError(err && err.message ? err.message : "unknown error");
})
.then(function () {
fetching = false;
renderHeaderStatus();
scheduleNextFetch();
});
}
function setPaused(p) {
paused = p;
pauseBtn.textContent = paused ? "Resume" : "Pause";
pauseBtn.setAttribute("aria-pressed", String(paused));
if (paused) clearTimeout(timerId);
else scheduleNextFetch();
renderHeaderStatus();
}
function postSeen(btn) {
if (fetching) return;
fetching = true;
btn.disabled = true;
fetch("/api/seen", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ project: btn.dataset.project, agent: btn.dataset.agent, lastActivity: btn.dataset.last, seen: btn.dataset.seen === "true" })
})
.then(function (res) {
return res.json().catch(function () {
throw new Error("the server sent a response that was not valid JSON");
}).then(function (body) {
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
return body;
});
})
.then(function (body) {
lastData = body;
hideError();
renderAll();
})
.catch(function (err) {
console.error("marking seen failed", err);
showError(err && err.message ? err.message : "unknown error");
btn.disabled = false;
})
.then(function () {
fetching = false;
renderHeaderStatus();
scheduleNextFetch();
});
}
function postReply(form) {
var id = form.dataset.id;
var input = form.querySelector(".reply-text");
var text = input.value;
if (!text.trim()) return;
var sendBtn = form.querySelector("button");
sendBtn.disabled = true;
fetch("/api/reply", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ agent: id, text: text })
})
.then(function (res) {
return res.json().catch(function () {
throw new Error("the server sent a response that was not valid JSON");
}).then(function (body) {
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
return body;
});
})
.then(function (body) {
replyReceipts[id] = body;
if (body.delivered) delete replyDrafts[id];
renderAll();
// The seat's log shows the effect; fetch now instead of waiting a tick.
if (body.delivered && !paused) runFetch();
})
.catch(function (err) {
console.error("reply failed", err);
replyReceipts[id] = { delivered: false, exitCode: null, error: err && err.message ? err.message : "unknown error" };
renderAll();
});
}
main.addEventListener("submit", function (e) {
var form = e.target.closest(".reply-form");
if (!form) return;
e.preventDefault();
postReply(form);
});
main.addEventListener("input", function (e) {
var input = e.target.closest(".reply-text");
if (!input) return;
replyDrafts[input.form.dataset.id] = input.value;
});
main.addEventListener("click", function (e) {
var seenBtn = e.target.closest(".seen-toggle");
if (seenBtn) return postSeen(seenBtn);
var btn = e.target.closest(".row-toggle");
if (!btn) return;
var detail = document.getElementById("detail-" + btn.dataset.idx);
var expanded = btn.getAttribute("aria-expanded") === "true";
btn.setAttribute("aria-expanded", String(!expanded));
detail.hidden = expanded;
if (expanded) delete openDetails[btn.dataset.key];
else openDetails[btn.dataset.key] = true;
});
main.addEventListener("change", function (e) {
var cb = e.target.closest(".hide-offline-toggle, .hide-seen-toggle");
if (!cb) return;
var stateMap = cb.classList.contains("hide-seen-toggle") ? hideSeenState : hideOfflineState;
stateMap[cb.dataset.project] = cb.checked;
renderProjects(lastData);
});
refreshBtn.addEventListener("click", runFetch);
pauseBtn.addEventListener("click", function () { setPaused(!paused); });
setInterval(function () {
if (!paused && secondsLeft > 0) secondsLeft -= 1;
renderHeaderStatus();
}, 1000);
renderHeaderStatus();
runFetch();
})();
</script>
</body>
</html>
+3 -45
View File
@@ -16,7 +16,7 @@
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, renameSync } from "node:fs";
import { join, basename, isAbsolute, resolve } from "node:path";
import { homedir } from "node:os";
import { spawnSync, execFile } from "node:child_process";
import { spawnSync } from "node:child_process";
import { readRegistration, samePath, SeatError, LAYOUTS } from "../../seat/src/seat.mjs";
import { discoverDiscordAgents, inspectDiscord, newestDiscordSession } from './discord.mjs';
@@ -193,46 +193,17 @@ export function parsePanes(listPanesOutput) {
// "unknown", never assumed alive).
// workspace the current path of the first pane running pi, else null.
// `exec` is injectable for tests.
export function tmuxInspect(tmux, { exec = spawnSync } = {}) {
return inspectFromResult(exec("tmux", tmuxArgs(tmux), { encoding: "utf8", timeout: TMUX_TIMEOUT_MS }));
}
const TMUX_TIMEOUT_MS = 5000;
function tmuxArgs({ socket, session }) {
export function tmuxInspect({ socket, session }, { exec = spawnSync } = {}) {
const args = [];
if (socket) args.push("-L", socket);
args.push("list-panes", "-s", "-t", `=${session}`, "-F", "#{pane_current_command}\t#{pane_current_path}");
return args;
}
// Shared by the sync and async paths, so they cannot drift.
function inspectFromResult(r) {
const r = exec("tmux", args, { encoding: "utf8", timeout: 5000 });
if (r.error) return { alive: null, workspace: null };
if (r.status !== 0) return { alive: false, workspace: null };
const pane = parsePanes(r.stdout ?? "").find((p) => PI_COMMANDS.includes(p.command));
return { alive: Boolean(pane), workspace: pane?.path ?? null };
}
// Async child run for the Console's in-process board (B1). Same result shape as
// spawnSync: { status, signal, stdout, stderr, error }. A timeout or a failure to
// start is an error, never a status. The Console's event loop must not block here.
export function runAsync(file, args, { encoding = "utf8", timeout, env } = {}) {
return new Promise((resolvePromise) => {
execFile(file, args, { encoding, timeout, env, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
if (!err) return resolvePromise({ status: 0, signal: null, stdout, stderr, error: undefined });
if (err.killed) return resolvePromise({ status: null, signal: err.signal ?? null, stdout, stderr, error: Object.assign(new Error(`timed out after ${timeout} ms`), { code: "ETIMEDOUT" }) });
if (typeof err.code === "number") return resolvePromise({ status: err.code, signal: null, stdout, stderr, error: undefined });
return resolvePromise({ status: null, signal: null, stdout, stderr, error: err });
});
});
}
// Async twin of tmuxInspect. `exec` is injectable for tests.
export async function tmuxInspectAsync(tmux, { exec = runAsync } = {}) {
return inspectFromResult(await exec("tmux", tmuxArgs(tmux), { encoding: "utf8", timeout: TMUX_TIMEOUT_MS }));
}
// Liveness only, for callers that do not need the pane path.
export function tmuxIsAlive(tmux, opts) {
return tmuxInspect(tmux, opts).alive;
@@ -491,16 +462,3 @@ export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAliv
writeAtomic(join(boardDir, "index.json"), index);
return index;
}
// Async scan for the Console's in-process board (B1). With no isAlive, every tmux
// check runs through the async exec first, in parallel, and the sync scan body
// then reads those answers. A caller's sync isAlive (the CLI, tests) is used as is.
// One scan body serves both paths, so the records cannot drift.
export async function scanAsync(specs, { isAlive, exec = runAsync, ...rest } = {}) {
if (isAlive) return scan(specs, { ...rest, isAlive });
const checks = new Map();
const keyOf = (tmux) => JSON.stringify([tmux.socket ?? null, tmux.session]);
const pending = [...new Map(specs.filter((s) => !s.connector).map((s) => [keyOf(s.tmux), s.tmux])).values()];
await Promise.all(pending.map(async (tmux) => checks.set(keyOf(tmux), await tmuxInspectAsync(tmux, { exec }))));
return scan(specs, { ...rest, isAlive: (tmux) => checks.get(keyOf(tmux)) ?? { alive: null, workspace: null } });
}
+191 -49
View File
@@ -1,30 +1,55 @@
// Control board: the pure board logic the Console calls in-process (row 198).
// No listener. The writer on 7331 is retired; the Console serves the board
// routes behind its own auth (packages/webui/src/serve.mjs). The board opens no
// socket and never binds a port.
// Control board step 2: a tiny local web server. No dependencies, no auth.
// It only ever binds to a loopback address (fail closed otherwise).
//
// Route logic lives in src/board.mjs. This module keeps the pure helpers the
// Console and the tests share: reply planning and shaping, the Host/Origin check
// (foreignRequest), and the conversation query and refusal map.
// GET / the page (src/page.html)
// GET /api/board re-runs the scanner and returns index.json as JSON
// POST /api/seen {project, agent, lastActivity, seen?} marks a row as seen
// (or clears the mark with seen:false); rescans, returns index
// POST /api/reply {agent: "<project>/<agent>", text} delivers text to the
// seat's tmux pane through tools/tmux/agent-send.sh (#1505);
// answers {delivered, exitCode, stdout, stderr, ...}
// GET /healthz {"ok":true}
// GET /api/conversations
// read-only Pi conversation catalogue (#1507, CHAT-02)
// GET /api/conversation?id=<conversation>[&branch=<branch>][&cursor=<cursor>]
// one CHAT-01 history page: the first page of a branch (the
// default one without branch), or the next page (or a
// follow) for a cursor. A cursor call repeats the page's
// branch; without it the answer is 400. Refusals carry
// {error, refusal: {code, reconcile}}.
//
// POST requires Content-Type: application/json. A plain form post from another
// site in the browser cannot set that header without a CORS preflight, and this
// server answers no preflight, so a stray page cannot flip marks.
//
// Every route first checks Host and Origin (#1507). Binding to loopback does
// not stop DNS rebinding: a page whose name now resolves to 127.0.0.1 reaches
// a Console on this host as its own origin, with its own name as Host, and could
// read the board or post /api/reply into a live pane. A Host that is not a
// loopback name on the listening port, or any Origin other than the server's
// own, gets 403 before anything else runs. The Console applies the same check.
// this server as its own origin, with its own name as Host, and could read
// /api/board or post /api/reply into a live pane. A Host that is not a loopback
// name on this server's port, or any Origin other than this server's own, gets
// 403 before anything else runs. Same check as packages/webui/src/serve.mjs.
// No CORS headers are ever sent.
//
// Every /api/board request rescans, so the page is never staler than its
// refresh timer. The scan rewrites the derived board files as a side effect.
//
// The conversation routes read only: packages/conversation lists the repository
// specs' Pi session roots (never fleet or connector ones), opens files
// O_NOFOLLOW and writes nothing. Registrations are hints there too. The
// conversation id is opaque and no path comes from the request.
// conversation id is opaque and no path comes from the request. Cursors live
// in this server's memory, bound to the one actor this unauthenticated
// loopback route has, local-operator.
import { createServer as createHttpServer } from "node:http";
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { isIP } from "node:net";
import { hostname } from "node:os";
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { seenKey, runAsync } from "./scan.mjs";
import { scan, markSeen, seenKey, ConfigError, loadRegistrations } from "./scan.mjs";
import { createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs";
const MAX_BODY = 4096;
// Reply-from-board (#1505). The board owns no transport: a reply is handed to
// the repository's own inter-agent channel, tools/tmux/agent-send.sh, which
@@ -46,48 +71,27 @@ const REPLY_TIMEOUT_MS = 15000;
// Refusals (4xx) happen before the tool runs and carry { error }. Once the
// tool has run the answer is 200 with delivered true/false, the exit code and
// both output streams verbatim, whatever the code was.
export function replyToRow(opts) {
return replyWith(opts, (exec, args, env, agentSend) => exec(agentSend, args, { encoding: "utf8", timeout: REPLY_TIMEOUT_MS, env }), { exec: spawnSync });
}
// Async twin of replyToRow for the in-process board (B1). `exec` is async.
export async function replyToRowAsync(opts) {
return replyWith(opts, async (exec, args, env, agentSend) => exec(agentSend, args, { encoding: "utf8", timeout: REPLY_TIMEOUT_MS, env }), { exec: runAsync, async: true });
}
function replyWith({ index, key, text, agentSend = DEFAULT_AGENT_SEND, exec, now = () => new Date(), host = hostname().split(".")[0] }, send, { exec: defaultExec, async = false }) {
const plan = planReply({ index, key, text, host });
if (plan.refusal) return async ? Promise.resolve(plan.refusal) : plan.refusal;
// The registration says which socket the seat is on. A launcher-exported
// MOSAIC_TMUX_SOCKET in this server's own environment must not override it.
const env = { ...process.env };
delete env.MOSAIC_TMUX_SOCKET;
const finish = (r) => replyBody({ r, key, agentSend, session: plan.session, socket: plan.socket, now });
const out = send(exec ?? defaultExec, plan.args, env, agentSend);
return async ? Promise.resolve(out).then(finish) : finish(out);
}
function planReply({ index, key, text, host }) {
const refuse = (status, error) => ({ refusal: { status, body: { error } } });
if (typeof key !== "string" || !key) return refuse(400, "agent must be the row id <project>/<agent>");
if (typeof text !== "string" || !text.trim()) return refuse(400, "text must be a non-empty string");
if (text.length > REPLY_LIMIT) return refuse(400, `text is longer than ${REPLY_LIMIT} characters`);
export function replyToRow({ index, key, text, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync, now = () => new Date(), host = hostname().split(".")[0] }) {
if (typeof key !== "string" || !key) return { status: 400, body: { error: "agent must be the row id <project>/<agent>" } };
if (typeof text !== "string" || !text.trim()) return { status: 400, body: { error: "text must be a non-empty string" } };
if (text.length > REPLY_LIMIT) return { status: 400, body: { error: `text is longer than ${REPLY_LIMIT} characters` } };
const rec = index.sessions.find((r) => seenKey(r) === key);
if (!rec) return refuse(404, `unknown row: ${key}`);
if (rec.connector || / \(discord: [a-z0-9][a-z0-9._-]{0,63}\)$/.test(rec.agent)) return refuse(409, "board replies are disabled for Discord connectors");
if (!rec) return { status: 404, body: { error: `unknown row: ${key}` } };
if (rec.connector || / \(discord: [a-z0-9][a-z0-9._-]{0,63}\)$/.test(rec.agent)) return { status: 409, body: { error: "board replies are disabled for Discord connectors" } };
const reg = rec.registered;
if (!reg) return refuse(409, "reply needs a registered seat (start it through scripts/mosaic launch)");
if (reg.alive === false) return refuse(409, `registration is stale: pid ${reg.pid} is gone`);
if (!reg.tmux || !reg.tmux.session) return refuse(409, "registration has no tmux session to address");
if (!reg) return { status: 409, body: { error: "reply needs a registered seat (start it through scripts/mosaic launch)" } };
if (reg.alive === false) return { status: 409, body: { error: `registration is stale: pid ${reg.pid} is gone` } };
if (!reg.tmux || !reg.tmux.session) return { status: 409, body: { error: "registration has no tmux session to address" } };
const session = reg.tmux.session;
const socket = reg.tmux.socket || null;
const args = ["-s", session, "-S", `${host}:${REPLY_SENDER}`];
if (socket) args.push("-L", socket);
args.push("-m", `${text}\n${REPLY_TRAILER}`);
return { args, session, socket };
}
function replyBody({ r, key, agentSend, session, socket, now }) {
// The registration says which socket the seat is on. A launcher-exported
// MOSAIC_TMUX_SOCKET in this server's own environment must not override it.
const env = { ...process.env };
delete env.MOSAIC_TMUX_SOCKET;
const r = exec(agentSend, args, { encoding: "utf8", timeout: REPLY_TIMEOUT_MS, env });
if (r.error) return { status: 500, body: { error: `could not run ${agentSend}: ${r.error.message}` } };
const exitCode = r.status;
return {
@@ -106,6 +110,39 @@ function replyBody({ r, key, agentSend, session, socket, now }) {
};
}
function sendJson(res, status, body) {
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
res.end(JSON.stringify(body) + "\n");
}
function readJsonBody(req) {
return new Promise((resolvePromise, reject) => {
const type = String(req.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
if (type !== "application/json") return reject(new Error("Content-Type must be application/json"));
const chunks = [];
let size = 0;
req.on("data", (c) => {
size += c.length;
if (size > MAX_BODY) {
req.destroy();
reject(new Error(`body larger than ${MAX_BODY} bytes`));
return;
}
chunks.push(c);
});
req.on("end", () => {
try {
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("body must be a JSON object");
resolvePromise(parsed);
} catch (err) {
reject(new Error(`invalid JSON body: ${err.message}`));
}
});
req.on("error", reject);
});
}
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
export function isLoopbackHost(host) {
@@ -150,6 +187,11 @@ export const REFUSAL_STATUS = {
};
const QUERY_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
function sendConversationJson(res, status, body) {
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", "x-content-type-options": "nosniff" });
res.end(JSON.stringify(body) + "\n");
}
// GET /api/conversation: id is required; branch and cursor are optional; any
// other, repeated or malformed parameter is a 400.
function conversationQuery(url) {
@@ -178,3 +220,103 @@ export function conversationResponse(reader, url) {
if (out.ok) return { status: 200, body: out };
return { status: REFUSAL_STATUS[out.refusal.code] ?? 422, body: { error: out.refusal.message, refusal: { code: out.refusal.code, reconcile: out.refusal.reconcile } } };
}
export function loadPage(path = join(import.meta.dirname, "page.html")) {
return readFileSync(path, "utf8");
}
// specs: agent specs to scan on each request. boardDir: where scan writes.
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync, conversationReader = null }) {
const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive, discordDataRoot });
const reader = conversationReader ?? createReader({ roots: () => rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations) });
return createHttpServer((req, res) => {
const refused = foreignRequest(req);
if (refused) {
req.resume();
return sendJson(res, 403, { error: refused });
}
const url = new URL(req.url, "http://localhost");
if (req.method === "POST" && url.pathname === "/api/reply") {
return readJsonBody(req)
.then((body) => {
let index;
try {
index = rescan();
} catch (err) {
return sendJson(res, 500, { error: err.message });
}
const out = replyToRow({ index, key: body.agent, text: body.text, agentSend, exec, now });
sendJson(res, out.status, out.body);
})
.catch((err) => sendJson(res, 400, { error: err.message }));
}
if (req.method === "POST" && url.pathname === "/api/seen") {
return readJsonBody(req)
.then((body) => {
try {
markSeen(boardDir, { project: body.project, agent: body.agent, lastActivity: body.lastActivity, seen: body.seen ?? true });
} catch (err) {
return sendJson(res, 400, { error: err.message });
}
try {
sendJson(res, 200, rescan());
} catch (err) {
sendJson(res, 500, { error: err.message });
}
})
.catch((err) => sendJson(res, 400, { error: err.message }));
}
if (req.method !== "GET" && req.method !== "HEAD") {
res.writeHead(405, { "content-type": "text/plain" });
return res.end("method not allowed\n");
}
if (url.pathname === "/" || url.pathname === "/index.html") {
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
return res.end(page);
}
if (url.pathname === "/api/board") {
let index;
try {
index = rescan();
} catch (err) {
res.writeHead(500, { "content-type": "application/json", "cache-control": "no-store" });
return res.end(JSON.stringify({ error: err.message }) + "\n");
}
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
return res.end(JSON.stringify(index) + "\n");
}
if (url.pathname === "/api/conversations" || url.pathname === "/api/conversation") {
let out;
try {
out = conversationResponse(reader, url);
} catch (err) {
process.stderr.write(`conversation read failed: ${err.message}\n`);
out = { status: 500, body: { error: "conversation read failed" } };
}
return sendConversationJson(res, out.status, out.body);
}
if (url.pathname === "/favicon.ico") {
res.writeHead(204);
return res.end();
}
if (url.pathname === "/healthz") {
res.writeHead(200, { "content-type": "application/json" });
return res.end('{"ok":true}\n');
}
res.writeHead(404, { "content-type": "text/plain" });
res.end("not found\n");
});
}
// Resolves to the listening server. Refuses any non-loopback host.
export async function startServer({ host = "127.0.0.1", port = 7331, ...rest }) {
if (!isLoopbackHost(host)) throw new ConfigError(`refusing to bind to non-loopback host: ${host} (no auth in the MVP)`);
const server = createServer(rest);
return new Promise((resolvePromise, reject) => {
server.once("error", reject);
server.listen(port, host, () => {
server.off("error", reject);
resolvePromise(server);
});
});
}
-129
View File
@@ -1,129 +0,0 @@
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, appendFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { createBoard } from "../src/board.mjs";
const pkgRoot = resolve(import.meta.dirname, "..");
const cli = join(pkgRoot, "src", "cli.mjs");
// Track every tmpdir we create so a stray failure never leaves fixtures behind.
const roots = [];
function makeRoot() {
const root = mkdtempSync(join(tmpdir(), "control-board-board-test-"));
roots.push(root);
return root;
}
after(() => {
for (const root of roots) rmSync(root, { recursive: true, force: true });
});
// One agent whose last message is an assistant reply (waiting on the user), so
// a mark on it is visible in the index. No registration; liveness stubbed so no tmux runs.
function fixture() {
const root = makeRoot();
const sessionsDir = join(root, "sessions");
mkdirSync(sessionsDir, { recursive: true });
writeFileSync(
join(sessionsDir, "session.jsonl"),
[
JSON.stringify({ type: "session", id: "s1", timestamp: "2026-10-20T18:59:00Z", cwd: root }),
JSON.stringify({ type: "message", timestamp: "2026-10-20T19:00:00Z", message: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "Input needed: which branch should I use?" }] } }),
].join("\n") + "\n",
);
const boardDir = join(root, "board");
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
const isAlive = () => ({ alive: true, workspace: null });
return { root, boardDir, specs, isAlive };
}
// Pinned order (Webby, Mos mos-1052): markSeen writes, bumps the counter, then scans.
test("markSeen runs write, bump, scan in that order", async () => {
const { boardDir, specs, isAlive } = fixture();
const events = [];
const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name) });
await board.markSeen({ project: "proj", agent: "agent1", lastActivity: "2026-10-20T19:00:00Z" });
assert.deepEqual(events, ["write", "bump", "scan"]);
});
// Mutant target: a scan run before the write, or a join of a pre-write scan,
// would let the answer omit the mark it was asked to show.
test("markSeen answer includes the mark it just wrote", async () => {
const { boardDir, specs, isAlive } = fixture();
const board = createBoard({ specs, boardDir, isAlive });
const before = await board.index();
assert.deepEqual(before.seen, []);
const rec = before.sessions[0];
const index = await board.markSeen({ project: "proj", agent: "agent1", lastActivity: rec.lastActivity });
assert.deepEqual(index.seen, ["proj/agent1"]);
});
// A reply to a row that is not registered is refused before any send, so the
// send and bump events must not appear.
test("reply refuses an unregistered row and never sends", async () => {
const { boardDir, specs, isAlive } = fixture();
const events = [];
const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name), exec: async () => assert.fail("no send on refusal") });
const out = await board.reply({ agent: "proj/agent1", text: "hello" });
assert.equal(out.status, 409);
assert.equal(events.includes("send"), false);
assert.equal(events.includes("bump"), false);
});
// The retired writer refuses to start. Exit 2 with a pointer to the Console.
test("cli serve refuses and points to the Console", () => {
const r = spawnSync(process.execPath, [cli, "serve"], { encoding: "utf8" });
assert.equal(r.status, 2);
assert.match(r.stderr, /retired on 7331/);
assert.match(r.stderr, /webui/);
});
// M15 (Webby N1): index() must not join a scan that started before the last write.
// A scan already answered before the mark would hide the mark; the join is what
// this kills, so the event log must show a fresh scan after the write.
test("index after a write starts a fresh scan and sees the mark (M15)", async () => {
const { boardDir, specs, isAlive } = fixture();
const events = [];
const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name) });
await board.index();
const before = events.length;
await board.markSeen({ project: "proj", agent: "agent1", lastActivity: "2026-10-20T19:00:00Z" });
const after = await board.index();
assert.deepEqual(events.slice(before), ["write", "bump", "scan", "scan"]);
assert.deepEqual(after.seen, ["proj/agent1"]);
});
// B4 (Webby r3): a settled scan is not in flight. With no write between two index()
// calls, the second must start a fresh scan and show activity appended in between.
// The mutant: drop the clear on settle in index(); the second call then joins the
// settled promise, logs one scan, and shows the first view.
test("index after a settled scan starts a fresh scan and shows new activity (B4)", async () => {
const { boardDir, specs, isAlive } = fixture();
const events = [];
const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name) });
await board.index();
appendFileSync(join(specs[0].sessionsDir, "session.jsonl"), JSON.stringify({ type: "message", timestamp: "2026-10-20T19:05:00Z", message: { role: "user", content: [{ type: "text", text: "new activity" }] } }) + "\n");
const second = await board.index();
assert.deepEqual(events, ["scan", "scan"]);
assert.equal(second.sessions[0].lastActivity, "2026-10-20T19:05:00Z");
});
// B4 (Webby r3): a scan that rejects is cleared too. The next index() runs fresh
// and answers, rather than replaying the rejection on every poll.
test("index after a rejected scan starts a fresh scan (B4)", async () => {
const { boardDir, specs, isAlive } = fixture();
const events = [];
let fail = true;
const flaky = (tmux) => {
if (fail) throw new Error("boom");
return isAlive(tmux);
};
const board = createBoard({ specs, boardDir, isAlive: flaky, onEvent: (name) => events.push(name) });
await assert.rejects(board.index(), /boom/);
fail = false;
const answer = await board.index();
assert.deepEqual(events, ["scan", "scan"]);
assert.equal(answer.sessions[0].agent, "agent1");
});
+12 -12
View File
@@ -6,8 +6,7 @@ import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { discoverDiscordAgents, inspectDiscord } from '../src/discord.mjs';
import { scan, scanAgent } from '../src/scan.mjs';
import { replyToRow } from '../src/serve.mjs';
import { createBoard } from '../src/board.mjs';
import { replyToRow, startServer } from '../src/serve.mjs';
import { identityOf } from '../../discord/src/journal.mjs';
import { envelope } from '../../discord/src/context.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
@@ -116,20 +115,21 @@ test('connector reply refusal precedes forged live tmux registration; ordinary a
r.agent = 'ordinary'; assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/ordinary', text: 'hello', exec }).status, 200); assert.equal(calls, 1);
});
test('board rescans connector discovery and refuses reply without transport', async () => {
const f = fixture();
test('server rescans connector discovery and refuses HTTP reply without transport', async () => {
const f = fixture(); let server;
try {
const sessionsDir = join(f.root, 'sessions', 'discord-demo'), seatsDir = join(f.root, 'seats');
writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'forged', project: 'fleet', sessionsDir, seatDir: sessionsDir, launchScript: join(f.root, 'unused.sh'), layout: 'repo', defaultWorkspace: f.root }, task: 'forged', tmux: { session: 'forged', socket: null }, pid: process.pid }));
const board = createBoard({ specs: [], seatsDir, boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } });
const payload = await board.index();
assert.equal(payload.sessions[0].state, 'idle'); assert.equal(payload.sessions[0].connector.alive, true);
assert.equal(payload.sessions[0].registered, null); assert.notEqual(payload.sessions[0].task, 'forged');
const { status } = await board.reply({ agent: 'fleet/researcher (discord: demo)', text: 'hello' });
assert.equal(status, 409);
server = await startServer({ port: 0, specs: [], seatsDir, boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } });
const base = `http://127.0.0.1:${server.address().port}`;
let response = await fetch(base + '/api/board'); const board = await response.json();
assert.equal(board.sessions[0].state, 'idle'); assert.equal(board.sessions[0].connector.alive, true);
assert.equal(board.sessions[0].registered, null); assert.notEqual(board.sessions[0].task, 'forged');
response = await fetch(base + '/api/reply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'fleet/researcher (discord: demo)', text: 'hello' }) });
assert.equal(response.status, 409);
rmSync(join(f.root, 'discord', 'demo.json'));
assert.equal((await board.index()).sessions.length, 0);
} finally { f.cleanup(); }
assert.equal((await (await fetch(base + '/api/board')).json()).sessions.length, 0);
} finally { if (server) await new Promise(r => server.close(r)); f.cleanup(); }
});
test('connector session links and linked directories are not read', () => {
@@ -1,44 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join, resolve } from "node:path";
// Row 198: the 7331 writer is retired and the board runs in the Console. These
// are source and doc checks; each names the design mutant it kills (M1, M2, M9).
const repo = resolve(import.meta.dirname, "..", "..", "..");
const read = (path) => readFileSync(join(repo, path), "utf8");
const srcDir = join(repo, "packages", "control-board", "src");
const srcFiles = readdirSync(srcDir).filter((f) => f.endsWith(".mjs"));
// M1: cli.mjs starts no server, not even on the retired port.
test("retired: cli.mjs starts no server (M1)", () => {
assert.doesNotMatch(read("packages/control-board/src/cli.mjs"), /startServer|createServer|\.listen\(/);
});
// M2: nothing in the board's source opens a socket or exports a server.
test("retired: no board source imports http or exports a server (M2)", () => {
for (const file of srcFiles) {
const text = read(join("packages", "control-board", "src", file));
assert.doesNotMatch(text, /from ["']node:http["']|createServer\(|\.listen\(/, file);
assert.doesNotMatch(text, /export (async )?function (startServer|createServer)\b/, file);
}
});
// M9: a live doc or script must not tell anyone to run the retired serve or open 7331.
// A line that says it is retired is allowed. Append-only logs and plans are not scanned.
test("retired: live docs and scripts do not tell anyone to run control-board serve (M9)", () => {
const liveDocs = ["README.md", "AGENTS.md"];
for (const pkg of readdirSync(join(repo, "packages"))) {
if (existsSync(join(repo, "packages", pkg, "README.md"))) liveDocs.push(join("packages", pkg, "README.md"));
}
for (const file of readdirSync(join(repo, "scripts"))) {
if (file.endsWith(".sh")) liveDocs.push(join("scripts", file));
}
const retiredUse = /control-board\/src\/cli\.mjs serve|control-board serve\b|127\.0\.0\.1:7331/;
const retiredNote = /retired|no longer|exits 2/i;
for (const file of liveDocs) {
read(file).split("\n").forEach((line, i) => {
if (retiredUse.test(line) && !retiredNote.test(line)) assert.fail(`${file}:${i + 1} tells people to run the retired board: ${line.trim()}`);
});
}
});
File diff suppressed because it is too large. Load diff
+57 -4
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 { GUARD_CODES, FIXED_TEXTS } from "../../library/src/reply-protocol.mjs";
import { fetchReview, landWork, pushWorking } from "./git-tools.mjs";
export const EXIT = Object.freeze({ stopped: 0, founder: 20, claim: 21, session: 22, broker: 23, usage: 2 });
@@ -458,8 +459,9 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
return EXIT.founder;
}
const client = new Client({ path: session.brokerSocket, cap, timeout: 30_000 });
let library = false;
try {
await client.call("role.claim");
library = (await client.call("role.claim"))?.library === true;
} catch (e) {
// No answer is not a refusal: the broker is down or the outcome is unknown.
if (e.code === "outcome-unknown") {
@@ -582,9 +584,60 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
if (!turn.ok) log(`runner: turn ${n} for message ${m.id} failed: ${turn.reason}`);
try {
if (m.class !== "RESULT") {
const body = turn.ok ? turn.text || "(the session gave no answer)" : `The turn failed: ${turn.reason}`;
await client.call("message.send", { to: m.from_role, body: clip(body) + unslop.note(), class: "RESULT", in_reply_to: m.id });
unslop.sent();
if (!library) {
const body = turn.ok ? turn.text || "(the session gave no answer)" : `The turn failed: ${turn.reason}`;
await client.call("message.send", { to: m.from_role, body: clip(body) + unslop.note(), class: "RESULT", in_reply_to: m.id });
unslop.sent();
} else {
const send = async (answer) => {
const raw = answer.ok ? answer.text || FIXED_TEXTS.empty : FIXED_TEXTS.failed;
const fixed = Object.values(FIXED_TEXTS).includes(raw);
let body = raw;
if (!fixed) {
let value;
try { value = JSON.parse(raw); } catch {}
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
// Malformed objects still reach the guard and its reply-invalid refusal.
if (typeof value.text === "string") value.text += unslop.note();
body = JSON.stringify(value);
} else body = clip(raw) + unslop.note();
}
try {
await client.call("message.send", { to: m.from_role, body, class: "RESULT", in_reply_to: m.id });
if (!fixed) unslop.sent();
} catch (error) {
// The existing broker body cap is smaller than the socket's 64 KiB cap.
// Both follow the one-retry rule, without changing non-library sends.
if (error.code === "invalid-request" && body.length > 32768) {
error = Object.assign(new Error("request-too-large"), { code: "request-too-large" });
}
throw error;
}
};
try { await send(turn); }
catch (error) {
if (!GUARD_CODES.includes(error.code) && error.code !== "request-too-large") throw error;
log(`runner: reply to ${m.id} refused: ${error.code}${error.reason ? `: ${error.reason}` : ""}`);
const reason = error.code === "request-too-large"
? "The message exceeds the 64 KiB request limit or the 32768-character body limit. Send a shorter answer."
: error.reason ?? "The reply did not pass the citation check.";
++n;
try { await client.call("usage.begin", { turn: `${m.id}:retry` }); }
catch (e) { log(`runner: usage baseline unavailable: ${e.code ?? "failed"}`); }
const retry = await runTurn({ session, request: `${block ?? ""}\n${turnRequest(m)}\n\nYour answer was refused with ${error.code}: ${reason}\nReply again in this same session.`, n, env: turnEnv, onChild: (c) => (child = c) });
unslop.read();
try { await client.call("usage.end", { turn: `${m.id}:retry` }); }
catch (e) { log(`runner: usage outcome unavailable: ${e.code ?? "failed"}`); }
if (stopping) break loop;
if (!retry.ok) log(`runner: retry turn ${n} for message ${m.id} failed: ${retry.reason}`);
try { await send(retry); }
catch (again) {
if (!GUARD_CODES.includes(again.code) && again.code !== "request-too-large") throw again;
log(`runner: retry to ${m.id} refused: ${again.code}`);
await send({ ok: true, text: again.code === "request-too-large" ? FIXED_TEXTS.tooLong : FIXED_TEXTS.uncited });
}
}
}
}
await client.call("message.read", { id: m.id });
} catch (e) {
@@ -0,0 +1,144 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { serve } from '../../bus/src/server.mjs';
import { FIXED_TEXTS } from '../../library/src/reply-protocol.mjs';
import { GuardError } from '../../library/src/guard.mjs';
import { citationWorld, citedBody, fixtureCite, guardCases } from '../../bus/tests/citation-helpers.mjs';
const RUNNER = new URL('../src/runner.mjs', import.meta.url).pathname;
const ADAPTER = new URL('./fixtures/citation-adapter.mjs', import.meta.url).pathname;
async function until(fn) {
const stop = Date.now() + 15000;
while (Date.now() < stop) { const value = fn(); if (value) return value; await new Promise(r => setTimeout(r, 15)); }
throw Error('fixture timed out');
}
async function setup(t, stepsFor, { library = true, requireCitation = true, sendAuthority = true, guard } = {}) {
const f = citationWorld(t, { library, sendAuthority, guard }), runDir = join(f.root, 'run'), bundle = join(runDir, 'bundle');
mkdirSync(bundle, { recursive: true }); mkdirSync(join(f.root, 'ws'));
const steps = stepsFor(f.commit), brokerSocket = join(f.root, 'broker.sock');
writeFileSync(f.settings, JSON.stringify({ replyLimits: { requireCitation } }));
const server = await serve({ broker: f.broker, path: brokerSocket });
writeFileSync(join(runDir, 'fixture-steps.json'), JSON.stringify(steps));
const adapter = join(runDir, 'adapter.sh'); writeFileSync(adapter, `exec '${process.execPath}' '${ADAPTER}'\n`);
writeFileSync(join(bundle, 'prompt.md'), 'Fixture only.\n');
writeFileSync(join(bundle, 'tools.json'), JSON.stringify([{ name: 'send_message', verb: 'message.send', action: 'message.send' }]));
writeFileSync(join(bundle, 'policy.json'), JSON.stringify({ needs: [], credentials: [] }));
writeFileSync(join(runDir, 'session.json'), JSON.stringify({ business: 'acme', instance: 'coder', run: 'coder-run', harness: 'pi',
provider: 'fixture', model: 'fixture', brokerSocket, workspace: join(f.root, 'ws'), sessionDir: join(runDir, 'session'), adapter,
bundle: { prompt: join(bundle, 'prompt.md'), tools: join(bundle, 'tools.json'), policy: join(bundle, 'policy.json') },
toolSocket: join(runDir, 'tools.sock'), turnMarker: join(runDir, 'turn.done'), turnEnd: 'marker', harnessEnv: {}, pollInterval: 20, turnTimeout: 10 }));
const child = spawn(process.execPath, [RUNNER, runDir], { stdio: ['pipe', 'ignore', 'pipe'], env: { PATH: process.env.PATH } });
let log = ''; child.stderr.on('data', b => { log += b; }); child.stdin.on('error', () => {}); child.stdin.end(JSON.stringify({ cap: f.agent }) + '\n');
const exited = once(child, 'exit');
t.after(async () => { child.kill('SIGTERM'); await exited; await server.close(); });
await until(() => f.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
const ask = async () => {
const sent = await f.call(f.human, 'message.send', { to: 'coder', body: 'Fixture question.', class: 'REQUEST' });
const reply = await until(() => f.store.get('SELECT * FROM messages WHERE in_reply_to=?', sent.id));
await until(() => f.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id));
return reply;
};
return { ...f, runDir, ask, log: () => log, count: () => Number(readFileSync(join(runDir, 'fixture-count'), 'utf8')) };
}
for (const code of ['reply-invalid', 'citation-missing', 'citations-over-limit', 'citation-invalid', 'playground-cited-as-canon', 'citation-cross-universe', 'citation-unfetched']) {
test(`C2-06 RESULT ${code} retries once, delivers only the valid answer and names reason`, async t => {
const f = await setup(t, commit => [{ text: guardCases(commit).find(c => c[0] === code)[1] }, { text: citedBody([fixtureCite(commit)]) }]);
const reply = await f.ask(); assert.equal(reply.body, citedBody([fixtureCite(f.commit)])); assert.equal(f.count(), 2);
assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='coder'").n, 1);
const requests = readFileSync(join(f.runDir, 'fixture-requests.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.match(requests[1], new RegExp(code)); assert.match(requests[1], /refused with/);
assert.match(f.log(), new RegExp(code));
});
}
test('C2-06 RESULT two guard refusals use the citation fixed text and exactly one retry', async t => {
const f = await setup(t, () => [{ text: 'uncited' }, { text: 'uncited again' }]);
assert.equal((await f.ask()).body, FIXED_TEXTS.uncited); assert.equal(f.count(), 2);
});
for (const size of [34000, 70000]) {
test(`C2-06 RESULT JSON ${size} characters retries with a shorter valid answer`, async t => {
const f = await setup(t, commit => [{ text: JSON.stringify({ text: 'x'.repeat(size), citations: [fixtureCite(commit)] }) }, { text: citedBody([fixtureCite(commit)]) }]);
assert.equal((await f.ask()).body, citedBody([fixtureCite(f.commit)])); assert.equal(f.count(), 2);
assert.match(readFileSync(join(f.runDir, 'fixture-requests.jsonl'), 'utf8'), /64 KiB/);
});
}
test('C2-06 RESULT two oversized JSON bodies use the length fixed text with citations required', async t => {
const f = await setup(t, commit => Array(2).fill({ text: JSON.stringify({ text: 'x'.repeat(70000), citations: [fixtureCite(commit)] }) }));
assert.equal((await f.ask()).body, FIXED_TEXTS.tooLong); assert.equal(f.count(), 2);
});
test('C2-06 multibyte plain bodies also receive the one request-size retry', async t => {
const f = await setup(t, commit => [{ text: '界'.repeat(32000) }, { text: citedBody([fixtureCite(commit)]) }]);
assert.equal((await f.ask()).body, citedBody([fixtureCite(f.commit)])); assert.equal(f.count(), 2);
});
test('C2-06 two multibyte plain bodies use the length fallback', async t => {
const f = await setup(t, () => Array(2).fill({ text: '界'.repeat(32000) }));
assert.equal((await f.ask()).body, FIXED_TEXTS.tooLong); assert.equal(f.count(), 2);
});
test('C2-06 RESULT a length refusal then citation refusal shares one retry and citation fallback', async t => {
const f = await setup(t, commit => [{ text: JSON.stringify({ text: 'x'.repeat(70000), citations: [fixtureCite(commit)] }) }, { text: 'uncited' }]);
assert.equal((await f.ask()).body, FIXED_TEXTS.uncited); assert.equal(f.count(), 2);
});
test('C2-06 fixed empty/failed replies defer unslop note until the next cited reply', async t => {
const f = await setup(t, commit => [{ text: '', unslop: true }, { fail: true }, { text: citedBody([fixtureCite(commit)]) }]);
assert.equal((await f.ask()).body, FIXED_TEXTS.empty); assert.equal((await f.ask()).body, FIXED_TEXTS.failed);
const reply = JSON.parse((await f.ask()).body);
assert.deepEqual(reply.citations, [fixtureCite(f.commit)]); assert.match(reply.text, /unslop/i); assert.equal(f.count(), 3);
});
test('C2-06 cited JSON above the old 32000 clip retains its citations and text', async t => {
const f = await setup(t, commit => [{ text: JSON.stringify({ text: 'x'.repeat(32200), citations: [fixtureCite(commit)] }) }]);
const reply = JSON.parse((await f.ask()).body); assert.equal(reply.text.length, 32200); assert.deepEqual(reply.citations, [fixtureCite(f.commit)]); assert.equal(f.count(), 1);
});
test('C2-06 a flagged cited answer keeps the note inside text and citations whole', async t => {
const f = await setup(t, commit => [{ text: citedBody([fixtureCite(commit)]), unslop: true }]);
const reply = JSON.parse((await f.ask()).body);
assert.deepEqual(reply.citations, [fixtureCite(f.commit)]); assert.match(reply.text, /unslop/i); assert.equal(f.count(), 1);
});
test('C2-06 send_message typed tool runs every guard code before the valid RESULT', async t => {
const f = await setup(t, commit => [{ text: citedBody([fixtureCite(commit)]), tools: guardCases(commit).map(([, body]) => ({ to: 'human', body })) }]);
await f.ask(); const results = readFileSync(join(f.runDir, 'fixture-tools.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.equal(results.length, 7); results.forEach((r, i) => { assert.equal(r.ok, false); assert.match(r.error, new RegExp(guardCases(f.commit)[i][0])); });
assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='coder'").n, 1);
});
test('C2-06 outside library the old failed text, clip and unslop behavior hold', async t => {
const f = await setup(t, () => [{ fail: true }, { text: 'x'.repeat(40000), unslop: true }], { library: false });
assert.match((await f.ask()).body, /^The turn failed:/);
const reply = (await f.ask()).body; assert.match(reply, /answer cut at 32000 characters/); assert.match(reply, /unslop/i); assert.equal(f.count(), 2);
});
test('C2-06 outside library a refused send keeps the existing no-retry behavior', async t => {
const f = await setup(t, () => [{ text: 'answer without send authority' }], { library: false, sendAuthority: false });
await f.call(f.human, 'message.send', { to: 'coder', body: 'Fixture question.', class: 'REQUEST' });
await until(() => /runner: reply to .* failed:/.test(f.log()));
assert.equal(f.count(), 1); assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='coder'").n, 0);
assert.equal(existsSync(join(f.runDir, 'fixture-requests.jsonl')), true);
});
// E4: the agent must receive the availability reason in its actual retry turn,
// rather than being told only that the source citation was invalid.
test('C2-06 the retry turn delivers citation-unavailable reason to the agent', async t => {
let sends = 0;
const reason = 'The library is busy or unavailable. Retry the answer when the library is available.';
const f = await setup(t, commit => [{ text: citedBody([fixtureCite(commit)]) }, { text: citedBody([fixtureCite(commit)]) }], {
guard: async () => { if (sends++ === 0) throw new GuardError('citation-unavailable', reason); return null; },
});
await f.ask(); assert.equal(f.count(), 2);
const requests = readFileSync(join(f.runDir, 'fixture-requests.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.equal(requests[1].includes(`citation-unavailable: ${reason}`), true);
});
+21
View File
@@ -0,0 +1,21 @@
// A fixture-only scripted harness. No provider or real credential is used.
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { callTool } from '../../src/tools.mjs';
const root = dirname(process.env.MOSAIC_TURN_MARKER), countFile = join(root, 'fixture-count');
const n = existsSync(countFile) ? Number(readFileSync(countFile, 'utf8')) : 0;
const steps = JSON.parse(readFileSync(join(root, 'fixture-steps.json'), 'utf8'));
const step = steps[n] ?? { fail: true };
writeFileSync(countFile, String(n + 1));
appendFileSync(join(root, 'fixture-requests.jsonl'), JSON.stringify(process.env.MOSAIC_REQUEST) + '\n');
if (step.unslop) appendFileSync(process.env.MOSAIC_UNSLOP_LOG, JSON.stringify({ ev: 'flagged', findings: [{ source: 'fixture', id: 'fixture' }] }) + '\n');
for (const args of step.tools ?? []) {
let result;
try { result = { ok: true, result: await callTool(process.env.MOSAIC_TOOL_SOCKET, 'send_message', args) }; }
catch (e) { result = { ok: false, error: e.message }; }
appendFileSync(join(root, 'fixture-tools.jsonl'), JSON.stringify(result) + '\n');
}
if (step.fail) process.exit(3);
process.stdout.write(step.text ?? '');
writeFileSync(process.env.MOSAIC_TURN_MARKER, 'done\n');
+188
View File
@@ -0,0 +1,188 @@
// C2-06 (design 5.2, rulings after r11 Q222, Q223 and Q226): the citation guard. The broker runs it
// before it stores an agent's message to the human in a business with a library project. It reads
// only the citations, never what the text says. Codes are design 5.2's table.
import { git } from '../../project/src/git.mjs';
import { classify } from './scope.mjs';
import { listTree, readLibrary, readTexts, splitLines } from './reader.mjs';
import { LibraryError } from './problems.mjs';
import { mirrorPath, readSyncState } from './mirror.mjs';
import { plain } from './limits.mjs';
import { FIXED_TEXTS } from './reply-protocol.mjs';
export { GUARD_CODES, FIXED_TEXTS } from './reply-protocol.mjs';
// Web cites are for these definitions only (design 5.2).
export const WEB_CITE_DEFINITIONS = Object.freeze(['research', 'companion']);
// The reason reaches the runner (sage-3599): the guard's own text naming the check, the cite and the
// limit, never a quote or file text, and at most REASON_MAX characters.
export const REASON_MAX = 300;
export class GuardError extends LibraryError {
constructor(code, reason) { reason = reason.slice(0, REASON_MAX); super(code, reason); this.reason = reason; }
}
const refuse = (code, reason) => { throw new GuardError(code, reason); };
const SHA = /^[a-f0-9]{40}$/;
const LIBRARY_CITE = ['path', 'commit', 'lines', 'quote'];
const WEB_CITE = ['url', 'quote'];
const only = (value, required, optional = []) => plain(value)
&& required.every(k => Object.hasOwn(value, k)) && Object.keys(value).every(k => required.includes(k) || optional.includes(k));
const text = value => typeof value === 'string' && value.length > 0 && !value.includes('\0');
function libraryCite(c) {
return only(c, LIBRARY_CITE, ['as']) && text(c.path) && c.path.length <= 4096 && typeof c.commit === 'string' && SHA.test(c.commit)
&& Array.isArray(c.lines) && c.lines.length === 2 && c.lines.every(n => Number.isSafeInteger(n) && n >= 1) && c.lines[0] <= c.lines[1]
&& text(c.quote) && (!Object.hasOwn(c, 'as') || c.as === 'canon');
}
const webCite = c => only(c, WEB_CITE) && text(c.url) && c.url.length <= 4096 && text(c.quote);
// A body is free text, or a JSON object {text, citations} that must fit the schema. Returns
// { text, citations, cited } where `cited` says the body was the JSON form.
export function parseReply(body) {
let value;
try { value = JSON.parse(body); } catch { return { text: body, citations: [], cited: false }; }
if (!plain(value)) return { text: body, citations: [], cited: false };
if (!only(value, ['text', 'citations']) || typeof value.text !== 'string' || !Array.isArray(value.citations))
refuse('reply-invalid', 'a JSON object reply holds exactly "text", a string, and "citations", a list');
value.citations.forEach((c, i) => {
if (!libraryCite(c) && !webCite(c))
refuse('reply-invalid', `citation ${i + 1} is neither {path, commit, lines, quote, as?} nor {url, quote}`);
});
return { text: value.text, citations: value.citations, cited: true };
}
const squash = value => value.replace(/\s+/g, ' ').trim();
// Design 5.2's checks, in the table's order. `limits` comes from resolveReplyLimits. `library` reads
// git for a library cite (libraryReader below). `web` is { allowed, pages } for this sender: whether
// its definition may cite a url, and the session's kept fetches. Returns the stored check result,
// { citations: [{ scope, agent, file }] } in body order (agreed with row 225, morgana-0198 and
// morgana-0200; a url cite is { scope: 'web', agent: null }), or null for a
// plain body. Throws GuardError.
export async function checkReply(body, { limits, mapping, library, web }) {
if (Object.values(FIXED_TEXTS).includes(body)) return null;
const reply = parseReply(body);
const { citations } = reply;
if (limits.requireCitation && citations.length === 0)
refuse('citation-missing', 'replyLimits.requireCitation is true, so a reply carries at least one citation');
if (citations.length > limits.citationsPerReply)
refuse('citations-over-limit', `replyLimits.citationsPerReply is ${limits.citationsPerReply}; the reply carries ${citations.length}`);
if (!reply.cited) return null;
const found = [];
for (const [i, c] of citations.entries()) {
if (!Object.hasOwn(c, 'path')) { found.push(null); continue; }
const at = `citation ${i + 1} (${c.path})`;
const where = classify(mapping, c.path);
if (where.scope === 'unmapped') refuse('citation-invalid', `${at} names a path that isn't mapped`);
const lines = await library.lines(c.commit, c.path);
if (lines === null) refuse('citation-invalid', `${at} isn't an indexed file at a commit reachable from the served commit`);
if (c.lines[1] > lines.length) refuse('citation-invalid', `${at} cites lines past the file's ${lines.length}`);
const quote = squash(c.quote);
if (!quote || !squash(lines.slice(c.lines[0] - 1, c.lines[1]).join('\n')).includes(quote))
refuse('citation-invalid', `${at} quote isn't in lines ${c.lines[0]}-${c.lines[1]}`);
found.push(where);
}
citations.forEach((c, i) => {
if (c.as === 'canon' && found[i].scope !== 'canon')
refuse('playground-cited-as-canon', `citation ${i + 1} (${c.path}) is ${found[i].scope}, not canon`);
});
const universes = [...new Set(found.filter(Boolean).map(w => w.universe))];
if (universes.length > 1) {
const hybrid = universes.find(u => mapping.universes[u]?.kind === 'hybrid');
const sources = hybrid ? mapping.universes[hybrid].sources : [];
if (!hybrid || !universes.every(u => u === hybrid || sources.includes(u)))
refuse('citation-cross-universe', `the citations span universes ${universes.join(', ')}`);
}
citations.forEach((c, i) => {
if (!Object.hasOwn(c, 'url')) return;
if (!web.allowed) refuse('citation-unfetched', `citation ${i + 1}: only research and companion cite a url`);
const quote = squash(c.quote);
const pages = web.pages.filter(p => p.url === c.url || p.finalUrl === c.url);
if (!pages.length) refuse('citation-unfetched', `citation ${i + 1}: this session fetched no such url through the bus`);
if (!quote || !pages.some(page => squash(page.text).includes(quote))) refuse('citation-unfetched', `citation ${i + 1}: the quote isn't in the url's kept text`);
});
const out = [];
for (const [i, c] of citations.entries())
out.push(found[i] ? { scope: found[i].scope, ...await library.origin(c.commit, c.path) } : { scope: 'web', agent: null });
return { citations: out };
}
// Git reads for one check against the bare mirror. `lines` is the file's lines at `commit` when the
// commit is reachable from the served commit and readLibrary indexes the path there (sage-3595 item 4:
// readLibrary at the cited commit stands in for the C2-05a index), else null. Reads are cached per
// commit for the one check.
export function libraryReader(mirror, served, mapping) {
const indexed = new Map(), reach = new Map();
const once = (cache, key, read) => {
if (!cache.has(key)) cache.set(key, read().catch(() => null));
return cache.get(key);
};
const reachable = commit => once(reach, commit, async () => {
if (!mirror || !served) return false;
if (await git(mirror, ['cat-file', '-t', commit]) !== 'commit') return false;
await git(mirror, ['merge-base', '--is-ancestor', commit, served]);
return true;
});
const index = commit => once(indexed, commit, async () => {
const [read, entries] = await Promise.all([readLibrary({ repo: mirror, commit, mapping }), listTree(mirror, commit)]);
return { files: new Set(read.files.map(file => file.path)), entries };
});
return {
async lines(commit, path) {
if (!await reachable(commit)) return null;
const at = await index(commit);
if (!at?.files.has(path)) return null;
const texts = await readTexts(mirror, [at.entries.find(entry => entry.path === path)]).catch(() => new Map());
return texts.has(path) ? splitLines(texts.get(path)) : null;
},
// One git call: the file's last commit at `commit` (morgana-0200: the Console compares it with the
// served tip's, as the cited library commit needn't touch the file) and that commit's trailer.
async origin(commit, path) {
if (!mirror) return { agent: null, file: null };
try {
const [file, trailer = ''] = (await git(mirror, ['--literal-pathspecs', 'log', '-1', '--format=%H%x00%(trailers:key=Mosaic-Agent,valueonly)', commit, '--', path])).split('\0');
return { agent: trailer.trim() || null, file: SHA.test(file) ? file : null };
} catch { return { agent: null, file: null }; }
},
// The file's last commit at the served tip, for thread.messages (sage-3595), or null.
async last(path) {
if (!mirror || !served) return null;
try {
const value = await git(mirror, ['--literal-pathspecs', 'log', '-1', '--format=%H', served, '--', path]);
return SHA.test(value) ? value : null;
} catch { return null; }
},
};
}
// thread.messages' `last` (sage-3595): each path's last commit at the served tip, one git call per
// distinct path. A path maps to null when there is no served commit or the read fails.
export async function lastCommits(context, paths) {
const { mirror, served } = await servedMirror(context);
const reader = libraryReader(mirror, served, null);
const out = new Map();
for (const path of new Set(paths)) out.set(path, await reader.last(path));
return out;
}
// The library project's bare mirror and its served commit. Either is null when there is none yet, so
// a plain reply still passes and any library cite refuses citation-invalid.
async function servedMirror(context) {
const mapping = context.business.projects[context.project];
let mirror = null, served = null;
try {
mirror = mirrorPath(context);
served = (await readSyncState(mirror, { branch: mapping.branch })).served ?? null;
} catch { served = null; }
return { mapping, mirror, served };
}
// The broker's entry: the library project's mirror at its served commit, then checkReply. `context`
// is { business, project, dataRoot }. `definition` is the sender's role definition, which decides only
// whether a url cite may pass; `pages` is the broker's page store for the sender's session.
export async function guardReply(context, body, { limits, definition, pages }) {
const { mapping, mirror, served } = await servedMirror(context);
const web = { allowed: WEB_CITE_DEFINITIONS.includes(definition), pages };
return checkReply(body, { limits, mapping, library: libraryReader(mirror, served, mapping), web });
}
+2
View File
@@ -8,3 +8,5 @@ export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs';
export { survey, formatSurvey, surveyMain } from './survey.mjs';
export { SYNC_STATE_FILE, mirrorPath, ensureMirror, withLibraryLock, servedCommit, readSyncState, libraryStatus, readLibraryFile } from './mirror.mjs';
export { syncCommand, sync, ASKPASS, SLOT, SYNC_INTERVAL_MS } from './sync.mjs';
export { REPLY_LIMITS, resolveReplyLimits, checkReplyLimits } from './limits.mjs';
export { GUARD_CODES, FIXED_TEXTS, WEB_CITE_DEFINITIONS, REASON_MAX, GuardError, parseReply, checkReply, libraryReader, guardReply, lastCommits } from './guard.mjs';
+41
View File
@@ -0,0 +1,41 @@
// C2-06 (design 5.2): the business file's replyLimits, read by the citation guard and written only
// by the human's library.limits.set. Pure, so the business package's organization writer imports it.
export const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
// The replyLimits keys, their defaults and their valid values. A key is read only when Object.hasOwn
// finds it and its value passes `valid`; anything else is the default (webby-0144 R3).
export const REPLY_LIMITS = Object.freeze({
requireCitation: { default: false, valid: v => typeof v === 'boolean' },
citationsPerReply: { default: 20, valid: v => Number.isInteger(v) && v >= 1 && v <= 1000 },
});
// `read` is { readable: false } when the business file is missing, unreadable, not JSON or not an
// object at check time, else { readable: true, document }. An unreadable file checks at the defaults
// except requireCitation, which is true so a setting she turned on doesn't fail open (webby-0151 R2).
export function resolveReplyLimits(read) {
const limits = {}, fallback = [];
const unreadable = !read?.readable || !plain(read.document);
const raw = unreadable ? undefined : read.document.replyLimits;
for (const [key, { default: value, valid }] of Object.entries(REPLY_LIMITS)) {
if (plain(raw) && Object.hasOwn(raw, key) && valid(raw[key])) limits[key] = raw[key];
else {
limits[key] = value;
// A key fell back when replyLimits is there but isn't an object, or holds the key with a value
// outside the table. A key left out is simply its default.
if (raw !== undefined && (!plain(raw) || Object.hasOwn(raw, key))) fallback.push(key);
}
}
if (unreadable) limits.requireCitation = true;
return { ...limits, fallback, unreadable };
}
// library.limits.set's check: the whole object is replaced, a key left out means its default, and an
// unknown key or a value outside the table refuses. Returns the object to write.
export function checkReplyLimits(value) {
if (!plain(value)) return null;
for (const [key, v] of Object.entries(value))
if (!Object.hasOwn(REPLY_LIMITS, key) || !REPLY_LIMITS[key].valid(v)) return null;
return structuredClone(value);
}
+12
View File
@@ -0,0 +1,12 @@
// C2-06: shared pure protocol constants for the host guard and sandboxed runner.
export const GUARD_CODES = Object.freeze(['reply-invalid', 'citation-missing', 'citations-over-limit', 'citation-invalid',
'playground-cited-as-canon', 'citation-cross-universe', 'citation-unfetched']);
// The runner's four fixed texts in a library business. The guard accepts exactly these bodies when
// requireCitation is true; an agent that sends one gains nothing it couldn't say anyway.
export const FIXED_TEXTS = Object.freeze({
empty: '(the session gave no answer)',
failed: '(the turn failed)',
uncited: '(no answer passed the citation check)',
tooLong: '(the answer was too long to send)',
});
+129
View File
@@ -0,0 +1,129 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { checkReply, GuardError, libraryReader, parseReply, FIXED_TEXTS } from '../src/guard.mjs';
import { resolveReplyLimits, checkReplyLimits } from '../src/limits.mjs';
import { BASE, MARKER, mapping, repo, git } from './helpers.mjs';
const defaults = resolveReplyLimits({ readable: true, document: {} });
const body = citations => JSON.stringify({ text: 'A fixture answer.', citations });
const cite = (commit, extra = {}) => ({ path: 'Book One/Characters/Mara Vell.md', commit, lines: [9, 9], quote: 'Grey eyes.', ...extra });
const context = r => ({ limits: defaults, mapping: mapping(), library: libraryReader(r.repo, r.commit, mapping()), web: { allowed: true, pages: [] } });
const refusal = code => e => e instanceof GuardError && e.code === code && typeof e.reason === 'string' && e.reason.length <= 300;
test('C2-06 reply schema: free bodies, exact objects and safe refusal reasons', async t => {
const c = context(repo(t));
for (const text of ['plain', 'null', '42', '[]', '"text"', '{broken']) {
assert.equal(parseReply(text).cited, false);
assert.equal(await checkReply(text, c), null);
}
assert.deepEqual(await checkReply(body([]), c), { citations: [] });
for (const value of [{}, { text: 'x', citations: [], scope: 'canon' }, { text: 1, citations: [] },
{ text: 'x', citations: {} }, { text: 'x', citations: [{ url: 'https://invalid.test', quote: 'x', agent: 'forged' }] },
{ text: 'x', citations: [cite('a'.repeat(40), { as: 'supporting' })] }])
await assert.rejects(checkReply(JSON.stringify(value), c), refusal('reply-invalid'));
});
test('C2-06 citation setting and all four exact fixed texts', async t => {
const c = context(repo(t)); c.limits = { ...defaults, requireCitation: true };
await assert.rejects(checkReply('plain', c), refusal('citation-missing'));
await assert.rejects(checkReply(body([]), c), refusal('citation-missing'));
for (const text of Object.values(FIXED_TEXTS)) {
assert.equal(await checkReply(text, c), null);
await assert.rejects(checkReply(text + ' ', c), refusal('citation-missing'));
}
});
test('C2-06 default citation cap passes 20 and refuses 21, even with optional citations', async t => {
const r = repo(t), c = context(r);
assert.equal((await checkReply(body(Array(20).fill(cite(r.commit))), c)).citations.length, 20);
await assert.rejects(checkReply(body(Array(21).fill(cite(r.commit))), c), refusal('citations-over-limit'));
});
test('C2-06 real Git citation scope, reachability, lines and normalized quotes', async t => {
const r = repo(t), c = context(r);
assert.equal((await checkReply(body([cite(r.commit, { quote: 'Grey\n eyes.' })]), c)).citations[0].scope, 'canon');
for (const extra of [{ path: 'Inbox Note.md', lines: [1, 1], quote: MARKER },
{ path: 'Book One/Board.canvas' }, { path: 'Book One/Characters/Missing.md' },
{ commit: '0'.repeat(40) }, { commit: git(r.repo, 'rev-parse', 'HEAD^{tree}') },
{ lines: [999, 999] }, { quote: 'not in her fixture' }])
await assert.rejects(checkReply(body([cite(r.commit, extra)]), c), refusal('citation-invalid'));
// A future commit exists locally but is not reachable from the served commit.
writeFileSync(join(r.repo, 'Inbox Note.md'), 'new\n'); git(r.repo, 'add', '-A'); git(r.repo, 'commit', '-qm', 'future');
await assert.rejects(checkReply(body([cite(git(r.repo, 'rev-parse', 'HEAD'))]), c), refusal('citation-invalid'));
assert.equal((await checkReply(body([cite(r.commit)]), c)).citations[0].scope, 'canon');
});
test('C2-06 playground cites pass but cannot state canon', async t => {
const r = repo(t), c = context(r), p = cite(r.commit, { path: 'Book One/Playground/What If.md', lines: [4, 4], quote: MARKER });
assert.equal((await checkReply(body([p]), c)).citations[0].scope, 'playground');
await assert.rejects(checkReply(body([{ ...p, as: 'canon' }]), c), refusal('playground-cited-as-canon'));
});
test('C2-06 independent universes refuse; a hybrid and only its sources pass', async t => {
const r = repo(t, { ...BASE, 'Crossover/Chapters/01.md': 'A hybrid fixture.\n' }), c = context(r);
const other = cite(r.commit, { path: 'Second World/Chapters/01.md', lines: [1, 1], quote: MARKER });
await assert.rejects(checkReply(body([cite(r.commit), other]), c), refusal('citation-cross-universe'));
c.mapping = mapping(e => { e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: ['Crossover'] }; return e; });
c.library = libraryReader(r.repo, r.commit, c.mapping);
const hybrid = cite(r.commit, { path: 'Crossover/Chapters/01.md', lines: [1, 1], quote: 'hybrid fixture' });
assert.equal((await checkReply(body([hybrid, cite(r.commit), other]), c)).citations.length, 3);
});
test('C2-06 web cites require allowed definition, this session fetch and kept quote', async t => {
const c = context(repo(t)), web = { url: 'https://fixture.invalid/page', quote: 'kept text' };
await assert.rejects(checkReply(body([web]), c), refusal('citation-unfetched'));
c.web.pages = [{ url: web.url, finalUrl: 'https://fixture.invalid/final', text: 'The kept\n text is here.' }];
assert.deepEqual(await checkReply(body([web]), c), { citations: [{ scope: 'web', agent: null }] });
assert.equal((await checkReply(body([{ ...web, url: c.web.pages[0].finalUrl }]), c)).citations.length, 1);
c.web.pages.push({ url: web.url, text: 'A later fetch.' });
assert.equal((await checkReply(body([{ ...web, quote: 'later fetch' }]), c)).citations.length, 1);
await assert.rejects(checkReply(body([{ ...web, quote: 'discarded bytes' }]), c), refusal('citation-unfetched'));
c.web.allowed = false;
await assert.rejects(checkReply(body([web]), c), refusal('citation-unfetched'));
});
test('C2-06 file origin is the path commit and trailer, while last follows served changes', async t => {
const r = repo(t), path = 'Book One/Characters/Mara Vell.md';
writeFileSync(join(r.repo, path), 'Agent fixture.\n'); git(r.repo, 'add', '-A');
git(r.repo, 'commit', '-qm', 'Note\n\nMosaic-Agent: acme/scribe');
const file = git(r.repo, 'rev-parse', 'HEAD');
writeFileSync(join(r.repo, 'Inbox Note.md'), 'Unrelated.\n'); git(r.repo, 'add', '-A'); git(r.repo, 'commit', '-qm', 'unrelated');
const cited = git(r.repo, 'rev-parse', 'HEAD'), reader = libraryReader(r.repo, cited, mapping());
assert.notEqual(cited, file);
assert.deepEqual(await reader.origin(cited, path), { agent: 'acme/scribe', file });
assert.equal(await reader.last(path), file);
writeFileSync(join(r.repo, path), 'Human fixture.\n'); git(r.repo, 'add', '-A'); git(r.repo, 'commit', '-qm', 'human');
assert.notEqual(await libraryReader(r.repo, git(r.repo, 'rev-parse', 'HEAD'), mapping()).last(path), file);
});
test('C2-06 origin uses literal paths even when a filename contains a glob', async t => {
const path = 'Book One/Characters/Mara*.md';
const r = repo(t, { ...BASE, [path]: 'Literal fixture.\n' }), original = r.commit;
writeFileSync(join(r.repo, 'Book One/Characters/Mara Vell.md'), 'Other fixture.\n'); git(r.repo, 'add', '-A');
git(r.repo, 'commit', '-qm', 'Other path\n\nMosaic-Agent: acme/other');
const reader = libraryReader(r.repo, git(r.repo, 'rev-parse', 'HEAD'), mapping());
assert.deepEqual(await reader.origin(git(r.repo, 'rev-parse', 'HEAD'), path), { agent: null, file: original });
assert.equal(await reader.last(path), original);
});
test('C2-06 reply limits fall back independently without coercion or inherited keys', () => {
for (const value of ['true', 1, null, [true], {}]) {
const r = resolveReplyLimits({ readable: true, document: { replyLimits: { requireCitation: value, citationsPerReply: 5 } } });
assert.equal(r.requireCitation, false); assert.equal(r.citationsPerReply, 5); assert.deepEqual(r.fallback, ['requireCitation']);
}
for (const value of [0, 1001, 2.5, true, '5']) {
const r = resolveReplyLimits({ readable: true, document: { replyLimits: { requireCitation: true, citationsPerReply: value } } });
assert.equal(r.requireCitation, true); assert.equal(r.citationsPerReply, 20); assert.deepEqual(r.fallback, ['citationsPerReply']);
}
for (const value of [null, [], 1, 'settings', undefined]) {
const r = resolveReplyLimits({ readable: true, document: { replyLimits: value } });
assert.equal(r.requireCitation, false); assert.equal(r.citationsPerReply, 20);
}
assert.equal(resolveReplyLimits({ readable: true, document: { replyLimits: Object.create({ requireCitation: true }) } }).requireCitation, false);
for (const document of [undefined, null, [], 'invalid']) assert.equal(resolveReplyLimits({ readable: true, document }).requireCitation, true);
assert.equal(resolveReplyLimits({ readable: false }).requireCitation, true);
assert.deepEqual(checkReplyLimits({ requireCitation: true, citationsPerReply: 1000 }), { requireCitation: true, citationsPerReply: 1000 });
for (const value of [{ unknown: 1 }, { requireCitation: 'true' }, { citationsPerReply: 0 }, null, []]) assert.equal(checkReplyLimits(value), null);
});
+1
View File
@@ -60,6 +60,7 @@ export const RUNNER_FILES = Object.freeze([
"packages/bus/src/landing.mjs",
"packages/business/src/vocabulary.mjs",
"packages/plugins/src/kinds.mjs",
"packages/library/src/reply-protocol.mjs",
"packages/processing-control/src/state.mjs",
"packages/tasks/src/requirements.mjs",
].map((p) => join(REPO, p)));
+9 -13
View File
@@ -4,22 +4,22 @@ Piece 4, #1507. The control board is the first and only WebUI screen.
## Run
The board runs inside the Console (row 198), so there is one server to start:
Start the existing board in one terminal, then the WebUI in another:
```sh
node packages/control-board/src/cli.mjs serve
node packages/webui/src/cli.mjs serve
```
Open http://127.0.0.1:7330/. Optional `--port N` selects another port. There
is no separate board server (`control-board serve` is retired and exits 2) and
no `--board` flag (it is refused).
Open http://127.0.0.1:7330/. Optional `--port N` and
`--board http://127.0.0.1:7331` select another port or loopback board origin.
`--business ID` picks the business the bus views read; without it they read
the running bus host's business (`mosaic bus start <business>`).
Port 0 picks a free port. Ctrl-C stops each foreground server. No daemon,
installation, account, login or deployment is added. The preview mode below
checks one header a sign-in proxy sets; it signs nobody in itself.
The WebUI binds to IPv4 loopback
Only HTTP loopback board origins are accepted. The WebUI binds to IPv4 loopback
by default, rejects a repeated Host (400), nonlocal Host and cross-origin
requests, sends no CORS
headers, refuses redirects and accepts only JSON object POST bodies up to 4096
@@ -60,11 +60,9 @@ In preview mode:
`Host: NAME` would otherwise skip the sign-in check.
- Every method other than GET and HEAD answers 403 `read-only`, whatever the
Host. `/api/seen` and `/api/reply` never reach the board, so Seen and Reply
fail in the page with that refusal. The exceptions are POST `/api/bus/message`
when the Console was started with `--send` (next section), and POST
`/api/seen` and `/api/reply` when the Console runs with `--send` on the
public name (row 198: the board runs in-process, so these writes reach it
directly; without `--send` they answer 403 `read-only`).
fail in the page with that refusal. The one exception is POST
`/api/bus/message` when the Console was started with `--send` (next
section).
- The GET routes are unchanged: the bus views, the Queue, Business and
Settings reads, the board panel and the assets. If the board is down, its
panel shows the existing error and the bus views still answer.
@@ -953,9 +951,7 @@ completion Seen and find it again in Seen. Record any reason to open the board's
own page or a repo seat terminal in DEFERRED.md. Pass is Jason's end-of-day
say-so, not a test result. Keep #1507 open pending that ruling.
Rollback requires only stopping the WebUI. The board runs inside it, so there is no separate board to keep running.
A move to a pre-198 copy, by rollback or by activate, has no board until the next re-pin. The plan drops a recorded `--board` (the 7331 board is retired and the install refuses the flag), and that Console runs without a board until a copy that has the in-process board is activated.
Rollback requires only stopping the WebUI and using the unchanged board at 7331.
Revert the scoped WebUI commit to remove it; there is no data migration to undo.
Seen and reply actions already taken belong to the board and are not rolled back.
+8 -20
View File
@@ -1,14 +1,12 @@
#!/usr/bin/env node
// MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP.
import { createChat } from './chat.mjs';
import { startServer, previewOptions, proofOptions } from './serve.mjs';
import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
import { busCaller, busBusiness, userCaller, consoleCall } from './bus.mjs';
import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs';
import { consoleAccess } from './access.mjs';
import { accessContextFor } from '../../cli/src/access.mjs';
import { accessFilePath, loadAccess } from '../../business/src/access.mjs';
import { createBoard } from '../../control-board/src/board.mjs';
import { discoverRepoAgents, discoverFleetAgents } from '../../control-board/src/scan.mjs';
import { startTesterSocket } from './tester-socket.mjs';
import { TESTER_CLI } from '../../cli/src/transport.mjs';
import { views } from '../../bus/src/views.mjs';
@@ -17,15 +15,14 @@ import { configDir } from '../../business/src/business.mjs';
import { consoleReads, redactor } from './reads.mjs';
import { createSso, readSlot } from './sso.mjs';
import { statSync } from 'node:fs';
import { homedir } from 'node:os';
import { isAbsolute, join } from 'node:path';
import { isAbsolute } from 'node:path';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage);
const options = { port: 7330 }, preview = {}, proofFlags = {};
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
let sendOn = false, ssoOn = false, testerSocket = false;
while (args.length) {
const flag = args.shift();
@@ -35,14 +32,12 @@ try {
if (flag === '--sso') { ssoOn = true; continue; }
if (flag === '--tester-socket') { testerSocket = true; continue; }
if (flag === '--tester-port') throw new Error('--tester-port retired; activate explicitly with --tester-socket');
// Row 198: the board runs inside the Console; there is no board origin to point at.
if (flag === '--board') throw new Error('--board retired: the board runs in the Console (row 198)');
const value = args.shift();
if (!value) throw new Error(`missing value for ${flag}`);
if (flag === '--port') {
if (!/^\d+$/.test(value) || Number(value) > 65535) throw new Error('port must be 0..65535');
options.port = Number(value);
}
} else if (flag === '--board') options.board = value;
// Row 77 (#1571): the canonical checkout the queue read runs from, when
// serve runs from a pinned copy; everything else loads from the copy.
else if (flag === '--checkout') {
@@ -92,13 +87,6 @@ try {
if (testerSocket) throw new Error(`--tester-socket needs the bus configured (${why})`);
busLine = `Bus: not configured (${why})`;
}
// Row 198: the board runs in-process over the system config's data root and reads the same repository
// agents as before, plus the fleet's read-only specs (the old board's default). Without the system
// config there is no data root, so the board routes answer not-configured.
const board = system ? createBoard({
specs: [...discoverRepoAgents(options.checkout ?? REPO), ...discoverFleetAgents(join(homedir(), '.mosaic', 'fleet', 'agents'))],
boardDir: join(system.dataRoot, 'board'), seatsDir: join(system.dataRoot, 'seats'), discordDataRoot: system.dataRoot,
}) : null;
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
// The sign-in config is checked again here, then each slot file, before anything listens. A
// provider that can't be reached starts degraded; one whose discovery is refused stops the start.
@@ -119,14 +107,14 @@ try {
}
let server;
try {
server = await startServer({ board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, decisionWrite, userOptions, federation: system?.federation ?? null });
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, decisionWrite, userOptions, federation: system?.federation ?? null });
} catch (err) { sso?.close(); throw err; }
let testerServer = null;
if (testerSocket) {
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
try {
testerServer = await startTesterSocket({ dataRoot: system.dataRoot, board,
testerServer = await startTesterSocket({ dataRoot: system.dataRoot, board: options.board,
tester: true, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
processing: { read: () => call('processing.status') },
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
@@ -145,7 +133,7 @@ try {
const health = sso?.health() ?? {};
const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send), grant Approve and Deny' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${board ? 'in the Console (no port)' : 'not configured (no system config)'}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
} catch (err) {
console.error(`refused: ${err.message}`);
+5 -8
View File
@@ -7,7 +7,7 @@
// Delivered replies with no newer answer yet, by row: the board's sentAt. Page memory only.
const awaiting = new Map();
let data = null, project = null, selected = null, returnFocus = null, paused = false, busy = false, writing = false, reading = null, timer;
let board = 'the Console board', palette = 'harbor', mode = 'system', failed = false;
let board = 'the configured board', palette = 'harbor', mode = 'system', failed = false;
// Show machine sessions (#1589): off by default, kept with the page's settings. While it is off the
// page doesn't read /api/board at all; the held roles above come from the bus (bus.js).
let machine = false;
@@ -20,8 +20,6 @@
// `sign-in required`; that signs the page out too, and the line links to the Console's sign-in.
// Any other 401 stays an error. `sso` comes from /api/config.
let sso = false;
// Row 198 (mos-1512): the reply form and the Seen button draw only when /api/config says send; otherwise a line says so.
let canSend = false;
const signInRequired = (res, body) => res.status === 401 && body?.error === 'sign-in required';
const signedOut = () => Object.assign(new Error('the sign-in proxy redirected it, so it never reached the Console'), { signedOut: true });
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
@@ -48,7 +46,7 @@
const s = Date.parse(sent), a = Date.parse(r.lastActivity);
return Number.isFinite(s) && Number.isFinite(a) && a < s;
}
const canReply = r => canSend && !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session;
const canReply = r => !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session;
const row = id => data?.sessions.find(r => key(r) === id);
const announce = text => { $('announce').textContent = text; };
// Colours come from tokens.css by data-palette and data-mode. System removes data-mode, so the OS setting picks light or dark.
@@ -68,7 +66,6 @@
$('mode').onchange = e => { mode = e.target.value; theme(); };
function seenButton(r) {
if (!['waiting', 'error'].includes(r.state) || !r.lastActivity) return '';
if (!canSend) return '<span class="small muted">Seen is off: this Console is read-only.</span>';
return `<button type="button" class="btn" data-seen="${esc(key(r))}" aria-label="${r.seen ? 'Unsee' : 'Mark seen'} ${esc(r.agent)}">${r.seen ? 'Unsee' : 'Seen'}</button>`;
}
const historyButton = r => `<button type="button" class="btn conv-open" data-history="${esc(key(r))}" aria-label="History for ${esc(r.agent)}">History</button>`;
@@ -97,7 +94,7 @@
if (r.relaunchedAt) fields.unshift(['Current activity', relaunchNotice(r)]);
if (r.connector) fields.push(['Connector', `${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'}; owner ${r.connector.ownerState}`]);
const receipt = receipts.get(selected);
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}${pending(r) ? `<dt>Reply</dt><dd>Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.</dd><dt>${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message</dt>` : `<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt>`}<dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${historyButton(r)}${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : !canSend ? 'Replies are off: this Console is read-only.' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}${pending(r) ? `<dt>Reply</dt><dd>Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.</dd><dt>${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message</dt>` : `<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt>`}<dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${historyButton(r)}${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
}
// Conversation view (#1507, CHAT-02): the full read-only history from the board's
// /api/conversations and /api/conversation routes. Session content is set with
@@ -197,7 +194,7 @@
if (!conv) return;
const r = row(conv.row), can = !!r && !!canReply(r), receipt = receipts.get(conv.row);
$('conv-form').hidden = !can; $('conv-no-reply').hidden = can;
$('conv-no-reply').textContent = !r ? 'This session is no longer in the board scan.' : r.connector ? 'Board replies disabled for Discord connectors' : !canSend ? 'Replies are off: this Console is read-only.' : 'reply needs a registered seat';
$('conv-no-reply').textContent = !r ? 'This session is no longer in the board scan.' : r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat';
if (r) $('conv-reply-label').textContent = `Reply to ${r.agent}`;
$('conv-send').disabled = sending.has(conv.row); $('conv-send').textContent = sending.has(conv.row) ? 'Sending…' : 'Send';
$('conv-receipt').hidden = !receipt;
@@ -479,5 +476,5 @@
setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); });
});
// Sign out (sage-1347, F7): a plain same-origin form POST to /auth/logout, shown only under --sso.
api('/api/config').then(config => { canSend = config.send === true; board = config.board || 'the Console board'; $('board-url').textContent = `Board: ${board}`; window.MosaicFederation?.render(config.federation, $('federation')); if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; window.MosaicFederation?.render(config.federation, $('federation')); if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
})();
+40 -37
View File
@@ -13,9 +13,19 @@ import { chatHTTP } from './chat-http.mjs';
import { processingHTTP } from './processing-http.mjs';
import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs';
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
export function isLoopback(host) {
return host === 'localhost' || host === '::1' || (isIP(host) === 4 && host.startsWith('127.'));
}
export function boardURL(value) {
const url = new URL(value);
if (url.protocol !== 'http:' || !isLoopback(url.hostname.replace(/^\[|\]$/g, '')) || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
throw new Error('board must be an HTTP loopback origin without credentials, path, query or fragment');
}
// Avoid hostname resolution for localhost.
if (url.hostname === 'localhost') url.hostname = '127.0.0.1';
return url.origin;
}
const root = resolve(import.meta.dirname, 'public');
const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
@@ -181,20 +191,6 @@ const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.header
// so it carries `sso: true` and the public origin; the preview writer still checks its header.
const signedIn = (req, p) => p.sso === true || one(req, p.header) === p.user;
const ownOrigin = p => p.origin ?? `https://${p.host}`;
// Row 198: the board's writes, in-process. A body the board refuses is a 400; the reply's own answer
// (its status and receipt) comes back as the board gave it, and a failed scan is a 500 with no detail.
async function boardSend(req, res, { board, path }) {
let value;
try { value = JSON.parse((await body(req)).toString('utf8')); } catch (err) { return json(res, 400, { error: err.message }); }
try {
if (path === '/api/seen') return json(res, 200, await board.markSeen(value));
const out = await board.reply(value);
return json(res, out.status, out.body);
} catch (err) {
if (path === '/api/seen') return json(res, 400, { error: err.message });
return json(res, 500, { error: 'board read failed' });
}
}
async function busSend(req, res, { send, preview, log }) {
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required' });
let to = null;
@@ -605,8 +601,7 @@ async function projectFileRead(res, read, path, search) {
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json.
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
// `board` is createBoard() from packages/control-board (row 198), run in-process; null answers the board routes 503.
export async function startServer({ host = '127.0.0.1', port = 7330, board = null, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, decisionWrite = null, userOptions = null, federation = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, decisionWrite = null, userOptions = null, federation = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1';
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
@@ -620,10 +615,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = nul
// Row 252 (#1753, design section 4): Console decision answers need --sso and --send, never the tester.
if (decisionWrite !== null && (tester || sso === null || send === null || typeof decisionWrite !== 'function')) throw new Error('Console decision answers need --sso and --send on the main listener');
const confirmations = createConfirmations();
const upstream = boardURL(board);
// `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session
// user's own transport (console-bus.mjs). Only a signed-in listener has user sessions.
if (userOptions !== null && (sso === null || typeof userOptions !== 'function')) throw new Error('user sessions need --sso');
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, files };
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files };
const hub = federationView(federation);
// An object with none of the three is refused too: asking for the preview never serves without it.
if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
@@ -631,10 +627,9 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = nul
}
if (proof !== null && (preview === null || tester)) throw new Error('proxy proof needs preview mode');
if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
const boardLabel = board ? 'in the Console' : null;
const server = createServer(async (req, res) => {
// A granted user's request rebinds these below; every other request keeps the owner's.
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, files } = owner, granted = false;
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files } = owner, granted = false;
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('referrer-policy', 'no-referrer');
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
@@ -701,7 +696,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = nul
if (!userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
if (userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
reads = null; files = USER_FILES; granted = true;
reads = null; upstream = null; files = USER_FILES; granted = true;
}
}
if (named && preview !== null) {
@@ -784,15 +779,31 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = nul
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
}
// Row 198: the board's two writes run in-process, under --send and the public name only.
if (path === '/api/seen' || path === '/api/reply') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if (!send || !named) return json(res, 403, { error: 'read-only', sent: false });
if (!board) return json(res, 503, { error: 'not-configured', message: 'the Console has no board configured' });
return boardSend(req, res, { board, path });
}
// Nothing else but GET and HEAD gets past here in preview mode, whatever the Host.
if ((preview !== null || sso !== null) && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
// The conversation routes (#1507, CHAT-02) keep their query; the board validates it.
const conversation = path === '/api/conversations' || path === '/api/conversation';
const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
if (allowed) {
if (req.method !== allowed) return json(res, 405, { error: 'method not allowed' });
if (upstream === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no board for this session' });
let bytes;
if (allowed === 'POST') {
try { bytes = await body(req); } catch (err) { return json(res, 400, { error: err.message }); }
}
try {
const response = await fetch(upstream + path + (conversation ? search : ''), {
method: allowed, headers: bytes ? { 'content-type': 'application/json' } : {}, body: bytes,
redirect: 'error', signal: AbortSignal.timeout(timeout),
});
const text = await response.text();
JSON.parse(text); // Never serve upstream HTML or scripts as API data.
res.writeHead(response.status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
return res.end(text);
} catch {
return json(res, 502, { error: `Board unreachable or invalid response at ${upstream}. Check the board server. No automatic action retry.`, board: upstream });
}
}
if (path.startsWith('/api/bus/')) {
const verb = path.slice('/api/bus/'.length);
if (!BUS_VERBS.includes(verb)) return json(res, 404, { error: 'not found' });
@@ -812,7 +823,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = nul
if (path === '/api/queue' || path.startsWith('/api/queue/') || path === '/api/business' || path === '/api/settings' || path === '/api/bus-status') {
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
const a = server.address(), address = `http://${a.family === 'IPv6' ? `[${a.address}]` : a.address}:${a.port}/`;
if (path === '/api/settings') return consoleRead(res, async () => ({ ...(reads ? await reads.settings() : { release: null, business: null, notifier: { refused: 'not-configured', message: 'the Console has no reads configured' } }), board: boardLabel, address }));
if (path === '/api/settings') return consoleRead(res, async () => ({ ...(reads ? await reads.settings() : { release: null, business: null, notifier: { refused: 'not-configured', message: 'the Console has no reads configured' } }), board: upstream, address }));
if (!reads) return json(res, 503, { error: 'not-configured', message: 'the Console has no reads configured' });
if (path === '/api/business') return consoleRead(res, () => reads.business());
if (path === '/api/bus-status') return consoleRead(res, () => reads.busStatus());
@@ -822,18 +833,10 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = nul
return consoleRead(res, () => reads.row(Number(id)));
}
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
// Row 198: the board's reads, in-process. The sign-in gate above has already run for each one.
if (path === '/api/board' || path === '/api/conversations' || path === '/api/conversation') {
if (!board) return json(res, 503, { error: 'not-configured', message: 'the Console has no board configured' });
if (path === '/api/board') return consoleRead(res, () => board.index());
let out;
try { out = board.conversation(new URL(req.url, 'http://localhost')); } catch { return json(res, 500, { error: 'conversation read failed' }); }
return json(res, out.status, out.body);
}
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
if (path === '/api/config') return json(res, 200, { board: granted ? null : boardLabel, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path);
+2 -2
View File
@@ -17,7 +17,7 @@ const view={business:'acme',revision:REV,launch:true,noHire:['reviewer'],retired
test('row 194 T16 Settings Agents: unavailable read-only, launch follows its request, confirms, reason and refusal',{timeout:90000},async()=>{
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>view};
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
try{
// POSTs answer from `replies`, request reads from `polls`; both record what was asked.
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.replies=[];window.polls=[];window.reads=[];window.enabled=new URL(location.href).searchParams.has('send');const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(options.method==='POST'&&/^\\/api\\/(agent|veto|instances)/.test(url)){posts.push([url,JSON.parse(options.body)]);const [status,body]=replies.shift();return new Response(JSON.stringify(body),{status});}if(String(url).startsWith('/api/agent/request')){reads.push(url);const [status,body]=polls.shift()??[200,{request:'${ID}',action:'launch',instance:'coder',state:'open',done:null,cancelled:null}];return new Response(JSON.stringify(body),{status});}return original(url,options);};`});
@@ -131,7 +131,7 @@ test('row 194 T16 Settings Agents: unavailable read-only, launch follows its req
test('row 256 T7 T8 the human page offers Delete on a creative instance, the tester page never does',{timeout:90000},async()=>{
const human={...view,instances:{...view.instances,bean:{...view.instances.bean,endProtected:false}}};
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>human};
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
try{
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const q=new URL(location.href).searchParams;const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify(q.has('tester')?{tester:true}:{send:true}));return original(url,options);};`});
const port=web.address().port;
+2 -4
View File
@@ -22,7 +22,6 @@ import { views } from '../../bus/src/views.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { fixture, close } from './fixture.mjs';
import { routeWrites } from './board-route.mjs';
const businesses = {
demo: {
@@ -73,8 +72,7 @@ async function setup(machine, fast = false) {
const reader = b0.bindReader({ business: 'demo' });
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
const f = await fixture(), b = await browser({ machine });
const web = await startServer({ port: 0, board: f.board, bus }), base = `http://127.0.0.1:${web.address().port}/`;
const stopRoute = await routeWrites(b, base, f.board);
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub(fast) });
await b.viewport(1280);
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
@@ -83,7 +81,7 @@ async function setup(machine, fast = false) {
const reqs = () => b.evaluate('window.__reqs');
const polls = () => b.evaluate('window.__polls');
const settle = () => new Promise(r => setTimeout(r, 600));
const done = async () => { await stopRoute(); await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
return { b, base, wait, text, shown, reqs, polls, settle, done };
}
@@ -1,144 +0,0 @@
// Row 198: the board runs in-process in the Console. These are the Console-side killers:
// M3 (no proxy fetch to a board URL), M5 (no seen write without --send), M6 and M7 (the
// preview gate runs before any board route), M10 (positive control: the write branches
// reach the board under --send) and M14 (a pending board read never blocks /healthz).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { request } from 'node:http';
import { startServer } from '../src/serve.mjs';
import { createBoard } from '../../control-board/src/board.mjs';
import { seenPath } from '../../control-board/src/scan.mjs';
import { close, fixture } from './fixture.mjs';
const NAME = 'mosaic-preview.example.com', HEADER = 'remote-user', ID = 'jason';
const preview = { host: NAME, header: HEADER, user: ID };
const ok = { [HEADER]: ID, origin: `https://${NAME}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
const without = name => Object.fromEntries(Object.entries(ok).filter(([k]) => k !== name));
const BODY = '{"agent":"proj/agent1","text":"hi"}';
const BOARD_ROUTES = [['GET', '/api/board'], ['GET', '/api/conversations'], ['POST', '/api/seen'], ['POST', '/api/reply']];
// node:http, not fetch: fetch can't set Host.
function call(port, { method = 'POST', path, headers = ok, host = NAME, body = BODY } = {}) {
return new Promise((resolve, reject) => {
const length = method === 'GET' ? {} : { 'content-length': Buffer.byteLength(body) };
const req = request({ host: '127.0.0.1', port, method, path, headers: { host, ...length, ...headers } }, res => {
let text = '';
res.on('data', c => (text += c));
res.on('end', () => resolve({ status: res.statusCode, text }));
});
req.on('error', reject);
req.end(method === 'GET' ? undefined : body);
});
}
// A board that counts every call that reaches it.
function countingBoard(hits, index = async () => ({ sessions: [], seen: [] })) {
return {
index: async () => { hits.push('index'); return index(); },
markSeen: async () => { hits.push('markSeen'); return { seen: [] }; },
reply: async () => { hits.push('reply'); return { status: 200, body: { delivered: true } }; },
conversation: async () => { hits.push('conversation'); return { status: 200, body: {} }; },
};
}
function stop(web) {
return new Promise(resolve => { web.closeAllConnections(); web.close(resolve); });
}
// M6, M7: under the preview, every board route refuses a request without the proxy header
// (401) and a request with a foreign Host (403), and the board never runs.
test('preview: every board route refuses without the proxy header (401) or with a foreign Host (403) before the board runs (M6, M7)', async t => {
const hits = [];
const web = await startServer({ port: 0, board: countingBoard(hits), preview });
t.after(() => stop(web));
const port = web.address().port;
for (const [method, path] of BOARD_ROUTES) {
const bare = await call(port, { method, path, headers: without(HEADER) });
assert.equal(bare.status, 401, `${method} ${path} without the header`);
const foreign = await call(port, { method, path, host: 'evil.example' });
assert.equal(foreign.status, 403, `${method} ${path} with a foreign Host`);
}
assert.deepEqual(hits, [], 'the board ran for a refused request');
});
// M5: without --send the seen write refuses and writes no mark file.
test('seen without --send refuses and writes no mark (M5)', async t => {
const f = await fixture();
t.after(() => f.close());
const web = await startServer({ port: 0, board: f.board, preview });
t.after(() => stop(web));
const res = await call(web.address().port, { path: '/api/seen', body: JSON.stringify({ project: 'proj', agent: 'agent1', lastActivity: 'x', seen: true }) });
assert.equal(res.status, 403);
assert.equal(JSON.parse(res.text).sent, false);
assert.equal(existsSync(seenPath(join(f.root, 'board'))), false, 'a mark was written without --send');
});
// M10: positive control. With --send on the public name, both writes reach the in-process
// board. A branch moved below the read-only refusal would answer 403 here and fail.
test('with --send on the public name, seen and reply reach the in-process board (M10)', async t => {
const f = await fixture();
t.after(() => f.close());
const web = await startServer({ port: 0, board: f.board, preview, send: async () => ({ id: 'msg-0001' }) });
t.after(() => stop(web));
const port = web.address().port;
const rec = (await f.board.index()).sessions.find(r => r.agent === 'agent1');
const seen = await call(port, { path: '/api/seen', body: JSON.stringify({ project: rec.project, agent: rec.agent, lastActivity: rec.lastActivity, seen: true }) });
assert.equal(seen.status, 200, seen.text);
assert.equal(existsSync(seenPath(join(f.root, 'board'))), true, 'the seen write did not reach the board');
const reply = await call(port, { path: '/api/reply' });
assert.equal(reply.status, 200, reply.text);
assert.equal(JSON.parse(reply.text).delivered, true);
assert.equal(f.captures.length, 1);
assert.match(f.captures[0].args.at(-1), /^hi\n/);
});
// M3: the Console reads its board in-process. No request leaves the Console for a board URL.
test('a board read makes no request to a board URL (M3)', async t => {
const hits = [];
const web = await startServer({ port: 0, board: countingBoard(hits) });
t.after(() => stop(web));
const base = `http://127.0.0.1:${web.address().port}`;
const urls = [];
const realFetch = globalThis.fetch;
globalThis.fetch = (url, opts) => { urls.push(String(url)); return realFetch(url, opts); };
t.after(() => { globalThis.fetch = realFetch; });
assert.equal((await globalThis.fetch(base + '/api/board')).status, 200);
assert.deepEqual(hits, ['index'], 'the board ran once, in-process');
assert.equal(urls.length, 1, 'the spy saw the test request');
assert.ok(urls.every(u => u.startsWith(base)), `a request left the Console: ${urls.join(', ')}`);
});
// M14: a board read never blocks the Console's loop. The board is a real createBoard, and its tmux
// check runs through a shim on PATH that sleeps 1 s. A 10 ms timer records the longest stall of the
// loop while /api/board is pending; a spawnSync in the read path stalls the loop for the shim's full
// second. /healthz is timed by elapsed time from the same moment.
test('a board read never blocks the loop: the timer gap and /healthz stay short while /api/board is pending (M14)', async t => {
const root = mkdtempSync(join(tmpdir(), 'webui-m14-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const shim = join(root, 'shim'), sessionsDir = join(root, 'sessions'), seatsDir = join(root, 'seats');
for (const d of [shim, sessionsDir, seatsDir]) mkdirSync(d);
writeFileSync(join(shim, 'tmux'), '#!/bin/sh\nsleep 1\nexit 1\n');
chmodSync(join(shim, 'tmux'), 0o755);
const PATH = process.env.PATH;
process.env.PATH = `${shim}:${PATH}`;
t.after(() => { process.env.PATH = PATH; });
const board = createBoard({ specs: [{ agent: 'agent1', project: 'proj', sessionsDir, tmux: { socket: null, session: 'agent1' } }], boardDir: join(root, 'board'), seatsDir, discordDataRoot: root, host: 'm14' });
const web = await startServer({ port: 0, board });
t.after(() => stop(web));
const base = `http://127.0.0.1:${web.address().port}`;
let last = Date.now(), worst = 0;
const tick = setInterval(() => { const now = Date.now(); worst = Math.max(worst, now - last); last = now; }, 10);
t.after(() => clearInterval(tick));
const pending = fetch(base + '/api/board');
const t0 = Date.now();
const health = await fetch(base + '/healthz');
const healthMs = Date.now() - t0;
assert.equal(health.status, 200);
assert.ok(healthMs < 500, `/healthz took ${healthMs} ms while the board read was pending`);
assert.equal((await pending).status, 200);
clearInterval(tick);
assert.ok(worst < 500, `the loop stalled for ${worst} ms during the board read`);
});
-148
View File
@@ -1,148 +0,0 @@
// Row 198 r4 (sage-3618, mos-1506): the board refusal. The in-process board never answers 403 (consoleRead
// has no 403 code), so the browser stubs the answer: while the board is refused, Fetch fulfils /api/board
// and /api/conversation(s) with 403 board-refused, as the old fake board server did. The assertions are the
// refusal block that 4d8123b0a deleted from shell.test.mjs, ported one for one (BUILD.md, r4 mapping).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { startServer } from '../src/serve.mjs';
import { ReadError } from '../src/reads.mjs';
import { sendConfig } from './board-route.mjs';
// The old fake board's data: agent1 waiting on you in proj, plus agent2 and agent3, one conversation.
function board() {
const state = { refused: false, down: false, mode: 'ok' };
let index = { sessions: [], counts: {}, generatedAt: new Date().toISOString() };
const one = { agent: 'agent1', project: 'proj', state: 'waiting', waitingOnYou: true, task: 'Fixture task', lastActivity: '2026-10-10T00:00:00Z', lastAssistantText: 'Input needed: fixture', registered: null };
const catalogue = { conversations: [{ project: 'proj', seat: 'agent1', conversation: 'c1', availability: 'available', title: 'Fixture', conversationCreatedAt: '2026-10-10T00:00:00Z', lastActivityAt: '2026-10-10T00:00:00Z' }] };
const page = { page: { branch: 'b1', entries: [{ message: 'm1', role: 'assistant', createdAt: '2026-10-10T00:00:00Z', content: [{ block: 0, type: 'text', text: 'Fixture history line' }] }] }, view: { incomplete: false, defaultBranch: 'b1', branch: 'b1' }, cursor: null, follow: null };
const set = (sessions, counts) => { index = { sessions, counts, generatedAt: new Date().toISOString() }; state.mode = 'ok'; };
return {
state,
board: {
index: () => {
if (state.down) throw new ReadError('read-failed', 'board unavailable');
if (state.mode === 'fail') throw new Error('board scan failed');
return index;
},
conversation: url => {
if (state.mode === 'fail') throw new Error('catalogue read failed');
return { status: 200, body: url.pathname === '/api/conversations' ? catalogue : page };
},
markSeen: () => ({ status: 200, body: { ok: true } }),
reply: () => ({ status: 200, body: { delivered: true } }),
},
one: () => { state.refused = false; state.down = false; set([one, { ...one, agent: 'agent2', state: 'working', waitingOnYou: false }, { ...one, agent: 'agent3', state: 'idle', waitingOnYou: false, seen: true }], { waiting: 1, working: 1 }); },
registered: () => { state.refused = false; state.down = false; set([{ ...one, registered: { alive: true, tmux: { session: 'fixture' } } }], { waiting: 1 }); },
fail: () => { state.refused = false; state.mode = 'fail'; },
unavailable: () => { state.refused = false; state.down = true; },
refuse: () => { state.refused = true; },
};
}
test('a board refusal fails closed: the page hides, the banner says refused, the conversation closes and clears', { timeout: 180000 }, async () => {
const brd = board(); brd.one();
const web = await startServer({ port: 0, board: brd.board });
const base = `http://127.0.0.1:${web.address().port}/`, b = await browser();
const out = process.env.WEBUI_EVIDENCE;
if (out) mkdirSync(out, { recursive: true });
const shot = async name => { if (out) await b.screenshot(join(out, `shell-${name}.png`)); };
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
const count = sel => b.evaluate(`document.querySelectorAll(${JSON.stringify(sel)}).length`);
const flat = async () => assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, 'sideways scroll at 400px');
const refresh = async () => { await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled'); };
// The stub: while refused, the board's reads get the 403 the old fake server gave; otherwise they go on to the Console.
// /api/config answers with send on (mos-1524: config only), so the conversation's reply form is drawn.
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*/api/board*', requestStage: 'Request' }, { urlPattern: '*/api/conversation*', requestStage: 'Request' }, { urlPattern: '*/api/config', requestStage: 'Request' }] });
b.on('Fetch.requestPaused', async ({ requestId, request: req }) => {
if (new URL(req.url).pathname === '/api/config') {
await b.call('Fetch.fulfillRequest', { requestId, responseCode: 200, responseHeaders: [{ name: 'content-type', value: 'application/json' }], body: Buffer.from(await sendConfig(base)).toString('base64') }).catch(() => {});
return;
}
const answer = brd.state.refused
? b.call('Fetch.fulfillRequest', { requestId, responseCode: 403, responseHeaders: [{ name: 'content-type', value: 'application/json' }], body: Buffer.from(JSON.stringify({ error: 'board-refused' })).toString('base64') })
: b.call('Fetch.continueRequest', { requestId });
answer.catch(() => {});
});
try {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));' });
await b.viewport(400, 900);
await b.navigate(base); await wait('!document.querySelector("#sessions [aria-busy]")');
await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
// Board, a refusal: fails closed. The kept rows are hidden, and so is everything drawn from
// them: the open inspector, the project filter, the project tree's counts and the footer.
await b.evaluate('document.querySelector("#projects [data-project=proj]").click()');
assert.equal(await b.evaluate('document.querySelector("#projects [data-project=proj]").getAttribute("aria-pressed")'), 'true');
await b.evaluate('document.querySelector("table.sessions [data-open]").click()'); await wait('!document.querySelector("#inspector").hidden');
assert.equal(await text('#footer'), 'waiting 1 · working 1');
assert.equal(await b.evaluate('document.querySelectorAll("#projects .count").length'), 2);
assert.equal(await count('#seen .wait-item'), 1);
brd.refuse(); await refresh();
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner ref');
assert.match(await text('#error'), /The board refused the read\. refused: board-refused/);
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false);
assert.equal(await b.evaluate('document.querySelector("#inspector").hidden'), true);
assert.equal(await b.evaluate('document.body.classList.contains("has-inspector")'), false);
assert.equal(await b.evaluate('document.querySelectorAll("#projects .count, #projects [data-project]").length'), 0);
assert.equal(await text('#footer'), 'Board not read: refused');
assert.equal(await text('#fresh-board'), 'Board not read: refused');
assert.doesNotMatch(await text('#status'), /Scanned/);
for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id);
await flat(); await shot('board-refused-400');
// A failed read after the refusal shows the board again, empty: no row, card or count from
// the refused scan, and the footer says what the banner says. With nothing focused, focus stays put.
await b.evaluate('document.activeElement.blur()');
brd.unavailable(); await refresh();
assert.equal(await b.evaluate('document.activeElement === document.body'), true, 'a failure with nothing focused leaves focus alone');
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err');
assert.match(await text('#error'), /board unavailable.*No board data loaded\./);
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
assert.equal(await count('#sessions tr, #sessions [data-open], #waiting .wait-item, #seen .wait-item, #board-view [data-history]'), 0);
for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id);
assert.equal(await text('#footer'), 'Board not read: the read failed');
assert.equal(await text('#fresh-board'), 'Board not read: the read failed');
assert.equal(await text('#status'), 'Not read: the read failed');
assert.equal(await text('#projects'), 'Not read: the read failed.');
await flat(); await shot('board-refused-then-failed-400');
brd.one(); await refresh();
assert.equal(await b.evaluate('document.querySelector("#error").hidden'), true);
assert.equal(await b.evaluate('document.querySelector("#projects [data-project=\'\']").getAttribute("aria-pressed")'), 'true');
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
assert.equal(await text('#footer'), 'waiting 1 · working 1');
// A good read after a failed one drops the stale words.
brd.fail(); await refresh(); assert.match(await text('#fresh-board'), /stale/);
brd.one(); await refresh(); assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d UTC/); assert.doesNotMatch(await text('#fresh-board'), /stale/);
// A refusal with a conversation open closes it and clears its history and reply form: the
// session is in no scan the page holds.
brd.registered(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===1');
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
assert.equal(await b.evaluate('document.querySelector("#conv-form").hidden'), false);
// Fix to HEAD behaviour: a failed catalogue read leaves no session from the last one in the picker.
assert.equal(await count('#conv-pick option'), 1);
brd.fail(); await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-status").textContent.startsWith("History unavailable")');
assert.equal(await count('#conv-pick option'), 0, 'no stale session in the picker');
brd.registered(); await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
brd.refuse(); await refresh();
assert.equal(await b.evaluate('document.querySelector("#conversation").hidden'), true);
assert.equal(await b.evaluate('document.body.classList.contains("has-conversation")'), false);
assert.equal(await count('#conv-log > *'), 0);
assert.equal(await b.evaluate('document.querySelector("#conv-form").checkVisibility()'), false);
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false);
// Fix to HEAD behaviour: focus went back to History, which the refusal removed; it lands on the page, not <body>.
assert.equal(await b.evaluate('document.activeElement.id'), 'main', 'focus after a refusal closes the conversation');
brd.one(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
assert.deepEqual(await b.evaluate('window.errors'), []);
} finally { await b.close(); await close(web); }
});
@@ -1,32 +0,0 @@
// Row 198 (mos-1513): the write mapping the browser tests reach through board-route.mjs, pinned in node
// against the named send listener itself. The Console's own gate, parse and status mapping answer here;
// the browser tests only get back what this listener returns.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { close, fixture } from './fixture.mjs';
import { forward, namedListener } from './board-route.mjs';
test('named send listener maps the board writes: seen is 200, a delivered reply is 200 delivered, an exit-4 reply is 200 not delivered', { timeout: 60000 }, async t => {
const f = await fixture();
const named = await namedListener(f.board);
t.after(async () => { await close(named); await f.close(); });
const port = named.address().port;
const rec = (await f.board.index()).sessions.find(r => r.agent === 'agent1');
const row = `${rec.project}/${rec.agent}`;
const seen = await forward(port, '/api/seen', JSON.stringify({ project: rec.project, agent: rec.agent, lastActivity: rec.lastActivity, seen: true }));
assert.equal(seen.status, 200, seen.text);
const delivered = await forward(port, '/api/reply', JSON.stringify({ agent: row, text: 'hi' }));
assert.equal(delivered.status, 200, delivered.text);
assert.equal(JSON.parse(delivered.text).delivered, true);
assert.equal(f.captures.length, 1);
f.failReply();
const refused = await forward(port, '/api/reply', JSON.stringify({ agent: row, text: 'hi' }));
assert.equal(refused.status, 200, refused.text);
const body = JSON.parse(refused.text);
assert.equal(body.delivered, false);
assert.equal(body.exitCode, 4);
assert.equal(f.captures.length, 2);
});
-75
View File
@@ -1,75 +0,0 @@
// Row 198 (mos-1513): the browser tests reach the real board routes. Fetch intercepts the page's
// /api/config (send on) and its /api/reply and /api/seen writes. Node forwards each write to a named
// send listener on the same board, with the preview Host, the https Origin and the signed user header,
// so the server's own gate, parse and status mapping answer and the page gets that answer back.
// Nothing here re-implements boardSend; the node route tests pin what the listener returns.
import { request } from 'node:http';
import { startServer } from '../src/serve.mjs';
import { close } from './fixture.mjs';
export const ROUTE = { host: 'board-route.example', header: 'X-authentik-uid', user: 'board-route-user' };
const JSON_HEADER = [{ name: 'content-type', value: 'application/json' }];
// The named send listener on the board: the preview Host, so the server's own gate and status mapping answer.
export function namedListener(board) {
return startServer({ port: 0, board, preview: ROUTE, send: async () => ({ id: 'board-route' }) });
}
// One POST to the named listener, as the browser's forwarded write would arrive. Resolves { status, text }.
export function forward(port, path, body) {
return new Promise((resolve, reject) => {
const headers = { host: ROUTE.host, origin: `https://${ROUTE.host}`, [ROUTE.header]: ROUTE.user, 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) };
const req = request({ host: '127.0.0.1', port, method: 'POST', path, headers }, res => {
let text = '';
res.setEncoding('utf8');
res.on('data', chunk => { text += chunk; });
res.on('end', () => resolve({ status: res.statusCode, text }));
});
req.on('error', reject);
req.end(body);
});
}
// The page's /api/config body with send on and everything else as the Console answers it. The base URL may end in a slash.
export async function sendConfig(base) {
return JSON.stringify({ ...(await (await fetch(new URL('/api/config', base))).json()), send: true });
}
// Config only (mos-1524): Fetch answers the page's /api/config with send on and leaves every write alone, so the
// writes go to the page's own listener on its existing path. The Fetch domain stays on until the browser closes.
export async function sendOnConfig(b, base) {
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*/api/config', requestStage: 'Request' }] });
b.on('Fetch.requestPaused', async ({ requestId }) => {
try {
await b.call('Fetch.fulfillRequest', { requestId, responseCode: 200, responseHeaders: JSON_HEADER, body: Buffer.from(await sendConfig(base)).toString('base64') });
} catch {
await b.call('Fetch.failRequest', { requestId, errorReason: 'Failed' }).catch(() => {});
}
});
}
// Returns a function that closes the named listener. Call it in the test's finally, before the browser closes.
export async function routeWrites(b, base, board) {
const listener = await namedListener(board);
const port = listener.address().port;
await b.call('Fetch.enable', { patterns: [
{ urlPattern: '*/api/config', requestStage: 'Request' },
{ urlPattern: '*/api/reply', requestStage: 'Request' },
{ urlPattern: '*/api/seen', requestStage: 'Request' },
] });
b.on('Fetch.requestPaused', async ({ requestId, request: req }) => {
try {
const path = new URL(req.url).pathname;
let answer;
if (path === '/api/config') {
answer = { status: 200, text: await sendConfig(base) };
} else {
answer = await forward(port, path, req.postData ?? '');
}
await b.call('Fetch.fulfillRequest', { requestId, responseCode: answer.status, responseHeaders: JSON_HEADER, body: Buffer.from(answer.text).toString('base64') });
} catch {
await b.call('Fetch.failRequest', { requestId, errorReason: 'Failed' }).catch(() => {});
}
});
return () => close(listener);
}
+13 -18
View File
@@ -1,31 +1,26 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { routeWrites } from './board-route.mjs';
import { startServer } from '../src/serve.mjs';
test('browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback', { timeout: 120000 }, async () => {
let index = { sessions: [], counts: {}, generatedAt: 'fixture time' }, held = null, sends = 0, holdBoard = true, releaseBoard;
let index = { sessions: [], counts: {}, generatedAt: 'fixture time' }, held = null, sends = 0, holdBoard = true, boardResponse;
let resolveBoardRequest;
const boardRequested = new Promise(resolve => { resolveBoardRequest = resolve; });
// The board runs in-process (row 198): index() is the page's /api/board read, reply() its reply.
const board = {
index: () => {
if (!holdBoard) return index;
resolveBoardRequest();
return new Promise(resolve => { releaseBoard = resolve; });
},
reply: () => { sends++; return new Promise(resolve => { held = resolve; }); },
};
const web = await startServer({ port: 0, board });
const server = createServer(async (req, res) => {
for await (const chunk of req) { void chunk; }
res.setHeader('content-type', 'application/json');
if (req.url === '/api/board') { if (holdBoard) { boardResponse = res; resolveBoardRequest(); return; } return res.end(JSON.stringify(index)); }
sends++; held = res;
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
const web = await startServer({ port: 0, board: `http://127.0.0.1:${server.address().port}` });
const b = await browser();
const base = `http://127.0.0.1:${web.address().port}`;
let stopRoute;
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out')})()`);
try {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'Object.defineProperty(window,"localStorage",{get(){throw new Error("storage denied")}});window.errors=[];addEventListener("error",e=>errors.push(e.message));' });
stopRoute = await routeWrites(b, base, board);
await b.viewport(320); await b.navigate(`http://127.0.0.1:${web.address().port}`);
// Row 95: with storage denied the toggle starts off and still turns on; the page throws nothing.
assert.match(await b.evaluate('document.querySelector("#status").textContent'), /Machine sessions hidden/);
@@ -35,7 +30,7 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
let timer;
try { await Promise.race([boardRequested, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Show machine sessions made no /api/board request in 5 s')), 5000); })]); }
finally { clearTimeout(timer); }
holdBoard = false; releaseBoard(index);
holdBoard = false; boardResponse.end(JSON.stringify(index));
await wait('document.querySelector("#sessions").textContent.includes("No sessions")');
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '0');
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
@@ -57,7 +52,7 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
assert.equal(await b.evaluate('document.querySelector(".reply-form button").disabled'), true);
await b.evaluate('document.querySelector("#reply").focus()'); await b.call('Input.insertText', { text: 'newer ' });
held({ status: 200, body: { delivered: true, sentAt: 'fixture', session: agent } });
held.end(JSON.stringify({ delivered: true, sentAt: 'fixture', session: agent }));
await wait('document.querySelector(".receipt")?.textContent.startsWith("delivered")');
assert.equal(sends, 1); assert.match(await b.evaluate('document.querySelector("#reply").value'), /newer/);
// A stale registration removes the form, but does not destroy its draft.
@@ -97,5 +92,5 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
assert.match(await b.evaluate('document.querySelector("#error").textContent'), /Invalid board session data/);
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions [data-open]").length'), 1);
assert.deepEqual(await b.evaluate('window.errors'), []);
} finally { releaseBoard?.(index); held?.({ status: 200, body: {} }); await stopRoute?.(); await b.close(); await close(web); }
} finally { boardResponse?.end(); held?.end(); await b.close(); await close(web); await close(server); }
});
+4 -7
View File
@@ -4,17 +4,14 @@ import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { fixture } from './fixture.mjs';
import { routeWrites } from './board-route.mjs';
test('served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures', { timeout: 120000 }, async () => {
const f = await fixture(), b = await browser();
const out = process.env.WEBUI_EVIDENCE;
if (out) mkdirSync(out, { recursive: true });
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out')})()`);
let stopRoute;
try {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));' });
stopRoute = await routeWrites(b, f.base, f.board);
await b.viewport(1440); await b.navigate(f.base); await wait('document.querySelectorAll("table.sessions tbody tr").length===3');
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '2');
assert.equal(await b.evaluate('document.querySelectorAll("#projects [data-project]").length'), 3);
@@ -112,15 +109,15 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
await b.evaluate('document.querySelector("#close").click()');
}
// Empty and unreachable pages are tested as browser responses, never live data claims.
// Fail only the fixture board's reads. The page keeps the last data and names the board it could not reach (row 198: the Console).
f.board.setDown(true);
// Stop only the fixture board. The page keeps the last data and reports its URL.
await new Promise(r => f.board.close(r));
await b.evaluate('document.querySelector("#refresh").click()');
await wait('!document.querySelector("#error").hidden');
const error = await b.evaluate('document.querySelector("#error").textContent');
assert.ok(error.includes('in the Console')); assert.match(error, /Showing last known data/);
assert.ok(error.includes(f.boardURL)); assert.match(error, /Showing last known data/);
assert.ok(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length') > 0);
assert.deepEqual(await b.evaluate('window.errors'), []);
await b.navigate(f.base); await wait('!document.querySelector("#error").hidden');
assert.match(await b.evaluate('document.querySelector("#error").textContent'), /No board data loaded/);
} finally { await stopRoute?.(); await b.close(); await f.close(); }
} finally { await b.close(); await f.close(); }
});
+1 -1
View File
@@ -98,7 +98,7 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
assert.ok(comments.label.includes('\u001b'), 'the label excerpt keeps the ESC byte');
assert.ok(comments.label.includes('\u202e'), 'the label excerpt keeps the bidi character');
writeFileSync(fieldsFile, JSON.stringify(hostile));
const web = await startServer({ port: 0, board: f.board, bus: s.bus, fieldsFile }), base = `http://127.0.0.1:${web.address().port}/`;
const web = await startServer({ port: 0, board: f.boardURL, bus: s.bus, fieldsFile }), base = `http://127.0.0.1:${web.address().port}/`;
const out = process.env.WEBUI_EVIDENCE;
if (out) mkdirSync(out, { recursive: true });
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
+8 -8
View File
@@ -48,7 +48,7 @@ function seeded(t) {
test('bus routes serve the four reads from the reader view, unescaped JSON for the page to escape', async t => {
const { decision, bus, calls } = seeded(t);
const web = await startServer({ port: 0, board: null, bus });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
t.after(() => close(web));
const base = `http://127.0.0.1:${web.address().port}`;
const read = async path => { const r = await fetch(base + path); return { status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }; };
@@ -106,7 +106,7 @@ test('decisions route serves the roles and recent lists from the reader view', a
const reader = b.bindReader({ business: 'demo' });
const calls = [];
const bus = views({ call: async (verb, args = {}) => { calls.push([verb, args]); return structuredClone(b.request(reader, { verb, args })); } });
const web = await startServer({ port: 0, board: null, bus });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
t.after(() => close(web));
const base = `http://127.0.0.1:${web.address().port}`;
const read = async path => { const r = await fetch(base + path); return { status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }; };
@@ -140,7 +140,7 @@ test('credentials route serves the broker credential status from the reader view
const reader = b.bindReader({ business: 'demo' });
const calls = [];
const bus = views({ call: async (verb, args = {}) => { calls.push([verb, args]); return structuredClone(b.request(reader, { verb, args })); } });
const web = await startServer({ port: 0, board: null, bus });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
t.after(() => close(web));
const r = await fetch(`http://127.0.0.1:${web.address().port}/api/bus/credentials`);
assert.equal(r.status, 200); assert.equal(r.headers.get('cache-control'), 'no-store');
@@ -170,7 +170,7 @@ test('replies route serves the replies to one message from the reader view', asy
try { return structuredClone(b.request(reader, { verb, args })); } catch (err) { throw new BusReadError(err.code); }
};
const bus = views({ call });
const web = await startServer({ port: 0, board: null, bus });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
t.after(() => close(web));
const base = `http://127.0.0.1:${web.address().port}`;
const read = async path => { const r = await fetch(base + path); return { status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }; };
@@ -197,7 +197,7 @@ test('replies route serves the replies to one message from the reader view', asy
test('bus routes are GET-only, have no write verb, and keep the same-origin checks', async t => {
const calls = [];
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies'].map(v => [v, async () => { calls.push(v); return []; }]));
const web = await startServer({ port: 0, board: null, bus });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
t.after(() => close(web));
const base = `http://127.0.0.1:${web.address().port}`;
for (const method of ['POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS']) {
@@ -218,8 +218,8 @@ test('bus routes are GET-only, have no write verb, and keep the same-origin chec
test('bus refusals map to statuses and never carry a path or a stack', async t => {
let fail;
const bus = { inbox: async () => { throw fail; }, tasks: async () => ({ not: 'rows' }), agents: async () => [], trail: async () => [] };
const web = await startServer({ port: 0, board: null, bus });
const none = await startServer({ port: 0, board: null });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
const none = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
t.after(() => Promise.all([close(web), close(none)]));
const base = `http://127.0.0.1:${web.address().port}`;
const inbox = async () => { const r = await fetch(base + '/api/bus/inbox'); return [r.status, (await r.json())]; };
@@ -275,7 +275,7 @@ test('humanCall speaks the human transport protocol without blocking the server'
assert.equal(await humanCall({ socket: 's', business: 'demo', cli: join(tmpdir(), 'no-such-dir-x', 'cli.mjs') })('inbox').catch(e => e.code), 'invalid-response');
// A slow read stays slow on its own: the server keeps answering meanwhile.
const web = await startServer({ port: 0, board: null, bus: views({ call: (verb) => call(verb, { mode: 'slow' }) }) });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus: views({ call: (verb) => call(verb, { mode: 'slow' }) }) });
t.after(() => close(web));
const base = `http://127.0.0.1:${web.address().port}`;
const started = Date.now();
+1 -1
View File
@@ -47,7 +47,7 @@ const stub = `(() => {
})();`;
async function setup(t, suffix = '') {
const f = await fixture(); t.after(() => f.close());
const server = await startServer({ port: 0, board: f.board }); t.after(() => close(server));
const server = await startServer({ port: 0, board: f.boardURL }); t.after(() => close(server));
const b = await browser(); t.after(() => b.close());
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
await b.navigate(`http://127.0.0.1:${server.address().port}/${suffix}#/chat`);
+1 -1
View File
@@ -53,7 +53,7 @@ test('C2-09a metadata uses the human listener bus, projects labels only and refu
test('C2-09a metadata uses the private tester listener own bus and no human transport', async t => {
const root = mkdtempSync(join(tmpdir(), 'chat219-')); t.after(() => rmSync(root, { recursive: true, force: true }));
let reads = 0;
const server = await startTesterSocket({ dataRoot: root, board: null,
const server = await startTesterSocket({ dataRoot: root, board: 'http://127.0.0.1:9',
bus: { instances: async () => { reads++; return { instances: { quill: { definition: 'proofreader', vars: { 'persona.name': 'Quill' } } } }; } } });
t.after(() => close(server));
const answer = await get(server);
+1 -1
View File
@@ -294,7 +294,7 @@ test('configured Console process injects chat through its existing human transpo
const { fileURLToPath } = await import('node:url');
const root = tmp(t, 'webui-chat-cli-'), { env } = fixture(root);
const cli = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
const child = spawn(process.execPath, [cli, 'serve', '--port', '0',
const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9',
'--public-host', preview.host, '--auth-header', preview.header, '--allow-user', preview.user, '--send'],
{ env, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(async () => {
+1 -1
View File
@@ -74,7 +74,7 @@ async function setup() {
const reader = b0.bindReader({ business: 'demo' });
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
const f = await fixture(), b = await browser();
const web = await startServer({ port: 0, board: f.board, bus }), base = `http://127.0.0.1:${web.address().port}/`;
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB });
await b.viewport(1280);
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
+2 -2
View File
@@ -56,7 +56,7 @@ async function setup(t, server = {}, access = null) {
};
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => []]));
const userOptions = access && { userOptions: userOptionsFor({ owner: async () => ({}), send: true, transport: () => async () => [] }) };
const web = await startServer({ port: 0, board: null, bus, sso, send: async () => ({ id: 'msg-0001' }), decisionWrite, log: l => lines.push(l), ...userOptions, ...server });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus, sso, send: async () => ({ id: 'msg-0001' }), decisionWrite, log: l => lines.push(l), ...userOptions, ...server });
t.after(() => close(web));
const port = web.address().port;
const signIn = async (sub = undefined) => {
@@ -134,7 +134,7 @@ test('J16: without a decision transport the routes are read-only and /api/config
const write = async () => ({});
await assert.rejects(startServer({ port: 0, decisionWrite: write, send: async () => ({}) }), /need --sso and --send/);
await assert.rejects(startServer({ port: 0, tester: true, testerSocket: {}, decisionWrite: write }), /need --sso and --send/);
const plain = await startServer({ port: 0, board: null });
const plain = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
t.after(() => close(plain));
const port = plain.address().port, host = `127.0.0.1:${port}`;
const r = await call(port, { method: 'POST', path: '/api/decisions/d1/confirmation', host, headers: { origin: `http://${host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, body: '{"choice":"yes"}' });
+8 -10
View File
@@ -3,16 +3,16 @@ import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { identityOf } from '../../discord/src/journal.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { close, inProcessBoard } from './fixture.mjs';
import { forward, namedListener } from './board-route.mjs';
import { close } from './fixture.mjs';
test('Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content', { timeout: 60000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'webui-discord-'));
let web, b, named;
let board, web, b;
try {
const dir = join(root, 'discord', 'pilot'); mkdirSync(join(dir, 'run.lock'), { recursive: true });
writeFileSync(join(root, 'discord', 'pilot.json'), JSON.stringify({ name: 'pilot', seat: 'sage', tokenFile: '/PRIVATE-NOT-READ', guildId: 'PRIVATE-ID' }), { mode: 0o600 });
@@ -23,21 +23,19 @@ test('Discord row through real board/WebUI: independent brake/liveness, no Reply
// #1511: a native registration naming the connector's sessions directory, with a setter, must lend it nothing.
const seatsDir = join(root, 'seats');
writeRegistration(seatsDir, { ...makeRegistration({ resolved: { seat: 'sage', project: 'fleet', sessionsDir: sessions, seatDir: dir, launchScript: join(root, 'unused.sh'), layout: 'fleet', defaultWorkspace: null }, task: 'forged native task', tmux: { session: 'sage', socket: null }, pid: process.pid }), taskSetBy: 'forged-setter' });
const board = inProcessBoard({ root, specs: [], seatsDir, exec: () => { throw Error('no transport'); } });
web = await startServer({ port: 0, board });
board = await startBoard({ port: 0, specs: [], seatsDir, boardDir: join(root, 'board'), discordDataRoot: root, exec: () => { throw Error('no transport'); } });
web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` });
const base = `http://127.0.0.1:${web.address().port}`;
const payload = await (await fetch(base + '/api/board')).json();
assert.equal(payload.sessions[0].connector.braked, true); assert.equal(payload.sessions[0].state, 'idle');
assert.equal(JSON.stringify(payload).includes('PRIVATE'), false);
assert.equal(payload.sessions[0].task, 'Discord connector'); assert.equal(payload.sessions[0].taskSource, 'connector'); assert.equal(payload.sessions[0].taskSetBy, null);
assert.equal(JSON.stringify(payload).includes('forged'), false);
// The 409 is the board's refusal, reached through the named send listener; the loopback listener has no send and answers 403 first.
named = await namedListener(board);
const reply = await forward(named.address().port, '/api/reply', JSON.stringify({ agent: 'fleet/sage (discord: pilot)', text: 'refuse me' }));
const reply = await fetch(base + '/api/reply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'fleet/sage (discord: pilot)', text: 'refuse me' }) });
assert.equal(reply.status, 409);
b = await browser(); await b.viewport(320, 1000);
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,50))}throw Error('timeout')})()`);
await b.navigate(base);
await b.navigate(`http://127.0.0.1:${board.address().port}`);
await wait('document.querySelector(".session-row")');
assert.match(await b.evaluate('document.body.textContent'), /braked \(STOP\)/);
assert.match(await b.evaluate('document.body.textContent'), /Board replies disabled for Discord connectors/);
@@ -60,5 +58,5 @@ test('Discord row through real board/WebUI: independent brake/liveness, no Reply
await wait('document.querySelector("#inspection").textContent.includes("owner absent")');
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /not braked; owner absent/);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /offline/);
} finally { if (b) await b.close(); if (named) await close(named); if (web) await close(web); rmSync(root, { recursive: true, force: true }); }
} finally { if (b) await b.close(); if (web) await close(web); if (board) await close(board); rmSync(root, { recursive: true, force: true }); }
});
+1 -1
View File
@@ -48,7 +48,7 @@ test('the editor: the list, hostile markdown that runs nothing, the draft, and e
const f = await fixture(), b = await browser();
const reads = { files: [], file: [] };
const web = await startServer({
port: 0, board: f.board,
port: 0, board: f.boardURL,
reads: { business: async () => ({ business: { id: 'demo', human: 'jason', projects: ['stack'] } }) },
projectFiles: async args => { reads.files.push(args); return { head: C1, files: Object.keys(files).map(path => ({ path })) }; },
projectFile: async args => { reads.file.push(args); return { path: args.path, head: C1, commit: C1, content: files[args.path], author: 'jason', at: '2026-10-16T00:00:00Z' }; },
+1 -1
View File
@@ -51,7 +51,7 @@ async function session(run) {
const reads = [];
const f = await fixture(), b = await browser();
const web = await startServer({
port: 0, board: f.board,
port: 0, board: f.boardURL,
reads: { business: async () => ({ business: { id: 'demo', human: 'jason', projects: ['stack'] } }) },
projectFiles: async () => ({ head: C1, files: Object.keys(files).map(path => ({ path })) }),
projectFile: async args => { reads.push(args.path); return { path: args.path, head: C1, commit: C1, content: files[args.path], author: 'jason', at: '2026-10-16T00:00:00Z' }; },
+7 -13
View File
@@ -1,19 +1,12 @@
// Same session-line fixture shape as control-board/tests/serve.test.mjs.
// Run the real board scanner in-process (row 198) against temporary data only.
// Run the real board scanner/server against temporary data only.
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createBoard } from '../../control-board/src/board.mjs';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { makeRegistration, writeRegistration, updateTask } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
export const close = server => new Promise(resolve => { server.close(resolve); server.closeIdleConnections(); });
// The Console's board: the same createBoard context the CLI builds, over a temporary data root.
// setDown(true) makes the next read fail, which is how a test shows the page a board outage.
export function inProcessBoard({ root, specs, seatsDir, isAlive, exec }) {
const board = createBoard({ specs, boardDir: join(root, 'board'), seatsDir, discordDataRoot: root, isAlive, exec });
let down = false;
return { ...board, index: () => { if (down) throw new Error('fixture board is down'); return board.index(); }, setDown(value) { down = value; } };
}
export async function fixture() {
const root = mkdtempSync(join(tmpdir(), 'webui-fixture-'));
const seatsDir = join(root, 'seats'), specs = [];
@@ -36,11 +29,12 @@ export async function fixture() {
updateTask(seatsDir, agent, 'Registered fixture task', { layout: 'repo', setBy: 'fixture-setter' });
}
}
const board = inProcessBoard({ root, specs, seatsDir, isAlive: tmux => tmux.session !== 'agent4',
const board = await startBoard({ port: 0, specs, seatsDir, boardDir: join(root, 'board'), isAlive: tmux => tmux.session !== 'agent4', isPidAlive: () => true,
exec: (file, args) => { captures.push({ file, args }); return { status: exitCode, stdout: 'fixture transport', stderr: exitCode ? 'fixture refusal <unsafe>' : '' }; },
});
const web = await startServer({ port: 0, board });
return { root, board, web, base: `http://127.0.0.1:${web.address().port}`, captures, failReply: () => { exitCode = 4; },
async close() { await close(web); rmSync(root, { recursive: true, force: true }); },
const boardURL = `http://127.0.0.1:${board.address().port}`;
const web = await startServer({ port: 0, board: boardURL });
return { root, board, web, boardURL, base: `http://127.0.0.1:${web.address().port}`, captures, failReply: () => { exitCode = 4; },
async close() { await close(web); await close(board); rmSync(root, { recursive: true, force: true }); },
};
}
+3 -33
View File
@@ -51,7 +51,7 @@ const ALICE = { id: 'alice', home: 'local', subjects: [{ provider: 'a', sub: 'al
// The fake OP, an in-process broker for business `demo`, createSso with the access hook, and the
// human listener. The owner's options are counting stubs; each user session's options are
// consoleBus over that user's transport. `counts` records every owner stub and user transport call.
async function setup(t, { users = [ALICE], grants = [grant()], board = null, send = false } = {}) {
async function setup(t, { users = [ALICE], grants = [grant()] } = {}) {
const op = await fakeOidc();
t.after(() => op.close());
const iss = op.issuer('a');
@@ -103,7 +103,7 @@ async function setup(t, { users = [ALICE], grants = [grant()], board = null, sen
reads: Object.fromEntries(['settings', 'business', 'busStatus', 'queue', 'row'].map(v => [v, stub(v)])),
chat: Object.fromEntries(['projects', 'threads', 'messages', 'send', 'open', 'createProject', 'createThread'].map(v => [v, stub(v)])),
};
const web = await startServer({ port: 0, board, send, sso, log, ...owner, userOptions, federation: FEDERATION });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, log, ...owner, userOptions, federation: FEDERATION });
t.after(() => close(web));
const p1 = broker.request(human, { verb: 'project.create', args: { name: 'shared' } }).id;
const p2 = broker.request(human, { verb: 'project.create', args: { name: 'private' } }).id;
@@ -246,36 +246,6 @@ test('R6 G4 every owner route answers 403 owner-only before any bus call', async
assert.equal(events(env), before, 'something was written');
});
// Row 198 (sage-3603): the board runs in-process behind userRoute alone; upstream's null-origin backstop
// is gone. A granted session is refused on all five board paths before any board read, and the owner's
// session reaches each one. The mutant that drops the userRoute check for these paths must fail here.
test('R6 G4b the five board paths: a granted session is refused before any board read, the owner reaches all five', async t => {
const reads = [];
const board = {
index: async () => { reads.push('index'); return { sessions: [], counts: {}, generatedAt: 'fixture' }; },
conversation: url => { reads.push(url.pathname); return { status: 200, body: { conversations: [] } }; },
markSeen: async () => { reads.push('markSeen'); return { sessions: [] }; },
reply: async () => { reads.push('reply'); return { status: 200, body: { delivered: true } }; },
};
const env = await setup(t, { board, send: true });
const { session: granted } = await signIn(env, 'alice-sub');
const { session: owner } = await signIn(env, 'owner-sub');
const seen = JSON.stringify({ project: 'p', agent: 'a', lastActivity: 'x', seen: true }), reply = JSON.stringify({ agent: 'p/a', text: 'hi' });
const paths = [['GET', '/api/board'], ['GET', '/api/conversations'], ['GET', '/api/conversation?conversation=c1'], ['POST', '/api/seen', seen], ['POST', '/api/reply', reply]];
const headers = (method, cookie) => (method === 'GET' ? { cookie } : writeHeaders(cookie));
for (const [method, path, body] of paths) {
const r = await call(env.port, { method, path, headers: headers(method, granted), body });
assert.equal(r.status, 403, `granted ${method} ${path}`);
assert.equal(r.body.error, 'owner-only', `granted ${method} ${path}`);
}
assert.deepEqual(reads, [], 'a granted session reached the board');
for (const [method, path, body] of paths) {
const r = await call(env.port, { method, path, headers: headers(method, owner), body });
assert.equal(r.status, 200, `owner ${method} ${path}: ${JSON.stringify(r.body)}`);
}
assert.deepEqual(reads, ['index', '/api/conversations', '/api/conversation', 'markSeen', 'reply']);
});
test('R6 G5 the bus refuses every owner verb over the user transport on its own', async t => {
const env = await setup(t);
const o = consoleBus(env.userTransport('alice'), { send: true, owner: env.ownerCall });
@@ -366,7 +336,7 @@ test('R6 G7 the hub label and plain peer links render from config on both pages'
const b = await browser(); t.after(() => b.close());
// The owner page, over the real server.
const f = await fixture(); t.after(() => f.close());
const owner = await startServer({ port: 0, board: f.board, federation: FEDERATION }); t.after(() => close(owner));
const owner = await startServer({ port: 0, board: f.boardURL, federation: FEDERATION }); t.after(() => close(owner));
await b.navigate(`http://127.0.0.1:${owner.address().port}/`);
await wait(b, '!document.querySelector("#federation").hidden');
assert.deepEqual(JSON.parse(await b.evaluate(links)), expected);
+5 -4
View File
@@ -5,9 +5,10 @@
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
import { close, inProcessBoard } from './fixture.mjs';
import { close } from './fixture.mjs';
export const at = s => new Date(Date.now() + s * 1000).toISOString();
@@ -35,11 +36,11 @@ export async function historyFixture({ seat = 'fixture', project = 'repo' } = {}
mkdirSync(sessionsDir, { recursive: true });
const seatsDir = join(root, 'seats'), captures = [];
writeRegistration(seatsDir, makeRegistration({ resolved: { seat, project, sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: projectRoot }, task: 'Fixed task', tmux: { session: seat, socket: null }, pid: process.pid, now: () => new Date(Date.now() - 3600000) }));
const board = inProcessBoard({ root, specs: [{ agent: seat, project, sessionsDir, tmux: { session: seat } }], seatsDir, isAlive: () => true,
const board = await startBoard({ port: 0, specs: [{ agent: seat, project, sessionsDir, tmux: { session: seat } }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true,
exec: (f, args) => { captures.push(args); return { status: 0, stdout: '', stderr: '' }; } });
const web = await startServer({ port: 0, board });
const web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` });
return { root, projectRoot, sessionsDir, board, web, base: `http://127.0.0.1:${web.address().port}`, captures,
async close() { await close(web); rmSync(root, { recursive: true, force: true }); },
async close() { await close(web); await close(board); rmSync(root, { recursive: true, force: true }); },
};
}
@@ -8,7 +8,6 @@ import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { routeWrites } from './board-route.mjs';
import { historyFixture, session, user, assistant, toolResult, at, waiter, turns } from './history-fixture.mjs';
const PEER = '[from: darkwing (7525c8e4) -> to: fixture (00000000)] peer note that lands mid-turn';
@@ -17,7 +16,7 @@ const BIG = 'BIG_START ' + Array.from({ length: 900000 }, (_, i) => (i % 1679616
test('return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch', { timeout: 240000 }, async () => {
const f = await historyFixture();
let b, stopRoute;
let b;
try {
const file = join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl');
const log = session(file, f.projectRoot);
@@ -25,7 +24,6 @@ test('return flow through the conversation view: send, tool call, delayed result
log.add(assistant('Input needed: OLD_ANSWER'), at(-90));
b = await browser(); await b.viewport(1440, 1000);
stopRoute = await routeWrites(b, f.base, f.board);
const wait = waiter(b);
const draft = () => b.evaluate('(() => { const t = document.querySelector("#conv-reply"); return [t.value, t.selectionStart, document.activeElement === t]; })()');
// The seat is waiting on Jason, so the view opens from its card.
@@ -97,5 +95,5 @@ test('return flow through the conversation view: send, tool call, delayed result
assert.notEqual(await b.evaluate('document.querySelector("#conv-pick").value'), opened);
assert.equal(await b.evaluate('document.querySelector("[data-marker=newer]")'), null);
assert.equal(await b.evaluate('document.querySelector("#conv-reply").value'), 'NEXT_DRAFT');
} finally { if (stopRoute) await stopRoute(); if (b) await b.close(); await f.close(); }
} finally { if (b) await b.close(); await f.close(); }
});
@@ -18,7 +18,7 @@ test('row 165 Settings: role instances read, add with a restart named, change wi
// The server's read follows each write, so a poll between steps shows what the stub answered.
let current=view;
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>current};
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
try{
// The stub answers each POST with the next queued reply and records the body.
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.replies=[];window.enabled=new URL(location.href).searchParams.has('send');const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(url==='/api/instances'&&options.method==='POST'){posts.push(JSON.parse(options.body));const [status,body]=replies.shift();return new Response(JSON.stringify(body),{status});}return original(url,options);};`});
+2 -2
View File
@@ -21,7 +21,7 @@ const TOKENS=[
test('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',{timeout:90000},async()=>{
let answer={revision:launch.revision,limits:launch.limits,tokens:TOKENS};
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},limits:async()=>answer};
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{},launch})}}),b=await browser();
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{},launch})}}),b=await browser();
try{
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.enabled=new URL(location.href).searchParams.has('send');window.fixture=${JSON.stringify(launch)};const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(url==='/api/settings'&&options.method==='POST'){posts.push(JSON.parse(options.body));return new Response(JSON.stringify({...fixture,revision:'b'.repeat(64)}));}return original(url,options);};`});
const port=web.address().port;
@@ -74,7 +74,7 @@ test('REQ-LIMIT-3 REQ-LIMIT-4 Settings shows token limits counted, not enforced
test('Row 167 with an assistant draft open, a token limit reset gives the assistant submit its revision',{timeout:90000},async()=>{
const withAssistant={...launch,assistants:{assistant:{definition:'assistant',vars:{model:'claude-opus-5-5'},tracker:null}}};
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},limits:async()=>({revision:launch.revision,limits:launch.limits,tokens:TOKENS})};
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{},launch:withAssistant})}}),b=await browser();
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{},launch:withAssistant})}}),b=await browser();
try{
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.fixture=${JSON.stringify(withAssistant)};const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:true}));if(url==='/api/settings'&&options.method==='POST'){posts.push(JSON.parse(options.body));return new Response(JSON.stringify({...fixture,revision:'bc'[posts.length-1].repeat(64)}));}return original(url,options);};`});
const q=sel=>`document.querySelector(${JSON.stringify(sel)})`;
+3 -3
View File
@@ -15,7 +15,7 @@ function seed(t){
}
test('messages HTTP read pages and preserves metadata; bad arguments refuse before a read; other reads unchanged',async t=>{
const s=seed(t),a=s.send('first'),b=s.send('second');
const web=await startServer({port:0,board:null,bus:s.bus});t.after(()=>close(web));
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:s.bus});t.after(()=>close(web));
const url=`http://127.0.0.1:${web.address().port}/api/bus/`;
const get=async path=>{const res=await fetch(url+path);return {status:res.status,body:await res.json()}};
const page=await get('messages?limit=1');assert.equal(page.status,200);
@@ -34,7 +34,7 @@ test('messages HTTP read pages and preserves metadata; bad arguments refuse befo
});
test('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', {timeout:45000},async t=>{
const s=seed(t),early=s.send('early');
const web=await startServer({port:0,board:null,bus:s.bus});
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:s.bus});
const b=await browser({machine:false});
const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,25))}throw Error('condition timed out')})()`);
try{
@@ -78,7 +78,7 @@ test('Messages nav count polls on Tasks and Board and clears on a Board refusal;
business:async()=>{localReads.push('business');return {business:{id:'demo',human:'jason',arbiters:{},projects:[],vars:{},instances:[],actions:[],credentials:[]},at:at()};},
settings:async()=>{localReads.push('settings');return {release:'0.0.1',business:'demo',notifier:{binding:null},at:at()};},
};
const web=await startServer({port:0,board:null,bus,reads});
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads});
const b=await browser({machine:false});
const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,25))}throw Error('condition timed out: '+${JSON.stringify(expr)})})()`);
const badge=`document.querySelector('#sections a[href="#/messages"] .count')`;
+1 -5
View File
@@ -225,7 +225,7 @@ test('a copy that meets an empty target directory replaces it by rename, not by
// serve from a copy: /healthz, the config check, roles and RELEASE from the
// copy, and the queue read from --checkout or, without it, from the copy.
async function serve(t, cli, args, env) {
const child = spawn(process.execPath, [cli, 'serve', '--port', '0', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(() => child.kill());
let out = '', err = '';
child.stderr.on('data', (c) => { err += c; });
@@ -252,10 +252,6 @@ test('serve runs from a copy: /healthz 200, the config check, roles and RELEASE
assert.match(out, /^Bus: acme$/m, 'the config check ran from the copy');
assert.match(out, /^Queue: read from the --checkout directory$/m);
assert.deepEqual(await get('/healthz'), { status: 200, body: { ok: true } });
// Row 198 (M11): the copy carries control-board/src, so the in-process board answers.
const board = await get('/api/board');
assert.equal(board.status, 200, JSON.stringify(board.body));
assert.ok(Array.isArray(board.body.sessions), 'the board answers from the copy');
const settings = await get('/api/settings');
assert.equal(settings.status, 200);
assert.equal(settings.body.release, '0.0.0-fixture', 'RELEASE from the copy');
+17 -13
View File
@@ -1,3 +1,5 @@
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
import { after as laneAfter } from 'node:test';
// Row 74 (#1568, lead decision 93): the preview mode. A request for the public name needs the
// proxy's header equal to the one user id, else 401 with nothing read; in preview mode nothing
// but GET and HEAD gets through, whatever the Host; loopback requests work as they did. Round 2:
@@ -5,7 +7,7 @@
// fails closed instead of reaching the loopback path.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { createServer, request } from 'node:http';
import { connect } from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
@@ -44,17 +46,20 @@ function raw(port, lines) {
const signedIn = { [HEADER]: ID };
const json = { 'content-type': 'application/json' };
// A board that counts what reaches it (in-process, row 198), and a bus that counts its reads.
// A board that counts what reaches it, and a bus that counts its reads.
async function setup(t, options = {}) {
const boardHits = [];
const board = {
index: async () => { boardHits.push('GET /api/board'); return { sessions: [] }; },
markSeen: async () => { boardHits.push('POST /api/seen'); return { sessions: [] }; },
reply: async () => { boardHits.push('POST /api/reply'); return { status: 200, body: { delivered: true } }; },
};
const upstream = createServer((req, res) => {
boardHits.push(`${req.method} ${req.url}`);
res.writeHead(200, json);
res.end(req.url === '/api/board' ? '{"sessions":[]}' : '{"ok":true}');
});
await new Promise(r => upstream.listen(0, '127.0.0.1', r));
// Closed first and on its own, so a refused startServer doesn't leave it holding the process open.
t.after(() => close(upstream));
const busCalls = [];
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async arg => { busCalls.push([v, arg]); return [{ verb: v }]; }]));
const web = await startServer({ port: 0, board, bus, preview, ...options });
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, bus, preview, ...options });
t.after(() => close(web));
return { port: web.address().port, boardHits, busCalls };
}
@@ -99,7 +104,7 @@ test('the CLI refuses a partial or invalid preview flag set at start', () => {
test('the CLI with all three flags serves the preview mode', async t => {
// No system config, so the bus routes answer not-configured; the gate runs before them.
const env = { ...process.env, MOSAIC_CONFIG: fileURLToPath(new URL('./no-such-config.json', import.meta.url)) };
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--public-host', NAME, '--auth-header', 'Remote-User', '--allow-user', ID], { env, stdio: ['ignore', 'pipe', 'pipe'] });
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--public-host', NAME, '--auth-header', 'Remote-User', '--allow-user', ID], { env, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(() => child.kill());
let out = '';
const port = await new Promise((resolve, reject) => {
@@ -202,9 +207,8 @@ test('in preview mode every write answers 403 read-only, from the public name an
});
test('loopback requests are unchanged in preview mode, and the bus views work with the board down', async t => {
// The board is down: every read throws, which the Console answers as invalid-response (502).
const down = { index: async () => { throw new Error('board down'); } };
const { port, busCalls } = await setup(t, { board: down });
const dead = `http://127.0.0.1:${await leasePort({after:laneAfter}, {closed:true})}`;
const { port, busCalls } = await setup(t, { board: dead, timeout: 200 });
const loopback = { host: `127.0.0.1:${port}` };
const inbox = await send(port, { headers: loopback });
assert.equal(inbox.status, 200); assert.deepEqual(inbox.body.rows, [{ verb: 'inbox' }]);
@@ -213,7 +217,7 @@ test('loopback requests are unchanged in preview mode, and the bus views work wi
assert.equal((await send(port, { headers: { host: `127.0.0.1:${port + 1}` } })).status, 403);
// The board panel's existing error; the bus views still answer.
const board = await send(port, { path: '/api/board', headers: signedIn });
assert.equal(board.status, 502); assert.equal(board.body.error, 'invalid-response');
assert.equal(board.status, 502); assert.equal(board.body.board, dead);
assert.equal((await send(port, { path: '/api/bus/agents', headers: signedIn })).status, 200);
assert.deepEqual(busCalls.map(c => c[0]), ['inbox', 'agents']);
});
@@ -6,7 +6,7 @@ import { browser } from './browser.mjs';
const GEN = '12345678-1234-4234-8234-123456789abc';
async function setup(t, readonly = false) {
const f = await fixture(); t.after(() => f.close());
const server = await startServer({ port: 0, board: f.board }); t.after(() => close(server));
const server = await startServer({ port: 0, board: f.boardURL }); t.after(() => close(server));
const b = await browser(); t.after(() => b.close());
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(() => {
const real=fetch.bind(window);window.writes=[];window.failWrite=false;window.confirmResume=true;window.confirmed=0;
+3 -3
View File
@@ -37,7 +37,7 @@ test('proof startup refuses partial flags non-preview and invalid fixture file m
writeFileSync(hook, `import fs from 'node:fs';import {syncBuiltinESMExports} from 'node:module';const original=fs.fstatSync;fs.fstatSync=(...args)=>{const s=original(...args);s.uid=process.getuid()+1;return s;};syncBuiltinESMExports();`);
cases.push({ flags: [...PREVIEW, ...proof], env: { ...f.env, NODE_OPTIONS: `--import=${hook}` } });
for (const c of cases) {
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', ...c.flags], { env: c.env ?? f.env, encoding: 'utf8', timeout: 5000 });
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...c.flags], { env: c.env ?? f.env, encoding: 'utf8', timeout: 5000 });
assert.equal(r.status, 2, 'invalid proof startup must refuse'); assert.match(r.stderr, /proof/i);
assert.ok(!r.stderr.includes(VALUE)); assert.ok(!r.stdout.includes(VALUE));
if (c.value) assert.ok(!r.stderr.includes(c.value));
@@ -46,7 +46,7 @@ test('proof startup refuses partial flags non-preview and invalid fixture file m
test('serve argv carries only fixture proof path and header; startup explicitly reports proof on or off', async t => {
const f = fixture(t);
for (const on of [true, false]) {
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', ...PREVIEW, ...(on ? ['--proof-header', 'x-console-proof', '--proof-file', f.file] : [])], { env: f.env, stdio: ['ignore', 'pipe', 'pipe'] });
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...PREVIEW, ...(on ? ['--proof-header', 'x-console-proof', '--proof-file', f.file] : [])], { env: f.env, stdio: ['ignore', 'pipe', 'pipe'] });
let out = '', err = ''; child.stdout.on('data', c => out += c); child.stderr.on('data', c => err += c);
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
await new Promise((resolve, reject) => {
@@ -66,7 +66,7 @@ test('CLI applies proof on the main listener and rejects that header on its test
const f = await testerFixture(t), file = join(f.root, 'proof.fixture');
writeFileSync(file, VALUE, { mode: 0o600 });
await new Promise(r=>f.web.close(r));
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--tester-socket', '--business', 'acme', ...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], { env: f.f.env, stdio: ['ignore', 'pipe', 'pipe'] });
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--tester-socket', '--business', 'acme', ...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], { env: f.f.env, stdio: ['ignore', 'pipe', 'pipe'] });
let output = ''; child.stdout.on('data', c => output += c); child.stderr.resume();
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
await new Promise((resolve, reject) => {
+5 -4
View File
@@ -1,7 +1,7 @@
// Row 178: fixture proof values only. No installed proof, proxy or unit is read.
import test from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { createServer, request } from 'node:http';
import { mkdtempSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -24,8 +24,9 @@ async function setup(t) {
writeFileSync(file, VALUE + '\n', { mode: 0o600 });
const proof = serve.proofOptions({ proofHeader: HEADER, proofFile: file }, preview);
const calls = [], logs = [];
const board = { index: async () => { calls.push('board'); return { sessions: [] }; } };
const server = await serve.startServer({ port: 0, preview, proof, board,
const board = createServer((q, r) => { calls.push('board'); r.setHeader('content-type', 'application/json'); r.end('{"sessions":[]}'); });
await new Promise(r => board.listen(0, '127.0.0.1', r)); t.after(() => close(board));
const server = await serve.startServer({ port: 0, preview, proof, board: `http://127.0.0.1:${board.address().port}`,
bus: { tasks: async () => { calls.push('tasks'); return []; } }, send: async () => { calls.push('send'); return { id: 'receipt-1' }; }, log: line => logs.push(line) });
t.after(() => close(server));
return { port: server.address().port, calls, logs, proof, file };
@@ -69,7 +70,7 @@ test('proxy proof header is refused on other Hosts and on the tester listener',
assert.equal((await ask(f.port, { host: `127.0.0.1:${f.port}`, headers: { 'x-user': undefined, [HEADER]: '' } })).status, 403);
const testerRoot = mkdtempSync(join(tmpdir(),'tester-proof-'));
t.after(()=>rmSync(testerRoot,{recursive:true,force:true}));
const server = await startTesterSocket({ dataRoot: testerRoot, testerProofHeader: HEADER, board: null });
const server = await startTesterSocket({ dataRoot: testerRoot, testerProofHeader: HEADER, board: 'http://127.0.0.1:9' });
t.after(() => close(server));
const result = await testerRequest({dataRoot:testerRoot,headers:{[HEADER]:VALUE},path:'/api/config'});
assert.equal(result.status, 403); assert.deepEqual(f.calls, []);
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more