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
45 changed files with 1732 additions and 1373 deletions

No files matched your search

+4 -2
View File
@@ -5412,6 +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 | Boyd | Row 298 (#1804, Q228), before implementation completion | Took over Megavolt 7a141269c on boyd/row298 at 45dca69f6 under mos-1496/1502. Approved D1-D6 govern human-only grants.scope.set, empty fallback, in-scope close/evidence and spend recheck. All inherited code UNTESTED; no Node/tests until Sage CLEAR. Plan and static gaps recorded in agents/boyd/work/queue-298/BUILD.md; no live grant or credential access.
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 | Boyd | Row 298 (#1804, Q228), targeted verification | On sage-3642 T1 lane B, five whole files serial: business scope 7/0, bus scope 14/0, scope runtime 1/0, CLI scope 4/0, project runtime 3/0 (29/0, no skips). Initial bus fixture typo recorded and corrected. M1-M10 killed by named assertions, source restored and final files green. Dewey dewey-0933 positive control and notes addressed; manifest and evidence in agents/boyd/work/queue-298/BUILD.md. Full package/script gate remains Sage's. No live scope set.
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 | 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)
-233
View File
@@ -1,233 +0,0 @@
# Row 298 (#1804, Q228): chief-of-staff grant scope
Owner Boyd (48a3ae38), reviewer Dewey. Branch `boyd/row298`, base `45dca69f6`.
Assignment: mos-1496/Q245. Handoff: Megavolt megavolt-0203, local `7a141269c`;
cherry-picked as `4de3cec27` (design) and `7cea739de` (UNTESTED code).
Brief: `docs/plans/2026-10-23_mvp-readiness-rows.md`, chief-of-staff grant scope.
Megavolt's original design remains in `agents/megavolt/work/queue-298/BUILD.md`.
## Authority and accepted design
Sage sage-3594, restated in sage-3612: launch.grantScope, lenient read with empty
fallback, strict human-only write. No general organization setter may write it.
Per-method agents/projects or agents/hosts/commands; host-enroll never in scope.
Host command arrays match whole tokens and exact lengths. `grants.scope.set`
from the human CLI is Approve; no Console page or chief-raised scope change.
Mos mos-1496/1502 requires the same human-shell guard as `mosaic access`.
The broker closes an in-scope request via scope and writes grant.scope.used;
missing evidence must refuse execution. Narrowing before spend fails
scope-changed. Empty scope ships; only Jason sets the live value (Q188).
No credential files read, no live grants, no deployment, no queue writes. Push
only boyd/*, additive commits, no amend/force-push or attribution trailers.
Sage owns queue, shared test slot and landing. No Node/tests before CLEAR.
## Implementation plan and ledger
Using writing-plans and executing-plans inline; the approved brief and D1-D6
are the spec. This packet is the requested plan/tracking location.
- [x] Read brief, roster, implementation and handoff; register session.
- [x] Bring Megavolt's two commits onto Boyd's branch without changing history.
- [x] Add independent CLI tests in `packages/cli/tests/grants-scope.test.mjs`:
scope read/set requests; agent-marker rejection before stdin/transport;
--tester rejection; missing revision, malformed/empty/oversize input.
- [x] Strengthen new business/bus grant-scope tests for exact commands,
scope evidence binding, host-enroll exclusion and spent scope-changed.
- [ ] On Sage CLEAR, observe targeted failures before fixes; fix inherited
implementation using the existing human proof and grant executors.
- [ ] Update bus/CLI docs, append BUILD-LOG before/after evidence, manifest.
- [ ] On Sage CLEAR run touched suites; record exact results and mutants.
- [ ] Commit UNTESTED until verified; push boyd/row298 only when slot permits;
report to Sage and Dewey, cc Mos for pushes/blockers. Dewey reviews manifest.
Pre-flight interfaces: business checkGrantScope/grantScopeOf/grantInScope feed
the instance writer and broker. Runtime supplies a fresh scope from the org
file and routes the human setter to the same organization writer. CLI callFor
supplies refuseInsideAgent and humanTransport. The grant executors consume the
broker's immutable close/evidence. Tests use scratch files/fake Gitea only.
Review focus: exact argv length, stale scope before spend, forged/missing scope
evidence, malformed fallback, and CLI agent ancestry. Tests must cover each.
Static findings before any Boyd Node/test run:
- Handoff introduced commandPrefixes beyond the accepted D2 shape, allowing
trailing args. Remove it; only exact command arrays are authorized.
- scope.used carries a digest without validating it, and no org revision.
Bind evidence to the canonical scope snapshot and grant/close sequence;
preserve historical grants when scope is later changed, recheck at spend.
- Gitea recheck throws inside the spend transaction and rolls back spend on
scope-changed. Preserve spend and log failure for that case (D6).
- The optional giteaStart?. assertion always passes when the API is absent;
use the real runGiteaGrant entry point. Fake Gitea tokens() does exist.
## Existing test changes (mos-1272)
Approved by Mos mos-1510 via Sage sage-3624; edited exactly as approved:
| File | Actual numstat | Reason |
| --- | --- | --- |
| packages/bus/tests/project-runtime.test.mjs | +1 / -1 | Vocabulary import pin adds grantScopeOf and grantInScope. |
Other test work is confined to row 298's new files. No pre-existing CLI tests
will change. Record final numstat here before review.
## Human proof and organization ownership
CLI: `packages/cli/src/cli.mjs` callFor runs refuseInsideAgent before constructing
humanTransport, as `accessCommand` does. The setter takes no --tester flag.
`packages/bus/src/human.mjs` verifyHuman checks the nonce, CLI path and every
ancestor's markers/argv; `bindHuman` and #human in broker.mjs require the
business human, via cli, outsideAgent true. Tests will pin this chain's caller
refusal. The scope receipt records by/via/outsideAgent and old/new sha256.
Org file: `packages/business/src/organization.mjs` regular() requires a regular,
non-symlink file owned by the process uid and not writable by group/other;
write() uses revision checks, locked durable receipts and atomic rename.
The OS owner uid can edit it directly; the agent sandbox does not mount the
user's config (`packages/seat/src/sandbox.mjs`). This is the existing cooperative
human/process boundary, not a hostile same-uid isolation claim.
Chief enrollment: broker instances.set calls #human and #mayHuman before
add/change; credential issue/delete use #requestCredential's human gate.
launch.by is an organization-file setting, excluded from general setters;
only that chief may auto-approve within scope. Additional chief instances
remain on the per-grant human approval path.
## Validation
UNTESTED. No Boyd Node/test run yet; waiting for Sage CLEAR.
M1: bypass in-scope check. M2: bypass invalid-value fallback. M3: remove human
setter guard. M4: drop grant.scope.used. All need named failing tests on gate.
## Session correction
The initial SESSIONS entry labels startup 19:13Z; startup was approximately
19:08Z. Message header timestamps in boyd-0001 through 0004 were estimates;
use T3's receipt timestamps as authoritative. Subsequent reports use date -u.
## Departures from Megavolt 7a141269c (Sage sage-3624)
1. Removed commandPrefixes; host scope commands match exact token arrays only.
2. grantScope callback now supplies one organization read `{value,revision}`.
grant.scope.used includes that revision, canonical scope snapshot and digest;
#approvedGrant validates the digest, shape, method, agent and close sequence.
It uses the immutable snapshot so later narrowing doesn't revoke issued grants.
3. Gitea scope-changed is caught inside the spend transaction and logs a terminal
failure without rolling back its spend. Other recheck failures keep their
existing behavior. Host already catches recheck failures after spending.
Additional handoff corrections: replaced optional nonexistent giteaStart with
runGiteaGrant; added successful host executor/revoke and enrollment-exclusion
coverage; added real runtime writer coverage and independent CLI guard tests.
Sage scheduled push at 19:41Z with MOSAIC_GITEA_CREDENTIAL_FILE unset, then T1
lane B around 19:47Z (after explicit CLEAR), four new files whole and serial,
hard stop 20:00Z. Fethry lane A is disjoint. No test was run early. Production
corrections are marked UNTESTED under that slot mandate; baseline regression
checks against the inherited implementation will run only on CLEAR.
## Candidate preparation (before T1)
The three static corrections, CLI/runtime coverage and docs are prepared but
UNTESTED. `git diff --check` is clean. The approved import-pin edit is exactly
1 addition / 1 deletion. M1-M4 are encoded in mutants.py (not run). No Node,
https git, full suite or webui build has run in Boyd's worktree.
Review package: product/test/documentation files in MANIFEST.sha256, excluding
append-only project logs and the preserved predecessor packet. The manifest
identifies exact bytes for Dewey; each later product change regenerates it and
requires a review delta. Existing tests changed: only project-runtime, 1/1.
## Dewey static review dewey-0933
N1: Added a positive control to the malformed-evidence fixture. A valid event
must pass scope authorization and reach decision-mismatch on the deliberately
absent definition binding; invalid events remain not-authorized. No spend.
N2: Additional named mutants cover spend recheck, exact argv length, Gitea spend
rollback, launch.by eligibility and accepting any evidence row (results below).
N3: At spend only grant scope membership is rechecked, not whether the raising
chief is still launch.by. This implements sage-3594 Q5; changing the launcher
later does not by itself invalidate a pending scope-approved request.
N4: The scope shortcut returns the closed decision view to the chief without
sending a separate close message; documented in the bus verb table.
N5: grant.scope is logged after the organization file commit in a separate bus
transaction. A crash between them leaves durable changes.jsonl as the receipt,
as with other organization writers. The setter does not claim cross-store
atomicity.
## T1 lane B results (sage-3642, 2026-10-23 19:23Z–19:29Z)
TMPDIR: `/mnt/storage/scratch/tmp/boyd298-4bzr6fn7`. Each whole file ran serially
as `timeout -k 10 180 node --test --test-concurrency=1 FILE`, TMPDIR set to that
scratch directory. Final restored candidate results:
| Whole file | Pass | Fail | Skip |
| --- | ---: | ---: | ---: |
| business/tests/grant-scope.test.mjs | 7 | 0 | 0 |
| bus/tests/grant-scope.test.mjs | 14 | 0 | 0 |
| bus/tests/grant-scope-runtime.test.mjs | 1 | 0 | 0 |
| cli/tests/grants-scope.test.mjs | 4 | 0 | 0 |
| bus/tests/project-runtime.test.mjs | 3 | 0 | 0 |
29/0/0 total. `final-1.log` through `final-5.log` hold the final output. The
runtime's reload-needs-restart notice is expected: the fixture's boot document
has fewer credential references than its organization document, as in the
existing instances runtime tests. No credential contents are loaded from the
real user config.
Initial run: business 7/0; bus 13/1 due to Boyd's test typo, an out-of-scope
`opts` reference in the missing-scope assertion. Corrected only that new-test
line, then bus 14/0 and the remaining files green. Dewey N1 positive control
was added and the whole bus file passed again; final rerun above followed all
mutants. No production code changed during this test window.
Mutants: all whole files, serial, with timeout -k 10 and the same TMPDIR.
`mutants.py --sage-clear sage-3642 --logs SCRATCH/review-mutants --deadline
2026-10-23T19:38:00+00:00` restores exact original source after each run.
M1/M2/M3/M4 are brief/Sage mutants; M5–M10 cover review focus and dewey-0933.
Each exited 1 with named assertion failures, no loader/syntax kill.
| Mutant | Change | Pass/fail | Named killer |
| --- | --- | --- | --- |
| M1 | skip raise scope check | 11/3 | bus out-of-scope Gitea waits; empty/bad scope waits; host argv exclusion |
| M2 | skip invalid-value fallback | 5/2 | business missing/bad scope fallback; bad file scope fallback |
| M3 | remove #human from scope verb | 13/1 | bus Q3 caller refusals |
| M4 | drop grant.scope.used write | 9/5 | bus Q4 protected evidence assertion; four executor waits also fail |
| M5 | skip scope digest check | 13/1 | bus Q4 bad evidence: decision-mismatch instead of not-authorized |
| M6 | rethrow narrowing, rolling spend back | 13/1 | bus Q5 Gitea spent assertion |
| M7 | same without length check | 6/1 | business Q2 shorter command prefix exclusion |
| M8 | no-op #scopeRecheck | 12/2 | bus Q5 Gitea and host scope-changed outcomes |
| M9 | remove launch.by check | 13/1 | bus Q6 second chief stays open |
| M10 | accept any matched scope evidence row | 13/1 | bus Q4 bad evidence, with positive control |
Mutation log sha256 (under scratch/review-mutants):
```text
55e9973af5fca0088963e2ca11fd81b2fb46dfead813a577c0e0aad2d6269b68 M1.log
abd1b784eff5ae1333a7b47085d3e199cad6d27a00e2584edf0b62417ad4c888 M2.log
1a56085e548d1e244f0f88fe12a45f77c162375880794f9ef88b241ed3a1704d M3.log
9c120f3f7128488edaf8bfd6d41bae712e69e76da4195a0d4dc0e8336b02038f M4.log
1bba88a3752823454acdaa51abde6aad66fdd5488801b4aceda02a3b42c8123d M5.log
a4ce3cacca9f7ea56b8cdfd503c76173a8e4ff1d8a7ec07032c93f0b51aa60d8 M6.log
acecd5afc93d68d96d713f8a9a2dc08351831a0b7360a13b4e30a572ad4167bf M7.log
fce7fa505ccf4f2d4190bd03c209fd19f1cf2770d3ae1246a3bd03697594019e M8.log
a830a7964890dd04a2dcc7a932a1502f1dea06419a1eae6b91a85f7878dc5681 M9.log
38ce3e7f69f6c6ffe10133397465d52538a930a830f76256a8b9280f8df78e4b M10.log
```
Full touched-package suites and every scripts/test-*.sh remain for Sage's full gate; this CLEAR authorized only the five whole files. Candidate remains
marked Untested for the full gate. No queue row has been changed by Boyd.
## Dewey final delta review (dewey-0935, 2026-10-23 19:31:55Z)
Dewey reviewed 574ece13c..bcd93dc08 with local git only, no Node: CLEAN,
nothing blocks. Verified all 13 manifest entries, exact package-file coverage,
project-runtime 1/1, M5-M10 mutation sites, all ten mutation log hashes and
counts, named assertion kills without loader/syntax errors, final five logs
29/0/0, and N1-N5 coverage. No production code delta in this review range.
Formal Gitea APPROVE on #1804 and queue record wait on the row 291 hold release
and Sage's go. Sage's touched-package and every scripts/test-*.sh gate remains
required. Boyd has released lane B and has no running test processes.
@@ -1,13 +0,0 @@
612488b705853e75e6f8952e41552526f4d07dd297d7619d608d434ed75121d6 packages/bus/README.md
3677f403104c121d53eb50b874de7a5d8d22e038faac52a5ea5bad449d0d409c packages/bus/src/broker.mjs
2e0ade9233404439cd4a629245600878da960f1126e7299aa2ccfb3aff86d784 packages/bus/src/runtime.mjs
e7065db949cacfd4679a1031eebf5cde09ed196ce85612e8400ae563f02a0f18 packages/bus/tests/grant-scope-runtime.test.mjs
4030cf584481a1db12d9e6f744015c48fdebd468335d065ead3e90180858d366 packages/bus/tests/grant-scope.test.mjs
bc432d8d430433b9d84c00883584dc53d4b27afb5e93f739fe3ca28e80f01efa packages/bus/tests/project-runtime.test.mjs
84d6dd15d5ca2926d8f7a34548c70c3e7a3c58e692f0b009414f1af35cb096cc packages/business/src/business.mjs
1ac106522c8a115cb9479f2a6c5591fb9c77d138bbe93d2df10b6c4295645d0e packages/business/src/instance-settings.mjs
fed4177325f65a2b9823a40a9c35c01d60c0520e3ff40b4574c3cf2c29cc096b packages/business/src/vocabulary.mjs
b7f96e45b9ffca2a7ebc18dc446f0adcaac72d3900f098c60157cf6cbf1bfc13 packages/business/tests/grant-scope.test.mjs
60fc267b7f8f9e83eeb0d0207c8abdef68d554f32b08d508e98dbf59b442a366 packages/cli/README.md
b586907ca8f351f60dc19ec2683df75d2d70ddb12955d09fb14560c9d3bed3dc packages/cli/src/grants.mjs
fb7c30b31facd887b03ff02dd6734d48a815978481b33bb21ec48604a51c5ff5 packages/cli/tests/grants-scope.test.mjs
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env python3
"""Row 298 mutants. Run only with Sage's explicit CLEAR in Boyd's slot.
Each mutation is applied to this worktree, runs one whole new test file,
then restores the exact original bytes, including after an interruption.
Logs go to a caller-supplied scratch directory, never the repository root.
"""
import argparse
import datetime
import pathlib
import subprocess
ROOT = pathlib.Path(__file__).resolve().parents[4]
BROKER = ROOT / 'packages/bus/src/broker.mjs'
VOCABULARY = ROOT / 'packages/business/src/vocabulary.mjs'
BUS_TEST = 'packages/bus/tests/grant-scope.test.mjs'
BUSINESS_TEST = 'packages/business/tests/grant-scope.test.mjs'
def replace_once(source, old, new):
if source.count(old) != 1:
raise RuntimeError(f'expected exactly one mutant site, found {source.count(old)}')
return source.replace(old, new, 1)
def mutate(name, source):
if name == 'M1':
return replace_once(source,
'if (!raise?.grant || !grantInScope(scope, raise.grant)) return view;',
'if (!raise?.grant) return view;')
if name == 'M2':
return replace_once(source,
'return scope === null ? { scope: GRANT_SCOPE_EMPTY, fallback: true } : { scope, fallback: false };',
'return { scope: scope ?? value, fallback: false };')
if name == 'M3':
return replace_once(source,
' #grantScopeVerb(s, verb, a) {\n this.#human(s);',
' #grantScopeVerb(s, verb, a) {')
if name == 'M4':
old = """ this.#grantEvent(s.business, {
action: 'grant.scope.used', grantDecision: d.id, decisionSeq: seq, method: raise.grant.method,
agent: raise.grant.agent, scope, revision, scopeSha256: sha256(JSON.stringify(scope)),
}, d.id);"""
return replace_once(source, old, ' // MUTANT: scope evidence omitted.')
if name == 'M5':
return replace_once(source,
"evidence.agent === grant.agent && evidence.scopeSha256 === sha256(JSON.stringify(checked.scope)) &&",
"evidence.agent === grant.agent &&")
if name == 'M6':
start = source.index(" checked = this.#store.transaction(() => {\n const consumed = this.#consumeGrant")
end = source.index('\n } catch (e) {', start)
return source[:start] + " checked = this.#store.transaction(() => this.#giteaRecheck(business, decision, this.#consumeGrant(business, decision, 'gitea')));" + source[end:]
if name == 'M7':
return replace_once(source,
'const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);',
'const same = (a, b) => a.every((x, i) => x === b[i]);')
if name == 'M8':
return replace_once(source,
" if (this.#closed(decision)?.via === 'scope' && !grantInScope(this.#scopeNow(business).scope, g)) fail('scope-changed');",
" // MUTANT: no spend-time scope recheck.")
if name == 'M9':
return replace_once(source,
' if (s.role !== this.#business(s.business).launch?.by) return view;',
' // MUTANT: any chief instance can use scope.')
if name == 'M10':
start = source.index(' const evidence = JSON.parse(row.body);', source.index(' #scopeUsed('))
end = source.index('\n }', start)
return source[:start] + ' return true;' + source[end:]
raise ValueError(name)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--sage-clear', required=True, help='CLEAR message key; required before any Node run')
parser.add_argument('--logs', type=pathlib.Path, required=True)
parser.add_argument('--mutant', choices=['M1', 'M2', 'M3', 'M4', 'M5', 'M6', 'M7', 'M8', 'M9', 'M10', 'all'], default='all')
parser.add_argument('--deadline', default='2026-10-23T19:38:00+00:00')
args = parser.parse_args()
deadline = datetime.datetime.fromisoformat(args.deadline)
args.logs.mkdir(parents=True, exist_ok=True)
names = ['M1', 'M2', 'M3', 'M4', 'M5', 'M6', 'M7', 'M8', 'M9', 'M10'] if args.mutant == 'all' else [args.mutant]
for name in names:
remaining = (deadline - datetime.datetime.now(datetime.timezone.utc)).total_seconds()
if remaining <= 15:
raise RuntimeError('Sage slot deadline reached; no new test run')
path, test = (VOCABULARY, BUSINESS_TEST) if name in ['M2', 'M7'] else (BROKER, BUS_TEST)
original = path.read_bytes()
mutated = mutate(name, original.decode())
log = args.logs / f'{name}.log'
try:
path.write_text(mutated)
with log.open('w') as output:
result = subprocess.run(['timeout', '-k', '10', str(int(min(180, remaining - 13))), 'node', '--test', '--test-concurrency=1', test],
cwd=ROOT, stdout=output, stderr=subprocess.STDOUT,
timeout=min(195, remaining - 1), check=False)
print(f'{name}: exit={result.returncode} log={log}', flush=True)
finally:
path.write_bytes(original)
# Exit 1 needs human inspection of the failing test names; errors are not kills.
if result.returncode == 0:
raise RuntimeError(f'{name} survived')
if __name__ == '__main__':
main()
+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.
-92
View File
@@ -1,92 +0,0 @@
# Row 298 (#1804, Q228): the chief-of-staff grant scope
Owner Megavolt, reviewer Dewey. Assigned in sage-3584 (mos-1489), 2026-10-23 18:53Z.
Base: origin/refactor 45dca69f6. Branch: megavolt/row298.
Brief: docs/plans/2026-10-23_mvp-readiness-rows.md, "Brief: the chief-of-staff grant scope (Q228)".
Decision: DECISIONS Q228 (jason-master-restart DECISIONS.md:2278). It replaces Mos's Q190 reading.
## What exists at 45dca69f6 (broker.mjs)
- Raise: `#raiseGrant` (:2558) is chief-only (`grant-chief-only`) and binds a grant body per method:
gitea `#giteaGrantBinding` (:2665), host `#hostGrantBinding` (:2599), or host-enroll `enrollBinding`.
The decision goes through `#dispatch(..., 'decision.raise', ...)`, routed to the human.
- Approval: `#approvedGrant` (:1525) returns the binding only for a credential.mint routed to the
human and closed with `approvalChoice` by the business's human, via 'cli', or via 'console' with
the broker's own human.input (row 252).
- Start: decision.resolve (:5653-5659) runs `#approvedGrant` after the close and, once the transaction
commits, starts `#runEnrollment`, `#runHostGrant` or `#startGitea` with setImmediate.
- Spend and records: each executor calls `#consumeGrant` and writes the 236 records (grant.call,
grant.result, grant.step, grant.applied or grant.failed, gitea_grants or host_grants). Revoke
goes through grants.revoke and hosts.revoke, as before.
- Human-only settings: `#human(s)` (cli, outsideAgent) plus `#mayHuman`, as in veto.set (:4713).
The organization file is checked whole by business.mjs `checkLaunch`, and a bad file refuses
every read.
## Proposed design (waits on Sage's rulings, Q1-Q6 below)
D1. Storage. The scope gets its own key, `launch.grantScope`, in the organization file. The
whole-file check doesn't refuse on it. A separate reader, `grantScope(document)`, returns the
parsed scope or, when the key is missing or bad, the empty scope `{}`, plus a `fallback` flag.
A bad value can't break every organization read, and it falls back to "everything needs
Approve", which is the fail-closed direction. (Q1)
D2. Shape. `{ gitea?: { agents: [id], projects: [id] }, host?: { agents: [id], hosts: [id],
commands: [[argv prefix]] } }`. A request is in scope only if its method has an entry, its agent
is listed, and every target is listed: each gitea project, the host, and each command prefix
equal to a listed prefix. host-enroll is never in scope, because it adds a host to the registry.
(Q2)
D3. Writer. A new verb, `grants.scope.set { revision, scope }`, CLI-only for the human: `#human(s)`,
then `#mayHuman`, with the verb added to LIFECYCLE_HUMAN_VERBS. The tester, the reader and every
agent are refused. organization.set's ORGANIZATION_KEYS don't include it, so the chief's tool
never reaches it, the same way instances.set and veto.set stay human-only. The write validates
strictly and refuses a bad value; only the read falls back. Receipt: `grants.scope` with the old
and new sha256. `grants.scope` reads the value for the human, the tester and the chief. (Q3)
D4. In-scope raise. Inside the raise transaction, `#raiseGrant` checks the binding against the scope
and, if it's in scope, closes the new decision at once: op resolved, choice approvalChoice, by
`grant-scope`, via `scope`. It writes a `grant.scope.used` event carrying the scope sha256 and
revision. `#approvedGrant` accepts via 'scope' only when that event exists for the close's seq
and its sha256 matches the scope as read in the same transaction. After the commit, the same
setImmediate starts the same executor, so 236's spend, records and revoke are unchanged. The
decision is closed before any inbox read, so no inbox item ever shows. (Q4)
D5. Out-of-scope raise: unchanged. The decision stays open, routed to the human, for the Console
and CLI Approve.
D6. Executor recheck. At the spend, `#consumeGrant` reruns the scope check for a via-'scope'
approval. If the scope changed between raise and run and the grant is now out of scope, it
fails with `grant.failed scope-changed` and the grant is spent. (Q5)
## Open questions for Sage
Q1. Storage: `launch.grantScope` in the organization file with a lenient reader (D1), or a separate
file under the business directory?
Q2. Shape: is per-method agent, project, host and command lists right? Should host-enroll stay
out of scope always?
Q3. "A scope change needs Approve": is the human's CLI verb itself the Approve (D3)? Or must the
chief also be able to raise a scope change that Jason approves in the inbox (a new decision
action, more code)? I recommend D3 alone. Should Console get a scope page in this row? I
propose no.
Q4. Recording an in-scope grant as a broker close via 'scope' with a scope event (D4), versus a
decision that is never raised (a new table). D4 keeps 236's records and revoke unchanged.
Q5. Scope narrowed between raise and run: fail scope-changed (D6), or honor the raise-time check?
Q6. Chief enrollment ("Jason's Approve stays for the chief of staff's enrollment"): I read this
as the chief's own instance and credentials, which the human's instances.set and the
credential lifecycle already cover. So nothing new is needed in this row. Agree?
## Tests (access by brief; all on scratch stores, fake Gitea, fake host)
T1 agent, chief and tester callers of grants.scope.set are refused, and organization.set with
grantScope is refused. T2 a bad or missing grantScope falls back to empty and the grant waits.
T3 an in-scope gitea request issues with no open decision and no inbox item, and writes the 236
records. T4 the same for a host request. T5 out of scope by agent, by project, or by command
waits for Approve. T6 host-enroll always waits. T7 a scope change by the human's CLI writes a
receipt; a scope change by any other path is refused. T8 an in-scope grant is logged and revoked
with grants.revoke or hosts.revoke. T9 scope narrowed before run gives scope-changed.
## Mutants (brief)
M1 the scope check skipped (every chief raise auto-closes): killed by T5 and T6.
M2 the fallback skipped (a bad value is used as-is or throws): killed by T2.
M3 the `#human` check removed from grants.scope.set: killed by T1.
## Holds
Darkwing holds the slot: no push, no claim, no queue write, no node and no test run until Sage
says so. Q188: no agent creates a grant; tests use scratch stores only.
+1 -3
View File
@@ -1594,6 +1594,4 @@ are never rewritten or removed; corrections are new entries.
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-23T19:13:00Z | Boyd (48a3ae38, Codex gpt-6.1-sol) | Row 298 (#1804, Q228) takeover from Megavolt under mos-1496 | Started on boyd/row298 at 45dca69f6; reading brief, D1-D6 and human-shell proof; awaiting Megavolt handoff. No Node/tests before Sage CLEAR, no queue writes.
2026-10-23T19:29:00Z | Boyd (48a3ae38) | Row 298 (#1804) targeted T1 lane B | Five whole files 29/0/0, ten mutants killed, source restored; reviewer delta prepared. Earlier startup timestamp corrected in Boyd BUILD.md. Full gate and queue/landing remain Sage's.
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.
-2
View File
@@ -226,8 +226,6 @@ an operator inspect content and delivery evidence after an uncertain result.
| `hosts.remove` | `{host}`; the human or the tester. Deletes the record, writes `grant.host.removed` and removes the installer key. A `host_grants` record for the host is `host-in-use`, a guarded event naming a host line left on the host `host-line-left`. |
| `hosts.revoke` | `{agent,host,reason}`; the human, the business's assistant, or the tester when `bus.tester` is exactly `true` (row 235, #1741, r6 section 6). Anyone else gets `revoke-refused`. The tester check reads `bus.tester` again at the call, `tester-dev-only`. One transaction deletes the `host_grants` record and writes `grant.revoked` with `hostLine:"pending"`. With no await between, the key leaves memory and its file goes, and a `host.run` or executor in flight aborts. Then `remove <user> <tag>` runs on the host and `grant.revoke.closed` records `hostLine:"removed"` or `hostLineLeft`. A revoke with no record still runs, with `granted:false`. Returns `{agent,host,tag,granted,keyFile,hostLine or hostLineLeft}`. A restart with a pending marker runs the remove once the bus is up; `hosts.status` lists its tag under `revoking`. |
| `hosts.sweep` | `{host}`; the human or the tester (r6 X3). Runs `list <user>` and removes each listed tag this business recorded for the host in a `grant.step` or `grant.revoked` that no live grant holds. Another business's line and an unrecorded `mosaic-` line stay. Writes `grant.host.swept {host,removed,left}`; `hostLineLeft` then counts from it. Returns `{host,removed,hostLineLeft}`. |
| `grants.scope` | `{}`; the proven human CLI only (row 298, #1804, Q228). Returns the organization revision, effective scope and fallback flag. `launch.grantScope` missing or invalid covers nothing. |
| `grants.scope.set` | `{revision,scope}`; the proven human CLI only, itself Approve for the scope change. Agents, testers, readers and Console caps refuse. Strict scope shape; optimistic organization revision, durable organization receipt, and `grant.scope` with human proof and old/new scope digests. General organization setters cannot write this setting. Scope matches per-method agent and every project, or agent/host and exact command arrays; host enrollment is excluded. In-scope requests by `launch.by` close via `scope` with protected `grant.scope.used` evidence, and run the existing grant executor without per-grant Approve. The raise returns the closed decision view to the chief; this shortcut sends no separate decision-close message. Outside scope waits as before. Scope narrowing before spend fails `scope-changed`; every issued grant keeps its records and revoke path. Empty default ships; the live value is Jason's to set. |
| `grants.status` | `{service:"gitea"}`; the human, the tester or the chief (row 236, #1742, #436b r5). The admin slot's url, account and rotate-by date with `rotationDue`, `adminTokenLeft`, the `gitea_users` and `gitea_grants` records, approved decisions not yet run (`spentAtNextStart`), grants `running` or `settlePending`, agents held as `tokenUnknown`, the `serviceLeft` entries a sweep retries, and revokes still `revoking`. No token. |
| `grants.run` | `{decision}`; the human only. Starts the Gitea executor of a `credential.mint` decision with a gitea grant the human said yes to, as the yes itself does (X1). Step 0's refusals come back with no record: `not-authorized`, `decision-consumed`, `grant-token-unknown`, `grant-busy`, `gitea-not-configured`. Returns `{decision,agent,started:true}`; the outcome is a `grant.applied` or `grant.failed` and a notice. |
| `grants.revoke` | `{service:"gitea",agent,reason}`; the human, the business's assistant, or the tester when `bus.tester` is exactly `true`. The token leaves memory and its file goes, an executor in flight aborts, then one transaction deletes the `gitea_grants` record and writes `grant.revoked` with the token ids to delete. The deletes run with the admin token after C1 to C4, and `grant.revoke.closed` records `serviceLeft`, a list. `credentials.revoke AGENT gitea` and `credentials.delete` do the same for a stack-made user; delete also deletes every token of the user and sets `prohibit_login` (fire). |
+141 -133
View File
@@ -4,7 +4,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../business/src/vocabulary.mjs';
import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -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 = []) {
@@ -290,9 +301,6 @@ export function writeGrantEvent(store, at, business, body, subject = null) {
);
return eid;
}
// Row 298 (#1804, Q228): the by of a close the chief's grant scope makes, with via 'scope'. No session
// closes as it, and #approvedGrant takes it only with the grant.scope.used record of that close.
const SCOPE_BY = 'grant-scope';
export const HOSTS_VERBS = Object.freeze(['hosts.status', 'hosts.check', 'hosts.remove', 'hosts.revoke', 'hosts.sweep']);
// Row 236 (#1742, #436b r5): the Gitea grant path's verbs. run and admin.* are the human's alone.
export const GRANTS_VERBS = Object.freeze([
@@ -392,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 +428,6 @@ export class Broker {
#revokeUndo = [];
#testerOn;
#roleFile;
#grantScope;
// 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;
@@ -443,12 +455,11 @@ 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, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = 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');
if (testerOn !== null && typeof testerOn !== 'function') throw new TypeError('testerOn must be a function');
if (grantScope !== null && typeof grantScope !== 'function') throw new TypeError('grantScope must be a function');
if (roleFile !== null && typeof roleFile !== 'function') throw new TypeError('roleFile must be a function');
if (access !== null && typeof access !== 'function') throw new TypeError('access must be a function');
if (accessStamp !== null && typeof accessStamp !== 'function') throw new TypeError('accessStamp must be a function');
@@ -486,9 +497,6 @@ export class Broker {
// sets bus.tester to exactly true; the bus runtime reads it live, so a hand edit counts at the next
// tester lifecycle call and at the take. Without it the broker's own copy decides.
this.#testerOn = testerOn ?? ((business) => this.#business(business).vars?.['bus.tester'] === true);
// Row 298 (#1804, Q228): grantScope(business) reads {value, revision} atomically from the
// organization file. Without a configured reader no scope can authorize a grant.
this.#grantScope = grantScope ?? (() => ({ value: undefined, revision: null }));
// Row 194 (#1692, D2.3): roleFile(name) loads role definition `name` from the roles directory as it
// stands, for the hire checks. Without it a hire refuses not-configured.
this.#roleFile = roleFile;
@@ -1411,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;
@@ -1493,6 +1504,7 @@ export class Broker {
}
disconnect(cap) {
this.#sessions.delete(cap);
this.#pages.delete(cap);
}
identity(cap) {
const s = this.#session(cap);
@@ -1535,78 +1547,12 @@ export class Broker {
const raise = this.#raised(business, decision);
if (d?.action !== 'credential.mint' || d.route_to !== 'human' || !raise?.grant) return null;
const r = this.#closed(decision);
if (r?.op !== 'resolved' || r.choice !== raise.approvalChoice) return null;
// Row 298 (#1804, Q228, sage-3594 Q4): a scope close counts only with its own grant.scope.used,
// which only #scopeClose writes, under the grant actor.
if (r.via === 'scope') return r.by === SCOPE_BY && this.#scopeUsed(business, decision, r.seq, raise.grant) ? raise.grant : null;
if (r.by !== this.#business(business).human) return null;
if (r?.op !== 'resolved' || r.choice !== raise.approvalChoice || r.by !== this.#business(business).human) return null;
// Row 252 (#1753, design section 3, J14): a console close counts only with its own human.input,
// which only the broker writes (recordEvent reserves the kind).
if (!(r.via === 'console' ? this.#consoleInput(business, decision, r) : r.via === 'cli')) return null;
return raise.grant;
}
// Row 298: the grant.scope.used record of scope close seq. Gitea and host grants only.
#scopeUsed(business, decision, seq, grant) {
if (grant.method !== 'gitea' && grant.method !== 'host') return false;
const row = this.#store.get(
`SELECT body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.action')='grant.scope.used'
AND json_extract(body,'$.grantDecision')=? AND json_extract(body,'$.decisionSeq')=? AND json_extract(body,'$.method')=? LIMIT 1`,
business, decision, seq, grant.method,
);
if (!row) return false;
const evidence = JSON.parse(row.body);
const checked = grantScopeOf(evidence.scope);
// Verify the immutable raise-time snapshot, not today's scope: scope changes do not
// invalidate already issued grants. The executor rechecks today's scope before spend.
return !checked.fallback && typeof evidence.revision === 'string' && /^[a-f0-9]{64}$/.test(evidence.revision) &&
evidence.agent === grant.agent && evidence.scopeSha256 === sha256(JSON.stringify(checked.scope)) &&
grantInScope(checked.scope, grant);
}
// Row 298: the live organization snapshot. Missing/bad scope covers nothing. An unreadable
// organization file or missing revision cannot authorize a scope close either.
#scopeNow(business) {
try {
const snapshot = this.#grantScope(business);
return { ...grantScopeOf(snapshot?.value), revision: snapshot?.revision ?? null };
} catch {
return { ...grantScopeOf(undefined), revision: null };
}
}
// Row 298 (D4, sage-3594 Q2/Q4): after a gitea or host raise, in its transaction. When the raiser is
// launch.by, the decision went to the human and the grant is inside the scope, the broker closes it
// with the approval choice, via 'scope', writes grant.scope.used and starts the executor, as the
// human's Approve does. The spend, the records and the revoke are row 236's and 235's, unchanged.
// Anything else is left in the inbox. launch.by is a hand edit, so a chief-of-staff instance the
// tester or an instances.set adds never raises in scope (Q6).
#scopeClose(s, view) {
if (s.role !== this.#business(s.business).launch?.by) return view;
const d = this.#store.get('SELECT id,route_to FROM decisions WHERE business=? AND id=?', s.business, view.id);
if (d?.route_to !== 'human' || this.#closed(d.id)) return view;
const raise = this.#raised(s.business, d.id);
const { scope, revision } = this.#scopeNow(s.business);
if (typeof revision !== 'string' || !/^[a-f0-9]{64}$/.test(revision)) return view;
if (!raise?.grant || !grantInScope(scope, raise.grant)) return view;
const seq = Number(
this.#store.run(
'INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
d.id, this.#now(), 'resolved', SCOPE_BY, raise.approvalChoice, null, 'scope',
).lastInsertRowid,
);
this.#grantEvent(s.business, {
action: 'grant.scope.used', grantDecision: d.id, decisionSeq: seq, method: raise.grant.method,
agent: raise.grant.agent, scope, revision, scopeSha256: sha256(JSON.stringify(scope)),
}, d.id);
// As the resolve's: each executor rechecks all of this itself, so a rollback starts nothing.
if (raise.grant.method === 'host') setImmediate(() => this.#runHostGrant(s.business, d.id));
if (raise.grant.method === 'gitea') setImmediate(() => this.#startGitea(s.business, d.id));
return this.#decisionView(this.#decision(s, d.id));
}
// Row 298 (D6, sage-3594 Q5): at the spend, a scope close needs its grant still inside the scope.
// A narrowed scope ends it scope-changed, and the human's Approve is the way back.
#scopeRecheck(business, decision, g) {
if (this.#closed(decision)?.via === 'scope' && !grantInScope(this.#scopeNow(business).scope, g)) fail('scope-changed');
}
// Row 252: the console answer's human.input for close row r. Same decision, same seq, same choice,
// no role or run, and a three-string owner subject.
#consoleInput(business, decision, r) {
@@ -1839,7 +1785,6 @@ export class Broker {
}
// r6 X2 step 1's checks after the spend, R4's binding again. Throws the code that ends the grant.
#hostGrantRecheck(business, decision, g) {
this.#scopeRecheck(business, decision, g);
if (!this.#hostGrant || !this.#hostKeys) fail('host-not-configured');
const definition = this.#hostDefinition(business, g.agent);
if (this.#hostGrant.definitionSha256(definition) !== g.definitionSha256) fail('decision-mismatch');
@@ -2640,7 +2585,7 @@ export class Broker {
if (!this.#giteaGrant) fail('gitea-not-configured');
const g = this.#giteaGrant.giteaGrantCheck(a.grant, this.#now().slice(0, 10));
return this.#store.transaction(() => {
const result = this.#scopeClose(s, this.#dispatch(s, 'decision.raise', a, null, this.#giteaGrantBinding(s.business, g)));
const result = this.#dispatch(s, 'decision.raise', a, null, this.#giteaGrantBinding(s.business, g));
this.#secretCheck(result);
return result;
});
@@ -2649,7 +2594,7 @@ export class Broker {
if (a.grant?.method === 'host') {
const g = this.#hostGrant.hostGrantCheck(a.grant, this.#now().slice(0, 10));
return this.#store.transaction(() => {
const result = this.#scopeClose(s, this.#dispatch(s, 'decision.raise', a, null, this.#hostGrantBinding(s.business, g)));
const result = this.#dispatch(s, 'decision.raise', a, null, this.#hostGrantBinding(s.business, g));
this.#secretCheck(result);
return result;
});
@@ -2771,7 +2716,6 @@ export class Broker {
}
// Section 4 step 1's checks after the spend. Throws the code that ends the grant.
#giteaRecheck(business, decision, g) {
this.#scopeRecheck(business, decision, g);
const { b, role, definition } = this.#giteaRole(business, g.agent);
if (this.#giteaGrant.definitionSha256(definition) !== g.definitionSha256) fail('decision-mismatch');
if (this.#giteaGrant.identitySha256(role, b.projects) !== g.identitySha256) fail('decision-mismatch');
@@ -2922,22 +2866,10 @@ export class Broker {
} catch {}
return code;
}
// Step 1, one transaction: spend and recheck. scope-changed ends spent (D6);
// other refusals retain the existing rollback behavior.
// Step 1, one transaction: the spend and the rechecks. A refusal rolls the spend back.
let checked;
try {
checked = this.#store.transaction(() => {
const consumed = this.#consumeGrant(business, decision, 'gitea');
try {
return this.#giteaRecheck(business, decision, consumed);
} catch (e) {
// D6: scope narrowing is a terminal, spent failure. Other recheck
// refusals retain row 236's transaction/rollback behavior.
if (!(e instanceof BusError) || e.code !== 'scope-changed') throw e;
this.#giteaFailed(business, decision, agent, e.code);
return { scopeChanged: true };
}
});
checked = this.#store.transaction(() => this.#giteaRecheck(business, decision, this.#consumeGrant(business, decision, 'gitea')));
} catch (e) {
const code = e instanceof BusError ? e.code : 'gitea-failed';
if (code !== 'not-authorized' && code !== 'decision-consumed') {
@@ -2947,7 +2879,6 @@ export class Broker {
}
return code;
}
if (checked.scopeChanged) return 'scope-changed';
return await this.#giteaSteps(business, decision, g, signal, log, api, meta, checked, stop);
} finally {
this.#giteaKeys.release(business, agent, signal);
@@ -4755,12 +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 (request.verb === 'grants.scope' || request.verb === 'grants.scope.set')
return this.#grantScopeVerb(s, request.verb, structuredClone(args));
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set"].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.
@@ -4806,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
@@ -4857,34 +4801,6 @@ export class Broker {
throw this.#refused(s, e, request);
}
}
// Row 298 (#1804, Q228, sage-3594 Q3): the chief's grant scope, read and written by the human at the
// cli alone, and the write is the human's Approve of the change. #human is the one check, so an
// agent, the chief, the tester and a reader refuse human-required (the Console's cap never gets
// here). bindHuman binds only the business's human. The writer is the organization writer's
// grantScope; ORGANIZATION_KEYS doesn't name grantScope, so organization.set and limits.set can't
// write it. Outside the store transaction, as the organization branch.
#grantScopeVerb(s, verb, a) {
this.#human(s);
if (!this.#organization) fail('not-configured');
if (verb === 'grants.scope') keys(a, []);
else keys(a, ['revision', 'scope'], ['revision', 'scope']);
let result;
try {
result = this.#organization(s.business, verb, a, s.human);
} catch (e) {
this.#organizationFail(e, verb);
}
this.#secretCheck(result);
// The change's record: the human proof bindHuman checked, and the old and new scopes' sha256.
// The writer's changes.jsonl entry holds the values.
if (verb === 'grants.scope.set')
this.#store.transaction(() => this.#grantEvent(s.business, {
action: 'grant.scope', by: s.human, via: s.via, outsideAgent: s.outsideAgent === true,
oldSha256: sha256(JSON.stringify(result.old)), newSha256: sha256(JSON.stringify(result.scope)),
transaction: result.transaction ?? null,
}));
return result;
}
// Row 199 (#1697, design section 6). The token of an issue comes in args from the human's stdin; the
// secret check above has refused one the bus already holds, a refusal records its code alone, and
// nothing returned carries it. Outside the store transaction, as the organization branch.
@@ -5257,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 });
@@ -5271,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) {
@@ -5354,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
@@ -5480,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(
@@ -5504,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, []);
@@ -5868,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 &&
![
@@ -5914,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)
@@ -6533,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'));
+10 -12
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';
@@ -305,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 }),
@@ -323,8 +330,6 @@ export async function startBroker({
if(verb==='instances')return instances.read(id,actor);
// Row 65 (mos-0840): the Console's routing table.
if(verb==='routes')return settings.routes(id);
// Row 298 (#1804, Q228): the chief's grant scope as the file stands, the fallback shown.
if(verb==='grants.scope')return instances.grantScopeRead(id);
// Row 194 (#1692, V5): a delete's checks at the verb, with no write and no reload.
if(verb==='instances.check')return instances.checkDelete(id,args.instance,actor);
// Row 194 b6 (webby-0094 B1): a fire's checks against the file, before step A.
@@ -336,8 +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);
// Row 298: the human's grant scope, through the instance writer.
else if(verb==='grants.scope.set')result=instances.grantScope(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});
@@ -369,13 +374,6 @@ export async function startBroker({
return false;
}
},
// Row 298 (#1804, Q228): one read binds launch.grantScope to the organization revision.
// A missing organization reader or unreadable file cannot authorize an automatic grant.
grantScope: (id) => {
if (!settings) return { value: undefined, revision: null };
const { document, revision } = instances.writer.read(id);
return { value: document.launch?.grantScope, revision };
},
// Row 194 (#1692, D4.1, B8): bus.tester as the file sets it now; an unreadable file is off.
testerOn: (id) => {
try {
+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');
});
@@ -1,40 +0,0 @@
// Row 298: the real runtime routes the human scope verb to the durable
// organization writer. Everything lives under a scratch config/data root.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
// Break caught: missing runtime setter routing or losing the organization receipt.
test('row 298 runtime: human scope approval writes the file and organization history at the supplied revision', async t => {
const root = mkdtempSync(join(tmpdir(), 'scope-runtime-'));
let runtime;
t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); });
const rolesDir = rolesCopy(root);
const file = writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
mkdirSync(join(root, 'data'), { mode: 0o700 });
runtime = await startBroker({ dataRoot: join(root, 'data'),
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
organization: { dir: root, rolesDir, system: systemFor(root) },
});
const human = runtime.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
const before = call('grants.scope');
assert.deepEqual(before.scope, {});
const scope = { gitea: { agents: ['coder'], projects: ['stack'] } };
const saved = call('grants.scope.set', { revision: before.revision, scope });
assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')).launch.grantScope, scope);
assert.deepEqual(call('grants.scope').scope, scope);
assert.notEqual(saved.revision, before.revision);
const entries = readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.equal(entries.at(-1).state, 'committed');
assert.equal(entries.at(-1).actor, 'jason');
assert.equal(entries.at(-1).changes[0].path, 'launch.grantScope');
const bytes = readFileSync(file, 'utf8');
assert.throws(() => call('grants.scope.set', { revision: before.revision, scope: {} }), e => e.code === 'revision-conflict');
assert.equal(readFileSync(file, 'utf8'), bytes);
});
-359
View File
@@ -1,359 +0,0 @@
// Row 298 (#1804, Q228): the chief of staff's grant scope at the broker. The human's grants.scope and
// grants.scope.set (sage-3594 Q3), the in-scope close via 'scope' with grant.scope.used (Q4), the
// spend recheck's scope-changed (Q5), and the out-of-scope raise that still waits for the human.
// Gitea grants run against the fake Gitea in fake-gitea-api.mjs on 127.0.0.1; host grants stop at the
// spend, as no host key store is configured, and nothing connects to a host. Every token is a fake the
// fake made and none is printed. The organization writer is a fake here; the real one is
// packages/business/tests/grant-scope.test.mjs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker, GRANT_EVIDENCE, writeGrantEvent } from '../src/broker.mjs';
import { Credentials } from '../src/credentials.mjs';
import { giteaGrant, giteaKeys } from '../src/gitea-grant.mjs';
import { hostGrant, grantKeys, fingerprint } from '../src/host-grant.mjs';
import { Store } from '../src/store.mjs';
import { checkGrantScope, grantScopeOf } from '../../business/src/vocabulary.mjs';
import { fakeGiteaApi } from './fake-gitea-api.mjs';
import { hostRecord, grantDecision } from './host-fixtures.mjs';
const USER = 'demo-coder';
const ENTRY = { address: '192.0.2.10', port: 22, user: 'jarvis', hostKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHostKeyForTests'] };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const sha256 = (x) => createHash('sha256').update(x).digest('hex');
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
const DEFINITIONS = {
'chief-of-staff': { roleVersion: 2, credentials: [] },
pm: { roleVersion: 2, credentials: [] },
coder: { roleVersion: 2, credentials: [{ service: 'gitea' }] },
ops: { roleVersion: 2, credentials: [{ service: 'host' }] },
};
const GITEA = { gitea: { agents: ['coder'], projects: ['app'] } };
const HOST = { host: { agents: ['ops'], hosts: ['web1'], commands: [['systemctl', 'status', 'nginx']] } };
const REVISION = 'a'.repeat(64);
async function until(fn, ms = 10000) {
const end = Date.now() + ms;
while (!fn()) {
if (Date.now() > end) throw new Error('timed out');
await sleep(20);
}
}
const rejects = (p, code) => assert.rejects(Promise.resolve().then(p), (e) => (assert.equal(e.code, code), true));
// opts.scope: the value grantScope answers, or a function called at each read.
async function setup(t, opts = {}) {
const dir = mkdtempSync(join(tmpdir(), 'g298-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const gitea = await fakeGiteaApi(t, join(dir, 'tls'));
gitea.addUser('acme');
gitea.addRepo('acme', 'app');
gitea.addRepo('acme', 'lib');
const configDir = join(dir, 'cfg');
mkdirSync(configDir, { mode: 0o700 });
const storeRoot = join(configDir, 'secrets', 'agents');
mkdirSync(join(dir, 'data'), { mode: 0o700 });
const store = new Store(join(dir, 'data'));
const credentials = new Credentials({ references: {}, storeRoot, dataRoot: join(dir, 'data') });
t.after(() => {
credentials.close();
store.close();
});
const business = {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'pm' },
roles: {
chief: { definition: 'chief-of-staff', ...none },
// A second chief-of-staff instance, as an instances.set or the tester could add. It isn't launch.by.
chief2: { definition: 'chief-of-staff', ...none },
pm: { definition: 'pm', ...none },
coder: { definition: 'coder', ...none, identity: { name: 'Coder', email: '[email protected]', gitea: { url: gitea.url, user: USER } }, publish: ['app', 'lib'] },
ops: { definition: 'ops', ...none },
},
definitions: structuredClone(DEFINITIONS),
projects: Object.fromEntries(['app', 'lib'].map((p) => [p, { remote: `${gitea.url}/acme/${p}` }])),
launch: { by: 'chief', instances: ['pm', 'coder', 'ops'] },
vars: { 'bus.tester': true },
};
const grantApi = giteaGrant({ configDir, ca: gitea.ca, timeoutMs: 5000 });
grantApi.writeAdmin('demo', gitea.adminToken, { url: gitea.url, account: 'mosaic-grant', rotateBy: dayFrom(60), setAt: '2026-10-21T00:00:00.000Z' });
// The fake organization writer: the scope in a box, checked as the real writer checks it.
const box = { value: opts.scope };
const orgCalls = [];
const organization = (id, verb, args, actor) => {
orgCalls.push([verb, actor]);
if (verb === 'grants.scope') return { business: id, revision: REVISION, ...grantScopeOf(box.value) };
if (verb === 'grants.scope.set') {
const scope = checkGrantScope(args.scope);
if (scope === null) throw Object.assign(new Error('scope is not a grant scope'), { code: 'invalid-request' });
const old = grantScopeOf(box.value).scope;
box.value = scope;
return { business: id, scope, old, transaction: 'tx-298' };
}
return { revision: REVISION };
};
// Keep the real key generation/store/records; replace only remote host calls.
const hostLines = new Set();
const hostApi = {
...hostGrant({ configDir, runRoot: join(configDir, 'secrets', 'run') }),
keyscan: async () => ENTRY.hostKeys,
install: async (_business, _host, _record, tag) => { hostLines.add(tag); },
verify: async () => {},
remove: async (_business, _host, _record, tag) => { hostLines.delete(tag); },
};
const broker = new Broker({
store,
businesses: { demo: business },
secretCheck: (value) => credentials.assertClean(value),
giteaGrant: grantApi,
giteaKeys: giteaKeys({ credentials, storeRoot }),
hostGrant: hostApi,
hostKeys: opts.hostKeys ? grantKeys({ credentials, storeRoot }) : null,
organization,
grantScope: () => ({ value: typeof box.value === 'function' ? box.value() : box.value, revision: Object.hasOwn(opts, 'revision') ? opts.revision : REVISION }),
});
const caps = {};
for (const role of ['chief', 'chief2', 'pm', 'ops']) {
caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
broker.request(caps[role], { verb: 'role.claim' });
}
caps.human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
caps.tester = broker.bindTester({ business: 'demo' });
caps.reader = broker.bindReader({ business: 'demo' });
caps.console = broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'p', iss: 'https://idp.example.test', sub: 's' } });
hostRecord(store, { host: 'web1', entry: ENTRY });
const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args });
const raiseGitea = (over = {}, who = 'chief') =>
call(who, 'decision.raise', {
action: 'credential.mint', question: 'Grant coder Gitea?', options, recommendation: 'yes', blocking: false,
grant: { method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30), ...over },
});
const raiseHost = (over = {}, who = 'chief') =>
call(who, 'decision.raise', {
action: 'credential.mint', question: 'Grant ops web1?', options, recommendation: 'yes', blocking: false,
grant: { method: 'host', agent: 'ops', host: 'web1', commands: [['systemctl', 'status', 'nginx']], rotateBy: dayFrom(30), ...over },
});
const grantEvents = () =>
store.all(`SELECT body FROM events WHERE business='demo' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((e) => JSON.parse(e.body));
const ended = (decision) => grantEvents().findLast((e) => e.grantDecision === decision && ['grant.applied', 'grant.failed'].includes(e.action));
const closes = (decision) => store.all('SELECT * FROM decision_events WHERE decision=? ORDER BY seq', decision);
const spent = (decision) => grantEvents().filter((e) => e.decision === decision && e.operation?.startsWith('grant.')).map((e) => e.operation);
const record = () => store.get("SELECT * FROM gitea_grants WHERE business='demo' AND agent='coder'");
return { store, broker, caps, box, orgCalls, call, raiseGitea, raiseHost, grantEvents, ended, closes, spent, record, gitea, hostLines };
}
test('row 298 (Q3): only the human at the cli reads or sets the scope; agents, the tester, a reader and the Console refuse', async (t) => {
const f = await setup(t);
for (const verb of ['grants.scope', 'grants.scope.set']) {
const args = verb === 'grants.scope' ? {} : { revision: REVISION, scope: GITEA };
for (const who of ['chief', 'chief2', 'pm', 'tester']) await rejects(() => f.call(who, verb, args), 'human-required');
await rejects(() => f.call('reader', verb, args), 'read-only');
await rejects(() => f.call('console', verb, args), 'human-required');
}
assert.deepEqual(f.orgCalls, []);
assert.equal(f.box.value, undefined);
assert.deepEqual(f.grantEvents().filter((e) => e.action === 'grant.scope'), []);
});
test('row 298 (Q1): organization.set and limits.set refuse a grantScope key, and nothing is written', async (t) => {
const f = await setup(t);
await rejects(() => f.call('human', 'limits.set', { revision: REVISION, grantScope: GITEA }), 'invalid-request');
await rejects(() => f.call('chief', 'organization.set', { revision: REVISION, grantScope: GITEA }), 'invalid-request');
assert.deepEqual(f.orgCalls, []);
assert.equal(f.box.value, undefined);
});
test('row 298 (Q3): the human verb writes the scope and a grant.scope record with the human proof and both sha256', async (t) => {
const f = await setup(t);
assert.deepEqual(await f.call('human', 'grants.scope'), { business: 'demo', revision: REVISION, scope: {}, fallback: false });
const r = await f.call('human', 'grants.scope.set', { revision: REVISION, scope: GITEA });
assert.deepEqual(r.scope, GITEA);
assert.deepEqual(f.orgCalls, [['grants.scope', 'jason'], ['grants.scope.set', 'jason']]);
const changes = f.grantEvents().filter((e) => e.action === 'grant.scope');
assert.deepEqual(changes, [{
action: 'grant.scope', by: 'jason', via: 'cli', outsideAgent: true,
oldSha256: sha256(JSON.stringify({})), newSha256: sha256(JSON.stringify(GITEA)), transaction: 'tx-298',
}]);
await rejects(() => f.call('human', 'grants.scope.set', { revision: REVISION, scope: { gitea: {} } }), 'invalid-request');
await rejects(() => f.call('human', 'grants.scope.set', { revision: REVISION }), 'invalid-request');
await rejects(() => f.call('human', 'grants.scope.set', { revision: REVISION, scope: {}, extra: 1 }), 'invalid-request');
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.scope').length, 1);
});
test('row 298 (Q4): an in-scope Gitea grant is issued with no inbox item, logged with 236 records, and revocable', async (t) => {
const f = await setup(t, { scope: GITEA });
const d = await f.raiseGitea();
const [close] = f.closes(d.id);
assert.deepEqual([close.op, close.by, close.choice, close.via], ['resolved', 'grant-scope', 'yes', 'scope']);
assert.ok(f.grantEvents().some(e => e.action === 'grant.scope.used' && e.grantDecision === d.id), 'scope close must have protected evidence');
const inbox = await f.call('human', 'inbox');
assert.ok(!JSON.stringify(inbox).includes(d.id), 'the decision is not in the human inbox');
await until(() => f.ended(d.id));
await f.broker.giteaIdle();
assert.equal(f.ended(d.id).action, 'grant.applied');
const used = f.grantEvents().find((e) => e.action === 'grant.scope.used');
assert.deepEqual(used, {
action: 'grant.scope.used', grantDecision: d.id, decisionSeq: close.seq, method: 'gitea', agent: 'coder', scope: GITEA, revision: REVISION, scopeSha256: sha256(JSON.stringify(GITEA)),
});
assert.deepEqual(f.spent(d.id), ['grant.gitea']);
assert.equal(f.record().decision, d.id);
// The spend is row 236's: a second start finds it consumed.
assert.equal(await f.broker.runGiteaGrant('demo', d.id), 'decision-consumed');
const out = await f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'test' });
assert.deepEqual(out.serviceLeft, []);
assert.ok(f.grantEvents().some((e) => e.action === 'grant.revoked' && e.agent === 'coder'));
assert.equal(f.record(), undefined);
});
test('row 298: an out-of-scope Gitea grant waits for the human, whose Approve still issues it', async (t) => {
const f = await setup(t, { scope: GITEA });
const d = await f.raiseGitea({ target: { projects: ['app', 'lib'] } });
assert.deepEqual(f.closes(d.id), []);
assert.ok(JSON.stringify(await f.call('human', 'inbox')).includes(d.id));
assert.deepEqual(f.grantEvents().filter((e) => e.action === 'grant.scope.used'), []);
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
await until(() => f.ended(d.id));
await f.broker.giteaIdle();
assert.equal(f.ended(d.id).action, 'grant.applied');
});
test('row 298: with no scope, a bad scope or an unreadable file, an in-scope-looking grant waits', async (t) => {
const bad = { gitea: { agents: ['coder'], projects: ['app'], note: 'not a key' } };
for (const scope of [undefined, bad, () => { throw new Error('unreadable'); }]) {
const f = await setup(t, { scope });
const d = await f.raiseGitea();
assert.deepEqual(f.closes(d.id), [], String(scope));
assert.deepEqual(f.grantEvents().filter((e) => e.action === 'grant.scope.used'), []);
}
});
test('row 298 (Q6): a chief-of-staff instance that is not launch.by never raises in scope', async (t) => {
const f = await setup(t, { scope: GITEA });
const d = await f.raiseGitea({}, 'chief2');
assert.deepEqual(f.closes(d.id), []);
});
test('row 298 (Q5): a scope narrowed between the raise and the spend ends the grant scope-changed', async (t) => {
let reads = 0;
const f = await setup(t, { scope: () => (reads++ === 0 ? GITEA : {}) });
const d = await f.raiseGitea();
assert.equal(f.closes(d.id)[0].via, 'scope');
await until(() => f.ended(d.id));
await f.broker.giteaIdle();
assert.deepEqual([f.ended(d.id).action, f.ended(d.id).code], ['grant.failed', 'scope-changed']);
assert.equal(f.record(), undefined);
assert.deepEqual(f.gitea.tokens(USER) ?? [], []);
assert.deepEqual(f.spent(d.id), ['grant.gitea']);
assert.equal(await f.broker.runGiteaGrant('demo', d.id), 'decision-consumed');
});
test('row 298 (Q2): an in-scope host grant is spent with no Approve; ls never covers lsblk, nor a scope command extra arguments', async (t) => {
const scope = { host: { agents: ['ops'], hosts: ['web1'], commands: [['ls'], ['systemctl', 'status', 'nginx']] } };
const f = await setup(t, { scope });
const d = await f.raiseHost();
assert.equal(f.closes(d.id)[0].via, 'scope');
await until(() => f.ended(d.id));
// The spend is row 235's. No host key store is configured, so the recheck after it ends here.
assert.deepEqual(f.spent(d.id), ['grant.host']);
assert.deepEqual([f.ended(d.id).action, f.ended(d.id).code], ['grant.failed', 'host-not-configured']);
for (const commands of [[['lsblk']], [['ls', '-l']], [['systemctl', 'restart']]]) {
const w = await f.raiseHost({ commands });
assert.deepEqual(f.closes(w.id), [], JSON.stringify(commands));
}
});
test('row 298 (Q5): a host scope narrowed before the spend ends scope-changed', async (t) => {
let reads = 0;
const f = await setup(t, { scope: () => (reads++ === 0 ? HOST : {}) });
const d = await f.raiseHost();
assert.equal(f.closes(d.id)[0].via, 'scope');
await until(() => f.ended(d.id));
assert.deepEqual([f.ended(d.id).action, f.ended(d.id).code], ['grant.failed', 'scope-changed']);
assert.deepEqual(f.spent(d.id), ['grant.host']);
});
// Break caught: accepting scope closes with no evidence or unbound evidence.
test('row 298 Q4: missing scope evidence or a bad scope digest cannot authorize execution', async (t) => {
const f = await setup(t, { scope: GITEA });
const good = { scope: GITEA, scopeSha256: sha256(JSON.stringify(GITEA)), revision: REVISION };
for (const evidence of [
null,
{ ...good, scopeSha256: '0'.repeat(64) },
{ ...good, revision: null },
{ ...good, scope: { gitea: 'all' } },
{ ...good, agent: 'other' },
{ ...good, method: 'host' },
{ ...good, decisionSeq: 999999 },
]) {
const grant = { method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30) };
const id = grantDecision(f.store, { grant, close: { op: 'resolved', by: 'grant-scope', choice: 'yes', via: 'scope' } });
if (evidence) writeGrantEvent(f.store, new Date().toISOString(), 'demo', {
action: 'grant.scope.used', grantDecision: id, decisionSeq: f.closes(id)[0].seq,
method: 'gitea', agent: 'coder', ...evidence,
}, id);
assert.equal(await f.broker.runGiteaGrant('demo', id), 'not-authorized');
assert.deepEqual(f.spent(id), []);
assert.equal(f.record(), undefined);
}
// Positive control: the same fixture with valid scope evidence reaches the
// next grant check. Its intentionally absent definition binding refuses there.
const id = grantDecision(f.store, {
grant: { method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30) },
close: { op: 'resolved', by: 'grant-scope', choice: 'yes', via: 'scope' },
});
writeGrantEvent(f.store, new Date().toISOString(), 'demo', {
action: 'grant.scope.used', grantDecision: id, decisionSeq: f.closes(id)[0].seq,
method: 'gitea', agent: 'coder', ...good,
}, id);
assert.equal(await f.broker.runGiteaGrant('demo', id), 'decision-mismatch');
assert.deepEqual(f.spent(id), []);
assert.equal(f.record(), undefined);
});
// Break caught: host grants bypassing records or losing the ordinary revoke path.
test('row 298 T4/T8: an in-scope host grant issues, records and revokes through the existing executor', async (t) => {
const f = await setup(t, { scope: HOST, hostKeys: true });
const d = await f.raiseHost();
assert.equal(f.closes(d.id)[0].via, 'scope');
assert.ok(!JSON.stringify(await f.call('human', 'inbox')).includes(d.id));
await until(() => f.ended(d.id));
await f.broker.grantsIdle();
assert.equal(f.ended(d.id).action, 'grant.applied');
assert.equal(f.store.get("SELECT decision FROM host_grants WHERE agent='ops'").decision, d.id);
assert.equal(f.hostLines.size, 1);
await f.call('human', 'grants.scope.set', { revision: REVISION, scope: {} });
// Historical approval still passes #hostAccess after scope is narrowed;
// only the absent command executor refuses this call, before any sudo decision.
await rejects(() => f.broker.requestHost(f.caps.ops,
{ verb: 'host.run', args: { host: 'web1', argv: ['systemctl', 'status', 'nginx'] } }, null), 'host-not-configured');
await f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'test' });
assert.equal(f.store.get("SELECT decision FROM host_grants WHERE agent='ops'"), undefined);
assert.equal(f.hostLines.size, 0);
assert.ok(f.grantEvents().some(e => e.action === 'grant.revoked' && e.agent === 'ops'));
});
// Break caught: a new host enrollment entering the scope shortcut.
test('row 298 T6: host enrollment stays open for human Approve even with both methods in scope', async (t) => {
const f = await setup(t, { scope: { ...GITEA, ...HOST } });
const d = await f.call('chief', 'decision.raise', {
action: 'credential.mint', question: 'Enroll host?', options, recommendation: 'yes', blocking: false,
grant: { method: 'host-enroll', host: 'web2', address: ENTRY.address, port: ENTRY.port,
user: ENTRY.user, sudo: true, fingerprints: ENTRY.hostKeys.map(fingerprint) },
});
assert.deepEqual(f.closes(d.id), []);
assert.ok(JSON.stringify(await f.call('human', 'inbox')).includes(d.id));
assert.deepEqual(f.grantEvents().filter(e => e.action === 'grant.scope.used'), []);
});
// Break caught: using a scope without a verifiable organization revision.
test('row 298: scope without an organization revision never closes automatically', async (t) => {
const f = await setup(t, { scope: GITEA, revision: null });
const d = await f.raiseGitea();
assert.deepEqual(f.closes(d.id), []);
assert.deepEqual(f.spent(d.id), []);
});
+1 -1
View File
@@ -33,7 +33,7 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
// FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it.
// Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission.
"import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../business/src/vocabulary.mjs';",
"import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';",
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
+2 -5
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.
@@ -175,7 +175,7 @@ function checkLaunch(launch, roles, definitions, file) {
const where = `${file} launch`;
requireObject(launch, where);
if (Object.hasOwn(launch,"max")) refuse(`${where}.max needs migration: mosaic limits migrate --file FILE --models FILE`);
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account", "keep", "noHire", "grantScope"], where);
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account", "keep", "noHire"], where);
requireId(launch.by, `${where}.by`);
if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`);
const launcher = definitions[roles[launch.by].definition];
@@ -212,9 +212,6 @@ function checkLaunch(launch, roles, definitions, file) {
return {
by: launch.by, instances, models, limits, threadIdleSeconds, ...(account ? { account } : {}),
...(keep ? { keep } : {}), ...(noHire ? { noHire } : {}),
// Row 298 (#1804, Q228): the chief's grant scope passes through unchecked. A bad value must not
// refuse the whole file; the broker's reader (grantScopeOf) falls back to the empty scope instead.
...(launch.grantScope !== undefined ? { grantScope: structuredClone(launch.grantScope) } : {}),
};
}
+2 -29
View File
@@ -14,12 +14,11 @@ import { resolveInstance } from "./resolve.mjs";
import { loadRole } from "./role.mjs";
import { refuse } from "./errors.mjs";
import { isPlainObject, canonicalJson } from "./util.mjs";
import { CREATIVE_DEFINITIONS, END_PROTECTED, HOST_SERVICE, ID_PATTERN, checkGrantScope, grantScopeOf } from "./vocabulary.mjs";
import { CREATIVE_DEFINITIONS, END_PROTECTED, HOST_SERVICE, ID_PATTERN } from "./vocabulary.mjs";
const SET_KEYS = ["revision", "instance", "op", "definition", "vars", "launch", "tracker"];
const VETO_KEYS = ["revision", "op", "name"];
const VETO_OPS = ["keep", "unkeep", "nohire", "hire"];
const SCOPE_KEYS = ["revision", "scope"];
const id = value => typeof value === "string" && ID_PATTERN.test(value);
const revisionOk = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
// Row 194 (D2.2, N3): the vars a hire may set, the ones the chief can already set on an instance.
@@ -303,32 +302,6 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
return commit(business, old, next, { revision: args.revision, actor });
}
// Row 298 (#1804, Q228): the chief's grant scope as the file stands. scope is the one the broker
// applies, so a bad value reads as {} with fallback true.
function grantScopeRead(business) {
const { document, revision } = writer.read(business);
if (!document.launch) throw new OrganizationError("organization-launch-not-configured");
return { business, revision, ...grantScopeOf(document.launch.grantScope) };
}
// Row 298: args { revision, scope }. Human-only at the broker (grants.scope.set), and the only
// writer of launch.grantScope: SET_KEYS and the organization.set keys don't name it. The scope is
// checked strictly here; {} removes the key. old is what the broker applied before the write,
// the fallback included.
function grantScope(business, args, actor) {
if (!isPlainObject(args) || Object.keys(args).some(key => !SCOPE_KEYS.includes(key))) throw new OrganizationError("invalid-request");
if (!revisionOk(args.revision)) throw new OrganizationError("invalid-request");
const scope = checkGrantScope(args.scope);
if (scope === null) throw new OrganizationError("invalid-request", "scope is not a grant scope");
const old = writer.read(business).document;
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
const next = structuredClone(old);
if (Object.keys(scope).length === 0) delete next.launch.grantScope;
else next.launch.grantScope = scope;
const before = grantScopeOf(old.launch.grantScope).scope;
return { ...commit(business, old, next, { revision: args.revision, actor }), scope, old: before };
}
// Row 194 (#1692, D2.2): the chief's hire, after the broker's identity, grant, allowlist and
// hireable checks. args { name, definition, vars? }. A new name is added and listed. A fired name
// (declared, not listed) is listed again only with its own definition and no vars. The writer
@@ -446,5 +419,5 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
return { ...read(business, actor), transaction: receipt.transaction, restart: false };
}
return { writer, read, set, checkDelete, checkFire, veto, hire, fire, grantScope, grantScopeRead };
return { writer, read, set, checkDelete, checkFire, veto, hire, fire };
}
+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)) };
}
-70
View File
@@ -299,73 +299,3 @@ export function covers(grant, { business, project, instance }, now, { level } =
if (!LEVELS.includes(grant.level) || LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;
return true;
}
// Row 298 (#1804, Q228): the chief of staff's grant scope, launch.grantScope in the organization
// file. Only the human writes it (grants.scope.set). A Gitea or host grant the enrolled chief raises
// inside it is issued with no per-grant Approve; anything else waits for the human as before. The
// broker and the writer read these, and they live here because the session sandbox mounts this file
// (seat/src/sandbox.mjs RUNNER_FILES) and the runner loads broker.mjs.
//
// Shape: { gitea?: { agents, projects }, host?: { agents, hosts, commands } }.
// Each list is distinct and nonempty. A commands entry covers a requested prefix equal to it,
// token for token and with equal length. No entry matches part of a token, so
// ["ls"] never covers ["lsblk"]. Enrollment (host-enroll) is never in scope. {} is the empty scope.
export const GRANT_SCOPE_EMPTY = Object.freeze({});
const SCOPE_IDS = (value) =>
Array.isArray(value) && value.length > 0 && value.length <= 256 &&
value.every((x) => typeof x === "string" && ID_PATTERN.test(x)) && new Set(value).size === value.length;
const SCOPE_ARGVS = (value) =>
Array.isArray(value) && value.length > 0 && value.length <= 256 && value.every(validArgv) &&
new Set(value.map((argv) => JSON.stringify(argv))).size === value.length;
const SCOPE_PLAIN = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
// The scope, rebuilt in a fixed key order so JSON.stringify of it is stable, or null when the value
// isn't one. The writer refuses null; the readers fall back (grantScopeOf).
export function checkGrantScope(value) {
if (!SCOPE_PLAIN(value) || Object.keys(value).some((k) => k !== "gitea" && k !== "host")) return null;
const out = {};
if (value.gitea !== undefined) {
const g = value.gitea;
if (!SCOPE_PLAIN(g) || Object.keys(g).some((k) => k !== "agents" && k !== "projects")) return null;
if (!SCOPE_IDS(g.agents) || !SCOPE_IDS(g.projects)) return null;
out.gitea = { agents: [...g.agents], projects: [...g.projects] };
}
if (value.host !== undefined) {
const h = value.host;
if (!SCOPE_PLAIN(h) || Object.keys(h).some((k) => !["agents", "hosts", "commands"].includes(k))) return null;
if (!SCOPE_IDS(h.agents) || !Array.isArray(h.hosts) || !h.hosts.every((x) => typeof x === "string" && HOST_ID.test(x)) || !SCOPE_IDS(h.hosts)) return null;
if (!SCOPE_ARGVS(h.commands)) return null;
out.host = {
agents: [...h.agents], hosts: [...h.hosts],
commands: h.commands.map((argv) => [...argv]),
};
}
return out;
}
// What the readers use: a missing key is the empty scope, and a bad value falls back to it, with
// fallback true. The empty scope puts every grant in front of the human, so a bad value fails closed.
export function grantScopeOf(value) {
if (value === undefined) return { scope: GRANT_SCOPE_EMPTY, fallback: false };
const scope = checkGrantScope(value);
return scope === null ? { scope: GRANT_SCOPE_EMPTY, fallback: true } : { scope, fallback: false };
}
// Whether a raise's grant body is inside a checked scope. Only gitea and host bodies can be.
export function grantInScope(scope, body) {
if (body?.method === "gitea") {
const g = scope.gitea;
return Boolean(g) && g.agents.includes(body.agent) &&
Array.isArray(body.target?.projects) && body.target.projects.length > 0 &&
body.target.projects.every((p) => g.projects.includes(p));
}
if (body?.method === "host") {
const h = scope.host;
const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
return Boolean(h) && h.agents.includes(body.agent) && h.hosts.includes(body.host) &&
Array.isArray(body.commands) && body.commands.length > 0 &&
body.commands.every((argv) =>
h.commands.some((c) => same(argv, c)));
}
return false;
}
@@ -1,124 +0,0 @@
// Row 298 (#1804, Q228): the chief of staff's grant scope, launch.grantScope. The shape check, the
// readers' fallback, the match rules (sage-3594 Q2) and the one writer. The broker's verbs, the
// in-scope close and the executors are bus tests (packages/bus/tests/grant-scope.test.mjs).
import test, { after } from "node:test";
import assert from "node:assert/strict";
import { readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { instanceSettings } from "../src/instance-settings.mjs";
import { launchSettings } from "../src/launch-settings.mjs";
import { loadBusiness } from "../src/business.mjs";
import { checkGrantScope, grantInScope, grantScopeOf } from "../src/vocabulary.mjs";
import { businessDoc, rolesCopy, systemFor, tmp, writeJson } from "./helpers.mjs";
const roots = [];
after(() => roots.forEach(root => rmSync(root, { recursive: true, force: true })));
const GITEA = { gitea: { agents: ["coder"], projects: ["app", "lib"] } };
const HOST = { host: { agents: ["ops"], hosts: ["web1"], commands: [["systemctl", "status", "nginx"], ["ls"]] } };
const gitea = (over = {}) => ({ method: "gitea", agent: "coder", target: { projects: ["app"] }, ...over });
const host = (commands, over = {}) => ({ method: "host", agent: "ops", host: "web1", commands, ...over });
function fixture(mutate) {
const root = tmp();
roots.push(root);
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, "acme");
mutate?.(doc);
const dir = join(root, "config");
const file = writeJson(join(dir, "businesses", "acme.json"), doc);
const settings = instanceSettings({ dir, rolesDir, system: systemFor(root) });
const limits = launchSettings({ dir, rolesDir, system: systemFor(root) });
const onDisk = () => JSON.parse(readFileSync(file, "utf8"));
const log = () => readFileSync(settings.writer.logFile("acme"), "utf8").trim().split("\n").map(line => JSON.parse(line));
const revision = () => settings.read("acme").revision;
return { root, dir, rolesDir, file, settings, limits, onDisk, log, revision };
}
test("row 298: checkGrantScope takes the D2 shape and refuses anything else", () => {
assert.deepEqual(checkGrantScope({}), {});
assert.deepEqual(checkGrantScope(GITEA), GITEA);
assert.deepEqual(checkGrantScope(HOST), HOST);
assert.deepEqual(checkGrantScope({ host: { agents: ["ops"], hosts: ["web1"], commands: [["uptime"]] } }).host.commands, [["uptime"]]);
for (const bad of [
null, [], "x", { other: {} }, { gitea: [] }, { gitea: { agents: ["coder"] } }, { gitea: { agents: [], projects: ["app"] } },
{ gitea: { agents: ["coder", "coder"], projects: ["app"] } }, { gitea: { agents: ["Coder"], projects: ["app"] } },
{ gitea: { agents: ["coder"], projects: ["app"], extra: 1 } }, { host: { agents: ["ops"], hosts: ["web1"] } },
{ host: { agents: ["ops"], hosts: ["web_1"], commands: [["ls"]] } }, { host: { agents: ["ops"], hosts: ["web1"], commands: [[]] } },
{ host: { agents: ["ops"], hosts: ["web1"], commands: [["ls"], ["ls"]] } }, { host: { agents: ["ops"], hosts: ["web1"], commands: [["bin/ls"]] } },
{ host: { agents: ["ops"], hosts: ["web1"], commandPrefixes: [["ls"]] } }, { "host-enroll": {} },
])
assert.equal(checkGrantScope(bad), null, JSON.stringify(bad));
});
test("row 298: a missing scope is empty and a bad one falls back to empty, which covers nothing", () => {
assert.deepEqual(grantScopeOf(undefined), { scope: {}, fallback: false });
assert.deepEqual(grantScopeOf(GITEA), { scope: GITEA, fallback: false });
const bad = { gitea: { agents: ["coder"], projects: ["app"], extra: true } };
assert.deepEqual(grantScopeOf(bad), { scope: {}, fallback: true });
assert.equal(grantInScope(grantScopeOf(bad).scope, gitea()), false);
assert.equal(grantInScope({}, gitea()), false);
assert.equal(grantInScope({}, host([["ls"]])), false);
});
test("row 298: a Gitea grant is in scope only when its agent and every project are listed", () => {
assert.equal(grantInScope(GITEA, gitea()), true);
assert.equal(grantInScope(GITEA, gitea({ target: { projects: ["app", "lib"] } })), true);
assert.equal(grantInScope(GITEA, gitea({ target: { projects: ["app", "old"] } })), false);
assert.equal(grantInScope(GITEA, gitea({ agent: "research" })), false);
assert.equal(grantInScope(GITEA, gitea({ target: { projects: [] } })), false);
assert.equal(grantInScope(HOST, gitea()), false);
});
test("row 298 (sage-3594 Q2): host commands match whole argv tokens, and enrollment is never in scope", () => {
assert.equal(grantInScope(HOST, host([["systemctl", "status", "nginx"]])), true);
// A commands entry covers only itself: no extra trailing arguments, no shorter prefix.
assert.equal(grantInScope(HOST, host([["systemctl", "status", "nginx", "--now"]])), false);
assert.equal(grantInScope(HOST, host([["systemctl", "status"]])), false);
// No scope entry permits extending the requested command prefix.
assert.equal(grantInScope(HOST, host([["ls"]])), true);
assert.equal(grantInScope(HOST, host([["ls", "-l", "/var"]])), false);
assert.equal(grantInScope(HOST, host([["lsblk"]])), false);
assert.equal(grantInScope(HOST, host([["ls"], ["lsblk"]])), false);
assert.equal(grantInScope(HOST, host([["ls"]], { host: "web2" })), false);
assert.equal(grantInScope(HOST, host([["ls"]], { agent: "coder" })), false);
assert.equal(grantInScope(HOST, { method: "host-enroll", host: "web1" }), false);
assert.equal(grantInScope({ ...HOST, "host-enroll": true }, { method: "host-enroll", host: "web1" }), false);
});
test("row 298: a bad grantScope in the file still loads, and the read shows the fallback", () => {
const f = fixture(doc => { doc.launch.grantScope = { gitea: "everything" }; });
const business = loadBusiness("acme", { dir: f.dir, rolesDir: f.rolesDir });
assert.deepEqual(business.launch.grantScope, { gitea: "everything" });
assert.deepEqual(f.settings.grantScopeRead("acme"), { business: "acme", revision: f.revision(), scope: {}, fallback: true });
});
test("row 298: the writer checks the scope, logs the change and removes the key for {}", () => {
const f = fixture();
assert.deepEqual(f.settings.grantScopeRead("acme").scope, {});
const r = f.settings.grantScope("acme", { revision: f.revision(), scope: GITEA }, "jason");
assert.deepEqual(r.scope, GITEA);
assert.deepEqual(r.old, {});
assert.ok(typeof r.transaction === "string" && r.transaction.length > 0);
assert.deepEqual(f.onDisk().launch.grantScope, GITEA);
assert.equal(f.log().at(-1).actor, "jason");
const back = f.settings.grantScope("acme", { revision: f.revision(), scope: {} }, "jason");
assert.deepEqual(back.old, GITEA);
assert.equal(Object.hasOwn(f.onDisk().launch, "grantScope"), false);
const bytes = readFileSync(f.file, "utf8");
assert.throws(() => f.settings.grantScope("acme", { revision: f.revision(), scope: { gitea: {} } }, "jason"), e => e.code === "invalid-request");
assert.throws(() => f.settings.grantScope("acme", { revision: f.revision(), scope: GITEA, extra: 1 }, "jason"), e => e.code === "invalid-request");
assert.throws(() => f.settings.grantScope("acme", { revision: "0".repeat(64), scope: GITEA }, "jason"), e => e.code === "revision-conflict");
assert.throws(() => f.settings.grantScope("acme", { revision: f.revision(), scope: {} }, "jason"), e => e.code === "organization-no-change");
assert.equal(readFileSync(f.file, "utf8"), bytes);
});
test("row 298 (sage-3594 Q1): the general writers can't write grantScope", () => {
const f = fixture();
const bytes = readFileSync(f.file, "utf8");
assert.throws(() => f.limits.set("acme", { revision: f.revision(), grantScope: GITEA }, "jason"));
assert.throws(() => f.limits.set("acme", { revision: f.revision(), grantScope: GITEA }, "chief-of-staff.chief"));
assert.throws(() => f.settings.set("acme", { revision: f.revision(), instance: "coder", op: "change", grantScope: GITEA }, "jason"), e => e.code === "invalid-request");
assert.throws(() => f.settings.veto("acme", { revision: f.revision(), op: "keep", name: "coder", grantScope: GITEA }, "jason"), e => e.code === "invalid-request");
assert.equal(readFileSync(f.file, "utf8"), bytes);
});
@@ -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 });
});
+6 -31
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`.
@@ -1080,37 +1086,6 @@ reads the admin token on stdin, never argv; `admin rotate gitea --rotate-by DATE
gitea --finish` and `admin remove gitea` keep the slot. run and admin are the human's alone. Nothing
prints a token. A usage error exits 4 and a bus refusal 2.
`mosaic grants scope --business ID` reads the chief of staff's effective scope,
its organization revision, and whether a bad value fell back to empty (Q228,
row 298). `mosaic grants scope set --business ID --revision REV` reads scope JSON
from stdin. This human-shell command is Approve for the scope change; it refuses
agent callers through the same guard and human transport as `mosaic access`.
Neither command accepts `--tester`. No Console page or chief-raised scope-change
request exists. A stale revision refuses; malformed scope input writes nothing.
The setting is `launch.grantScope` in the business organization file. It defaults
to `{}`, which covers nothing; a missing or invalid value has the same effect.
Only Jason sets the live value. The supported shapes are:
```json
{
"gitea": { "agents": ["coder"], "projects": ["app"] },
"host": { "agents": ["ops"], "hosts": ["web1"], "commands": [["systemctl", "status", "nginx"]] }
}
```
Either method may be omitted. All lists are distinct and nonempty. Every target
project, agent, host and command must be listed for its method. Command arrays
match token for token with equal lengths: `["ls"]` covers neither `["lsblk"]`
nor `["ls", "-l"]`. Host enrollment always needs per-grant Approve.
An in-scope request from the enrolled chief is closed and issued by the protected
grant executor without an inbox approval; other requests wait for Approve.
All grants retain their records and revoke path. Narrowing the scope before a
grant is spent fails that grant with `scope-changed`; existing issued grants
remain revocable. Setting `{}` removes the setting and restores per-grant Approve.
Scope changes have organization receipts and a `grant.scope` event with the
human proof and old/new digests; each automatic approval has `grant.scope.used`.
## Access (FED R5, #414)
```
+4 -30
View File
@@ -2,20 +2,16 @@
// starts the executor of a decision the human said yes to, and admin set, rotate and remove keep the
// admin slot; those are the human's alone. status is the human's, the tester's or the chief's; revoke
// 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. Row 298 (#1804, Q228):
// scope reads the chief of staff's grant scope and scope set writes it from JSON on stdin; both are
// the human's, and the set is the human's Approve of the change.
// argv, and nothing here prints a token: the answers carry ids and names only.
import { CliError } from "./errors.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 | " +
"scope [--business ID] | scope set --revision REV [--business ID] (the scope JSON on stdin)";
"admin rotate gitea --rotate-by DATE | admin rotate gitea --finish | admin remove gitea";
const MAX_TOKEN = 16384;
const MAX_SCOPE = 65536;
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by", "--revision"];
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by"];
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
@@ -44,21 +40,6 @@ async function readToken(stdin) {
return text;
}
// Row 298: the whole of stdin, one JSON scope. {} is the empty scope.
async function readScope(stdin) {
let text = "";
for await (const chunk of stdin) {
text += chunk;
if (text.length > MAX_SCOPE) throw new CliError("refused: the scope on stdin is too long", 2);
}
if (!text.trim()) throw new CliError("refused: no scope on stdin; pipe the scope JSON, {} for none", 4);
try {
return JSON.parse(text);
} catch {
throw new CliError("refused: the scope on stdin is not JSON", 4);
}
}
// Each shape: the positional count after the verb, the flags and values it takes and needs.
const SHAPES = {
status: { n: 1, flags: ["--json", "--tester"], need: [], may: ["--business"] },
@@ -68,8 +49,6 @@ const SHAPES = {
"admin set": { n: 1, flags: [], need: ["--url", "--account", "--rotate-by"], may: ["--business"] },
"admin rotate": { n: 1, flags: ["--finish"], need: [], may: ["--rotate-by", "--business"] },
"admin remove": { n: 1, flags: [], need: [], may: ["--business"] },
scope: { n: 0, flags: [], need: [], may: ["--business"] },
"scope set": { n: 0, flags: [], need: ["--revision"], may: ["--business"] },
};
const lines = (s) => {
@@ -92,9 +71,6 @@ export async function grantsCommand(args, io, callFor) {
if (verb === "admin") {
verb = `admin ${rest[0] ?? ""}`;
rest = rest.slice(1);
} else if (verb === "scope" && rest[0] === "set") {
verb = "scope set";
rest = rest.slice(1);
}
// Own keys only, so `constructor` is a usage error, not a verb.
const shape = Object.hasOwn(SHAPES, verb ?? "") ? SHAPES[verb] : null;
@@ -103,7 +79,7 @@ export async function grantsCommand(args, io, callFor) {
[...parsed.flags].some((f) => !shape.flags.includes(f)) ||
Object.keys(parsed.values).some((v) => !shape.need.includes(v) && !shape.may.includes(v)) ||
shape.need.some((v) => !Object.hasOwn(parsed.values, v)) ||
(verb !== "run" && verb !== "scope" && verb !== "scope set" && rest.at(-1) !== "gitea") ||
(verb !== "run" && rest.at(-1) !== "gitea") ||
(verb === "admin rotate" && parsed.flags.has("--finish") === Object.hasOwn(parsed.values, "--rotate-by"))
)
throw new CliError(GRANTS_USAGE, 4);
@@ -117,8 +93,6 @@ export async function grantsCommand(args, io, callFor) {
else if (verb === "sweep") request = ["grants.sweep", { service: "gitea" }];
else if (verb === "admin set")
request = ["grants.admin.set", { service: "gitea", url: v["--url"], account: v["--account"], rotateBy: v["--rotate-by"], token: await readToken(io.stdin) }];
else if (verb === "scope") request = ["grants.scope", {}];
else if (verb === "scope set") request = ["grants.scope.set", { revision: v["--revision"], scope: await readScope(io.stdin) }];
else if (verb === "admin rotate")
request = ["grants.admin.rotate", parsed.flags.has("--finish") ? { service: "gitea", finish: true } : { service: "gitea", rotateBy: v["--rotate-by"] }];
else request = ["grants.admin.remove", { service: "gitea" }];
+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)] });
-74
View File
@@ -1,74 +0,0 @@
// Row 298/Q228: the real CLI parser and human-shell refusal. Transport is a
// recorder; no socket, config, credential or live grant is accessed.
import test from "node:test";
import assert from "node:assert/strict";
import { main } from "../src/cli.mjs";
import { humanEnv, io } from "./helpers.mjs";
const AGENT_MARKERS = ["MOSAIC_BUS_CAP", "MOSAIC_RUN_ID", "MOSAIC_AGENT_RUN", "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CODEX_THREAD_ID", "PI_AGENT_DIR"];
const REVISION = "a".repeat(64);
const SCOPE = { gitea: { agents: ["coder"], projects: ["app"] } };
const READ = ["grants", "scope", "--business", "acme"];
const SET = ["grants", "scope", "set", "--business", "acme", "--revision", REVISION];
function setup(env = humanEnv(), stdin = JSON.stringify(SCOPE)) {
const x = io(env);
x.stdin.end(stdin);
const calls = [];
const deps = {
system: { dataRoot: "/nonexistent" },
transport: (business) => async (verb, args) => {
calls.push({ business, verb, args });
return { revision: REVISION, scope: SCOPE, fallback: false };
},
testerTransport: () => async () => { throw new Error("tester transport must never run"); },
};
return { x, calls, deps };
}
// Break caught: dropping scope dispatch or misrouting the writer's revision.
test("row 298 CLI: scope reads and scope set sends JSON and the observed revision", async () => {
const f = setup();
await main(READ, f.x, f.deps);
assert.deepEqual(f.calls, [{ business: "acme", verb: "grants.scope", args: {} }]);
assert.deepEqual(JSON.parse(f.x.out.text), { revision: REVISION, scope: SCOPE, fallback: false });
const w = setup();
await main(SET, w.x, w.deps);
assert.deepEqual(w.calls, [{ business: "acme", verb: "grants.scope.set", args: { revision: REVISION, scope: SCOPE } }]);
const empty = setup(humanEnv(), "{}");
await main(SET, empty.x, empty.deps);
assert.deepEqual(empty.calls[0].args.scope, {});
});
// Break caught: a new grants path bypassing access's refuseInsideAgent guard.
test("row 298 CLI: every access agent marker refuses scope set before transport", async () => {
for (const marker of AGENT_MARKERS) {
const f = setup({ ...humanEnv(), [marker]: "agent" });
await assert.rejects(main(SET, f.x, f.deps), (e) => e.exitCode === 3 && /human shell/.test(e.message), marker);
assert.deepEqual(f.calls, []);
assert.equal(f.x.out.text, "");
}
});
// Break caught: --tester selecting a nonhuman transport for this approval.
test("row 298 CLI: --tester cannot read or change the scope", async () => {
for (const args of [READ, SET]) {
const f = setup();
await assert.rejects(main([...args, "--tester"], f.x, f.deps), (e) => e.exitCode === 4);
assert.deepEqual(f.calls, []);
}
});
// Break caught: a missing revision or invalid stdin being sent as an approval.
test("row 298 CLI: missing revision and empty, invalid or oversized JSON refuse without a call", async () => {
for (const [args, input, code] of [
[SET.slice(0, -2), "{}", 4],
[SET, "", 4],
[SET, " \n", 4],
[SET, "{bad", 4],
[SET, " ".repeat(65537), 2],
]) {
const f = setup(humanEnv(), input);
await assert.rejects(main(args, f.x, f.deps), (e) => e.exitCode === code);
assert.deepEqual(f.calls, []);
}
});
+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 });
});
+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)));