Compare commits

...
30 changed files with 1715 additions and 21 deletions

No files matched your search

+10
View File
@@ -5423,3 +5423,13 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r
2026-10-23 | Gladstone | #1816 / Vikunja 614 F6 and notify parent (mos-1537) | Before: the run sheet's copy check lacks its required CLI wrapper; mkdir -p -m does not guarantee the notify parent mode. After: a separately Mos-gated wrapper --create step follows install and precedes check, as mosaic from cwd /; section 9a explicitly creates both notify directories at 0700. Docs only, no live action; Node/tests still held.
2026-10-23 | Gladstone | Row320 / #1816 first review CHANGES (morgana-0217) | Reviewer run: hostcheck19/19, unit47/47, install24/24, copy17/18; seven of eight mutants killed, M4 survived because both base/mutant failed only cleanup. Fixed this row's F4 test to enter cwd then chmod000, assert no read/search, pass an absolute outside bundle, and restore0700 in finally. Existing tests/setup and production scripts unchanged. Correction untested locally pending Morgana's focused rerun; no acceptance claim.
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 | 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-23T22:41:07Z | Boyd | Row 298 (#1804), rebase after row 239 | Rebased onto 833ec1cc8d7c11842efb82e57c1a0b256c8d22d7 on boyd/row298-r2 without altering published boyd/row298. Only append-log conflicts; upstream bytes preserved and Boyd suffixes appended. Product merges automatic; row 239 writer-lock revoke and post-revoke error handling preserved, organization.mjs unchanged from new base. Static checks only; UNTESTED on new base pending Sage CLEAR. Details in Boyd BUILD.md.
2026-10-23 | Goldie | Row 324 / #1820 before (Q247, mos-1555) | New branch goldie/row324 at row298 approved860afc2b6. Design/readers audit in agents/goldie/work/queue-324/BUILD.md: normalize omitted tracker to null, preserve present validation, guard sync readers/adapter config; runtime/instance-settings/broker checks already optional. Existing business.test.mjs98 explicitly requires tracker, so implementation held for Mos approval of removing that one list entry; no old test or product edit, no tests run.
2026-10-23 | Goldie | Row 324 / #1820 draft built (sage-3836, mos-1556) | Claim rev3759. Omitted tracker normalizes null; present including null/malformed stays validated. Loader, business validate, bootConfig trackerFor and Console sync reader handle absence. Row298's guarded runtime/instance-settings/broker paths preserved. Two new test files (9 planned tests); sole old-test edit drops tracker from required-key list under mos-1556. node --check6/6 and diff check pass. No tests run: untested branch push expressly ordered before CLEAR; Duckworth review and Sage gate pending.
+272
View File
@@ -0,0 +1,272 @@
# 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.
## Rebase after row 239 (sage-3794)
Current branch: `boyd/row298-r2`, rebased from published row 298 tip
`2fc27bce6bfac2debe9703782bf308132c26b393` onto
`833ec1cc8d7c11842efb82e57c1a0b256c8d22d7`, which includes row 239 through
`e0cb1db2c`. Original `boyd/row298` remains intact locally and remotely. A new
Boyd branch carries the rebased candidate so no published history is amended
or force-pushed. This section supersedes the original base/branch header for
current candidate work.
Conflicts: only the first Boyd log append in `BUILD-LOG.md` and
`docs/SESSIONS.md` conflicted while replaying 574ece13c. For each, verified the
old commit's log started with the merge base bytes, then retained the new
base's complete log bytes and appended only Boyd's suffix (434 and 281 bytes
respectively). Later Boyd appends replayed automatically. No prior entries
were removed or rewritten. Five commits replayed; the third/fourth range-diff
entries differ only in append-log context; package-only range-diff is all `=`.
The overlapping product files merged automatically: broker.mjs, runtime.mjs
and instance-settings.mjs had no textual conflicts. Inspected their diff
against the new base: only the existing row 298 scope additions remain.
Preserved row 239's injected writer, fire/delete beforeWrite callback, and
commit() passing beforeWrite into writer.write. There is no pre-lock revoke
call in commit(). `packages/business/src/organization.mjs` is byte-identical
to the new base: beforeWrite still runs inside withLock, after revision and
validation checks; post-revoke errors still carry revoked true. Broker's
row-239 error handling and runtime's lock/revoke wiring remain intact.
Static checks only: package patch range-diff all equal, organization writer
unchanged, diff whitespace clean, refreshed 13-file package manifest verifies.
The only pre-existing row-298 test change remains project-runtime.test.mjs
exactly 1/1 under mos-1510. No test files were changed for this rebase.
UNTESTED on the new base: no Node, tests, mutants, builds or queue writes run.
Earlier 29/0 and M1-M10 results apply to the old base only. Await Sage CLEAR
for whole touched-file reruns; Dewey reviews the new candidate.
Row 321 (#1817) review assignment acknowledged; waiting for Sage's request
and Gladstone's pushed docs-only F7/F10 run-sheet candidate.
@@ -0,0 +1,13 @@
612488b705853e75e6f8952e41552526f4d07dd297d7619d608d434ed75121d6 packages/bus/README.md
63fdf6c048f0994af3551510119eeb294c71f8116702772844214930b827cc56 packages/bus/src/broker.mjs
65425ead353017c99e04f1e0bcc3f85c4ba60054da293badaa0360e681e418dc 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
e3b425f9ad2a0d4e6e0b55bd48827e144015662d95ace4163fe6474e5f98da81 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
@@ -0,0 +1,105 @@
#!/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()
+60
View File
@@ -0,0 +1,60 @@
# Optional business tracker (Q247) implementation plan — row 324 / #1820
Goldie, 2026-10-23. Base 860afc2b66e7bbd045e9d5e25d227ef12f62c55b, approved row298 candidate. New worktree /home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row324, branch goldie/row324. Spec: git object f375f4e92:docs/plans/2026-10-23_optional-tracker-brief.md, section Brief: tracker becomes optional in the business file. Sage-3836 and mos-1555/Q247 authorize optional tracker; Duckworth reviews credential registration.
## Goal and design
A raw business document may omit tracker. Normalize omission to tracker:null, consistent with existing optional launch/role-tracker representations. A present tracker (including null, false, malformed objects) must continue through checkTracker and its existing refusals; distinguish absence with Object.hasOwn rather than a truthiness check. Keep checkTracker unchanged. Required top-level keys no longer include tracker. Only compare role bots with the sync bot when a checked business tracker exists. Role definitions needing vikunja still require their own tracker and credentials unchanged.
CLI validate collects sync credential refs only when tracker is present. bootConfig's trackerFor returns null before interpreting tracker variables when business tracker is absent, so an old tracker.baseUrl variable cannot accidentally create sync/task adapter configuration. Console businessView emits no sync credential rows for absent tracker. Runtime registration, fixed-configuration reload comparison, instance-settings bot collision checks and broker credential display already use optional chaining on the row298 base; preserve those checks. The tasks adapter remains fail-closed for an explicitly inconsistent raw adapter config, and no-tracker bootConfig never configures it for this business.
## Reader audit (before implementation)
Command: git grep -n -E 'tracker\.sync|\.tracker\b' -- 'packages/*/src/*'. Every result is grouped here, with base line numbers:
| Reader | Absent business tracker behavior / action |
| --- | --- |
| business/src/business.mjs:85-96,296-300 | checkTracker remains exact for present input; add omission normalization and guard own-sync-bot comparison. |
| business/src/business.mjs:143-150 | Per-role tracker, independent of business tracker. Keep requirement/collision checks unchanged. |
| business/src/cli.mjs:85 | Guard Object.values sync credential refs; absent contributes zero. |
| business/src/instance-settings.mjs:108,120,126-127,133,223 | Role add/change and bot collision/retirement: old.tracker?.sync already skips absent sync; role trackers unchanged. No edit needed. |
| business/src/launch-settings.mjs:161,235-236 | Per-role tracker assistant view/update, independent of business tracker; no edit. |
| business/src/resolve.mjs:93 | Per-role tracker in resolved output; no edit. |
| bus/src/runtime.mjs:131 | Existing b.tracker?.sync?.credentials check registers no @sync when absent; preserve and exercise with new boot test. |
| bus/src/runtime.mjs:243,247 | Reload compares per-role tracker/null and canonical business sync/null; safe already, no edit. |
| bus/src/broker.mjs:3969-3970 | Optional per-role tracker bot map; independent, no edit. |
| bus/src/broker.mjs:4497 | Optional per-role tracker returned from instance write; independent, no edit. |
| bus/src/broker.mjs:6242 | Credential display resolves optional sync bot; no @sync row exists on trackerless boot. Safe already. |
| cli/src/config.mjs:57-60,137-138 (trackerFor; not caught by literal grep) | Add business-tracker absence guard; do not configure tracker adapter even if tracker.baseUrl remains set. |
| cli/src/instances.mjs:118 | Builds per-role tracker update request only when user supplies bot; unrelated to business tracker. |
| tasks/src/adapter.mjs:25,28-30,35,45,81 | settings rejects missing sync with controlled tracker-config before dereferencing labels/botId; files uses optional chaining. Factory iterates only configured trackers; absent business never configured by bootConfig. Preserve explicit-inconsistent-config refusal. |
| webui/src/reads.mjs:164,166 | Role credentials independent; guard sync credential display at166 so trackerless Console does not throw. |
| webui/src/public/bus.js:785 | Per-role editor tracker patch; independent of business tracker. |
| conversation/src/controller.mjs:443,502,748,807,820,831,888,935,961,997,1008,1021,1048,1056,1268,1308,1330,1334,1413,1447 | Different execution-tracking object/class, unrelated to business/Vikunja config. No change. |
Positive control: grep finds business.mjs:296 and webui reads:166, the unsafe business-tracker readers. Review focus: present null/malformed tracker still refuses; per-role Vikunja trackers still required; stale tracker variables do not enable sync; Console credential view remains usable; no @sync registration during runtime boot/reload.
## Steps and evidence plan
1. Document design and full reader list (this file); claim row324 in-progress as Goldie.
2. Add new tests for absent tracker loader+CLI validate, malformed/present preservation, per-role tracker requirement, stale-variable bootConfig behavior, actual bus boot with empty credential list/no @sync, and Console business view. New test files only unless Mos expressly approves old-test change below.
3. Make the minimal four-source-file change (business loader, business CLI, cli config, webui reads). Existing runtime/instance-settings/broker optional checks require coverage, not gratuitous edits.
4. Static diff checks. Push untested candidate only as expressly ordered by sage-3836, then ask Sage for CLEAR. No tests before CLEAR.
5. On CLEAR, new test files whole, applicable existing whole files/suites, then a mutant removing loader presence check. Restore and check source bytes. Record outputs; full landing gate is Sage's. Duckworth review follows.
## Pre-existing test boundary
Inspection finds packages/business/tests/business.test.mjs:98 loops over [human,arbiters,projects,tracker,roles] and :99 asserts deletion of each refuses. Q247 directly invalidates the tracker iteration. Proposed sole old-test edit: remove just the tracker string from that required-key list, leaving all other iterations/assertions intact, and add explicit tracker-omission coverage in a new test file. This requires Mos OK under mos-1272; stop implementation and report before editing existing tests. No tests have run.
## Implementation draft and old-test ruling
Sage-3840 relays Mos mos-1556: mos-1272 OK to remove only tracker from business.test.mjs's required-key list, preserving the four other keys and the assertion. Done exactly, one addition/one deletion at original line98; no other old test edited. All positive/negative coverage is in two new files: business/tests/optional-tracker.test.mjs and cli/tests/optional-tracker.test.mjs. The latter covers Console businessView as required by sage-3840.
Design now implemented in the four source files identified above; README explains omission versus literal null. New tests cover loader/CLI absence, present value preservation, malformed/present undefined or null refusal, unchanged role-bot requirement, stale business/project tracker variables, actual runtime startup with no credential files or @sync reference, Console view, and continued missing-token refusal for a present tracker. Runtime test uses useFile on unknown @sync to distinguish an unregistered reference from a registered but unavailable credential; callback must never run. Fixture project tracker.project is set, so removing trackerFor's presence guard would actually configure sync instead of passing accidentally through the no-project guard.
No tests run. New node_modules symlink points to canonical checkout dependencies and is ignored; it will not be committed. Sage-3836 expressly requests push before any tests; that specific sequencing overrides the usual green-before-commit default only for this draft push. Status is untested until CLEAR. Claim wrote goldie-0324-start-1 revision3759, briefed→in-progress. Row323 remains paused at the product boundary until324 lands.
Static verification: node --check passed for all four edited source modules and both new test files; git diff --check passed. No node --test or shell suite was run. reader-audit.txt captures the requested grep on the draft. MANIFEST.sha256 pins README, four source files, the one existing test and both new tests. mutant.py prepares the required omission-presence mutant and restores/checks bytes in finally; it has not run.
Proposed CLEAR run order: new business optional-tracker.test.mjs whole (5 tests), new cli optional-tracker.test.mjs whole (4 tests), existing business.test.mjs whole, then the targeted presence mutant and restored new tests. Broaden to business/CLI/Console/bus package gates only when Sage assigns that scope/slot. All test runs use timeout -k 10 and an explicitly allocated TMPDIR under /mnt/storage/scratch/tmp.
@@ -0,0 +1,8 @@
ecd63bcbeae04236c445e74493d425e4a5d1bfb723d72fb58446e1d020e4d429 packages/business/README.md
e76db42158f396ec6a44f97320355d6c2254dccb5fc53cd04e8ac42843b0d60a packages/business/src/business.mjs
afef5c581beb26d0b0797faeb2acd3597597d48c0ce03cf31cce8d9e9b2690b9 packages/business/src/cli.mjs
2ec0c80361f12c0d90669a3c149dfc5c9607fc9dbad98ccd30d24cf14c0de9b6 packages/cli/src/config.mjs
46defeb3b4be0e1a22e4b89445872ad6b15cdee182076e9e6222219ae29997f4 packages/webui/src/reads.mjs
3f99c2d7e41b37f82c3f9edeac3bc795f982dac9a3728f7694cfd5668e944691 packages/business/tests/business.test.mjs
e92a976bcd6bf97c1cf7832d9e3fe4dec350d92809f26f7b43b938bad7e5dc49 packages/business/tests/optional-tracker.test.mjs
7723e299fb9a11cf313d3b26df8c0270b15982731dfc0af2463c96f23e1582a2 packages/cli/tests/optional-tracker.test.mjs
+27
View File
@@ -0,0 +1,27 @@
"""Q247 targeted presence-check mutant; run only in an approved test slot."""
import hashlib
import os
from pathlib import Path
import subprocess
root = Path(__file__).resolve().parents[4]
source = root / 'packages/business/src/business.mjs'
fixed = source.read_bytes()
needle = b'Object.hasOwn(document, "tracker") ? checkTracker(document.tracker, file) : null'
assert fixed.count(needle) == 1
mutant = fixed.replace(needle, b'checkTracker(document.tracker, file)')
try:
source.write_bytes(mutant)
result = subprocess.run([
'timeout', '-k', '10', '90', 'node', '--test', '--test-reporter=tap',
'--test-name-pattern=Q247: omitting tracker',
'packages/business/tests/optional-tracker.test.mjs',
], cwd=root, env=os.environ, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
Path(__file__).with_suffix('.log').write_text(result.stdout)
print(f'presence mutant: exit {result.returncode}', flush=True)
assert result.returncode == 1, result.stdout
assert '# fail 1\n' in result.stdout and 'tracker' in result.stdout, result.stdout
finally:
source.write_bytes(fixed)
assert source.read_bytes() == fixed
print('restored sha256 ' + hashlib.sha256(fixed).hexdigest(), flush=True)
@@ -0,0 +1,62 @@
packages/bus/src/broker.mjs:3969: .filter(([, r]) => r.tracker?.botId !== undefined)
packages/bus/src/broker.mjs:3970: .map(([role, r]) => [r.tracker.botId, role]),
packages/bus/src/broker.mjs:4497: return { transaction: out.transaction, retry: false, ...(out.tracker ? { bot: out.tracker.bot } : {}) };
packages/bus/src/broker.mjs:6242: bot = sync ? b.tracker?.sync?.bot : service === 'vikunja' ? b.roles?.[role]?.tracker?.bot : null;
packages/bus/src/runtime.mjs:131: if (b.tracker?.sync?.credentials) references[business + '/@sync'] = b.tracker.sync.credentials;
packages/bus/src/runtime.mjs:243: tracker: r?.tracker ?? null,
packages/bus/src/runtime.mjs:247: sync: canonicalJson(b.tracker?.sync ?? null),
packages/business/src/business.mjs:89: requireObject(tracker.sync, `${where}.sync`);
packages/business/src/business.mjs:90: rejectUnknownKeys(tracker.sync, ["bot", "botId", "credentials"], `${where}.sync`);
packages/business/src/business.mjs:92: ...checkBot({ bot: tracker.sync.bot, botId: tracker.sync.botId }, `${where}.sync`),
packages/business/src/business.mjs:93: credentials: checkCredentialMap(tracker.sync.credentials, ["vikunja"], `${where}.sync.credentials`),
packages/business/src/business.mjs:143: if (entry.tracker === undefined) refuse(`${at} needs "tracker" with its Vikunja bot, because ${entry.definition} uses vikunja`);
packages/business/src/business.mjs:144: tracker = checkBot(entry.tracker, `${at}.tracker`);
packages/business/src/business.mjs:145: if (bots.has(tracker.bot)) refuse(`${at}.tracker.bot ${tracker.bot} is used by another instance`);
packages/business/src/business.mjs:146: if (botIds.has(tracker.botId)) refuse(`${at}.tracker.botId ${tracker.botId} is used by another instance`);
packages/business/src/business.mjs:149: } else if (entry.tracker !== undefined) {
packages/business/src/business.mjs:296: const tracker = Object.hasOwn(document, "tracker") ? checkTracker(document.tracker, file) : null;
packages/business/src/business.mjs:298: if (tracker && role.tracker && (role.tracker.bot === tracker.sync.bot || role.tracker.botId === tracker.sync.botId)) {
packages/business/src/business.mjs:299: refuse(`${file} tracker.sync must use its own bot, not the one roles.${instance} uses`);
packages/business/src/cli.mjs:85: ...Object.values(business.tracker?.sync?.credentials ?? {}),
packages/business/src/instance-settings.mjs:108:// needs a bus restart, then row 199's issue. A vikunja definition needs args.tracker, an existing
packages/business/src/instance-settings.mjs:120: tracker = checkBot(args.tracker, "tracker");
packages/business/src/instance-settings.mjs:126: const taken = Object.entries(old.roles).filter(([name, entry]) => name !== instance && entry.tracker).map(([name, entry]) => [name, entry.tracker]);
packages/business/src/instance-settings.mjs:127: if (old.tracker?.sync) taken.push(["tracker.sync", old.tracker.sync]);
packages/business/src/instance-settings.mjs:133: out.tracker = tracker;
packages/business/src/instance-settings.mjs:223: const bot = old.roles[instance]?.tracker?.bot;
packages/business/src/launch-settings.mjs:161: definition: entry.definition, vars: entry.vars ?? {}, tracker: entry.tracker ?? null,
packages/business/src/launch-settings.mjs:235: if (entry.tracker === null) delete next.roles[instance].tracker;
packages/business/src/launch-settings.mjs:236: else if (Object.hasOwn(entry, "tracker")) next.roles[instance].tracker = entry.tracker;
packages/business/src/resolve.mjs:93: tracker: entry.tracker,
packages/cli/src/config.mjs:58: if (!b.tracker) return null;
packages/cli/src/instances.mjs:118: if (bot) args.tracker = { bot: values["--bot"], botId: Number(values["--bot-id"]) };
packages/conversation/src/controller.mjs:443: exec.tracker = new Tracker({
packages/conversation/src/controller.mjs:502: if (exec.link.poisoned || exec.tracker.overlapped || this.closers.has("uncertain")) return;
packages/conversation/src/controller.mjs:748: return { fence: true, closer, fenceIdx: this.exec?.tracker.idx ?? 0 };
packages/conversation/src/controller.mjs:807: seam: { replay: "unavailable", streamEpoch: exec?.streamEpoch ?? null, fromSequence: (exec?.seq ?? 0) + 1, reconcile: true, quiet: !exec?.tracker.current && !exec?.tracker.slot },
packages/conversation/src/controller.mjs:820: if (exec?.tracker.slot) return refused(BUSY);
packages/conversation/src/controller.mjs:831: exec.tracker.attach(slot);
packages/conversation/src/controller.mjs:888: const tr = slot.exec.tracker;
packages/conversation/src/controller.mjs:935: const tr = exec.tracker;
packages/conversation/src/controller.mjs:961: const exec = slot.exec, tr = exec.tracker;
packages/conversation/src/controller.mjs:997: this.#settleFailure(exec.tracker.slot, RUN_OVERLAP);
packages/conversation/src/controller.mjs:1008: this.evidence.gaps.push({ kind, detail, idx: exec.tracker.idx });
packages/conversation/src/controller.mjs:1021: const tr = exec.tracker;
packages/conversation/src/controller.mjs:1048: const tr = exec.tracker;
packages/conversation/src/controller.mjs:1056: const tr = exec.tracker, s = tr.slot;
packages/conversation/src/controller.mjs:1268: const exec = this.exec, tr = exec?.tracker;
packages/conversation/src/controller.mjs:1308: const tr = exec.tracker;
packages/conversation/src/controller.mjs:1330: exec.tracker.clearWindow = Math.max(0, exec.tracker.clearWindow - 1);
packages/conversation/src/controller.mjs:1334: const { exec, stop } = ctx, tr = exec.tracker;
packages/conversation/src/controller.mjs:1413: const { exec } = ctx, tr = exec.tracker;
packages/conversation/src/controller.mjs:1447: const { exec, stop } = ctx, tr = exec.tracker;
packages/tasks/src/adapter.mjs:25: if (!positive(b.tracker?.sync?.botId)) throw new BusError('tracker-config');
packages/tasks/src/adapter.mjs:28: if (r.tracker) {
packages/tasks/src/adapter.mjs:29: if (!positive(r.tracker.botId) || typeof r.definition !== 'string') throw new BusError('tracker-config');
packages/tasks/src/adapter.mjs:30: roles[role] = { definition: r.definition, botId: r.tracker.botId };
packages/tasks/src/adapter.mjs:35: for (const [title, id] of Object.entries(b.tracker.labels ?? {})) {
packages/tasks/src/adapter.mjs:45: if (b.tracker?.sync?.credentials?.vikunja?.file) out['@sync'] = b.tracker.sync.credentials.vikunja.file;
packages/tasks/src/adapter.mjs:81: bots: new Set([businesses[name].tracker.sync.botId, ...Object.values(s.roles).map((r) => r.botId)]),
packages/webui/src/public/bus.js:785: if(f==='tracker'&&text.trim()===''){patch.tracker=null;continue;}
packages/webui/src/reads.mjs:164: for (const [service, ref] of Object.entries(r.credentials)) credentials.push(credential(service, ref, service === 'vikunja' ? r.tracker?.bot ?? null : null, instance, now));
packages/webui/src/reads.mjs:166: for (const [service, ref] of Object.entries(b.tracker?.sync?.credentials ?? {})) credentials.push(credential(service, ref, b.tracker.sync.bot, 'tracker sync', now));
+92
View File
@@ -0,0 +1,92 @@
# 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.
+8
View File
@@ -1597,3 +1597,11 @@ are never rewritten or removed; corrections are new entries.
2026-10-23 | Gladstone (433058fc-9cea-46e3-8554-6ddefb312762, Codex gpt-6.1-sol) | #1816 / Vikunja 614, mos-1531 | Built hostcheck octal-mode fix and two bare-scratch bundle verifies on gladstone/hostcheck-item5 from 93694c518. Three append-only test blocks; Node/tests held by Sage-3738, untested push authorized sage-3740. Queue number pending; Morgana static review, Sage gate/landing.
2026-10-23T21:58:38Z | Sage (1ef1e4f8) | Rows 220, 216, 320 landings and queue-commit (mos-1528, mos-1532/1534/1536/1537) | 220+216 joint gate GREEN 21:05:34Z on fbe57a05b, pushed with Filbert's SESSIONS line 582b86935; #1721 and #1717 closed. Row 320 (#1816, Gladstone F1-F6): faf55618e replayed onto 45e3ca7bd as 094124b4e (code patch-id equal, log appends in one final commit), Morgana APPROVE r2 c31403, full gate GREEN 21:53:00Z 0 fail, pushed 21:53Z, done rev 3717, #1816 closed c31413, Vikunja 614 done. queue-commit 604523b20 holds revs 3678-3717. Mos sent N=094124b4e to ops-01 (mos-1540).
2026-10-23T22:06Z | Duckworth (Opus 5.5) | row 239 (#1745) round 1 on r4 e0cb1db2c (honker-0206, rev 3719) | APPROVE (static): r4 range-diff all =, 16 paths byte-equal to r3, e0cb1db2c BUILD.md only, 4 channel-proof pins match; record waits for Sage's comment id and gap (duckworth-0295) |
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-23T22:41:07Z | Boyd (48a3ae38) | Row 298 rebase under sage-3794; row 321 review assigned | Rebased candidate on boyd/row298-r2 at base833ec1cc8; append-only log conflicts resolved preserving upstream, writer-lock revoke retained; no Node/tests before CLEAR. Row321 awaits pushed candidate/review request.
2026-10-23T23:15Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row 324 / #1820 optional tracker design | New goldie/row324 worktree from approved860afc2b6, pinned brief f375f4e92, complete reader audit/plan. Existing required-key test contradicts Q247; requesting Mos exception before implementation. Tests held until CLEAR; row323 remains deferred until324 lands.
2026-10-23T23:18Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row 324 / #1820 untested draft | Claim3759; optional tracker implementation, new loader/CLI/boot/Console tests, complete reader audit and prepared mutant. Mos-1556 permits sole existing required-key-list change. Syntax/diff checks pass; no tests before CLEAR. Untested push authorized sage-3836.
+2
View File
@@ -226,6 +226,8 @@ 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). |
+126 -7
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 } 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, grantScopeOf, grantInScope } from '../../business/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -290,6 +290,9 @@ 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([
@@ -412,6 +415,7 @@ 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;
@@ -439,11 +443,12 @@ export class Broker {
#clock = 0;
#processing;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null, giteaGrant = null, giteaKeys = null, 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, grantScope = 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');
@@ -481,6 +486,9 @@ 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;
@@ -1527,12 +1535,78 @@ 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 || r.by !== this.#business(business).human) return null;
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;
// 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) {
@@ -1765,6 +1839,7 @@ 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');
@@ -2567,7 +2642,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.#dispatch(s, 'decision.raise', a, null, this.#giteaGrantBinding(s.business, g));
const result = this.#scopeClose(s, this.#dispatch(s, 'decision.raise', a, null, this.#giteaGrantBinding(s.business, g)));
this.#secretCheck(result);
return result;
});
@@ -2576,7 +2651,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.#dispatch(s, 'decision.raise', a, null, this.#hostGrantBinding(s.business, g));
const result = this.#scopeClose(s, this.#dispatch(s, 'decision.raise', a, null, this.#hostGrantBinding(s.business, g)));
this.#secretCheck(result);
return result;
});
@@ -2698,6 +2773,7 @@ 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');
@@ -2848,10 +2924,22 @@ export class Broker {
} catch {}
return code;
}
// Step 1, one transaction: the spend and the rechecks. A refusal rolls the spend back.
// Step 1, one transaction: spend and recheck. scope-changed ends spent (D6);
// other refusals retain the existing rollback behavior.
let checked;
try {
checked = this.#store.transaction(() => this.#giteaRecheck(business, decision, this.#consumeGrant(business, decision, 'gitea')));
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 };
}
});
} catch (e) {
const code = e instanceof BusError ? e.code : 'gitea-failed';
if (code !== 'not-authorized' && code !== 'decision-consumed') {
@@ -2861,6 +2949,7 @@ 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);
@@ -4671,6 +4760,8 @@ export class Broker {
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
return this.#chiefLifecycle(s, request.verb, structuredClone(args));
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)) {
// 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
@@ -4768,6 +4859,34 @@ 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.
+11
View File
@@ -323,6 +323,8 @@ 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.
@@ -334,6 +336,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);
// 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});
@@ -365,6 +369,13 @@ 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 {
@@ -0,0 +1,40 @@
// 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
@@ -0,0 +1,359 @@
// 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 } 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, grantScopeOf, grantInScope } from '../../business/src/vocabulary.mjs';",
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
+7
View File
@@ -81,6 +81,13 @@ by group or other. The loader checks the owner and mode on the descriptor
it reads from, so the file can't be swapped between the check and the
read.
The business-level `tracker` is optional (Q247). Omit it for a business with
no tracker sync: validate and bus boot need no sync credential, and the
Console shows no sync credential entry. The loader represents omission as
`tracker: null`; writing a literal `null` in the business file still refuses.
A present tracker keeps its existing bot, credential and validation rules.
Tracker variables alone do not enable sync when the tracker block is absent.
Each role instance names a `definition` (a version 2 role file), an
optional `holder`, its Vikunja bot (`tracker`, required when the definition
uses Vikunja), a credential reference for each service the definition
+7 -4
View File
@@ -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"], where);
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account", "keep", "noHire", "grantScope"], 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,6 +212,9 @@ 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) } : {}),
};
}
@@ -233,7 +236,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
requireId(document.id, "business id");
const base = file.split("/").pop().replace(/\.json$/, "");
if (document.id !== base) refuse(`business "id" (${document.id}) must match its filename (${base}.json)`);
for (const key of ["human", "arbiters", "projects", "tracker", "roles"]) {
for (const key of ["human", "arbiters", "projects", "roles"]) {
if (document[key] === undefined) refuse(`business file requires "${key}" (${file})`);
}
const human = requireId(document.human, "business human");
@@ -290,9 +293,9 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
}
}
const tracker = checkTracker(document.tracker, file);
const tracker = Object.hasOwn(document, "tracker") ? checkTracker(document.tracker, file) : null;
for (const [instance, role] of Object.entries(roles)) {
if (role.tracker && (role.tracker.bot === tracker.sync.bot || role.tracker.botId === tracker.sync.botId)) {
if (tracker && role.tracker && (role.tracker.bot === tracker.sync.bot || role.tracker.botId === tracker.sync.botId)) {
refuse(`${file} tracker.sync must use its own bot, not the one roles.${instance} uses`);
}
}
+1 -1
View File
@@ -82,7 +82,7 @@ function validate(args) {
projects[id] = "valid";
}
const refs = [
...Object.values(business.tracker.sync.credentials),
...Object.values(business.tracker?.sync?.credentials ?? {}),
...Object.values(business.roles).flatMap((r) => Object.values(r.credentials)),
];
const checked = checkRefs(refs, forbiddenRoots(config, business));
+29 -2
View File
@@ -14,11 +14,12 @@ 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 } from "./vocabulary.mjs";
import { CREATIVE_DEFINITIONS, END_PROTECTED, HOST_SERVICE, ID_PATTERN, checkGrantScope, grantScopeOf } 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.
@@ -301,6 +302,32 @@ 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
@@ -418,5 +445,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 };
return { writer, read, set, checkDelete, checkFire, veto, hire, fire, grantScope, grantScopeRead };
}
+70
View File
@@ -299,3 +299,73 @@ 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 -1
View File
@@ -95,7 +95,7 @@ test("top-level refusals", () => {
refuses(() => check((d) => { d.owner = "x"; }), /unsupported business file key: "owner"/);
refuses(() => check((d) => { d.id = "other"; }), /must match its filename/);
refuses(() => check((d) => { d.id = "Acme"; }), /business id/);
for (const key of ["human", "arbiters", "projects", "tracker", "roles"]) {
for (const key of ["human", "arbiters", "projects", "roles"]) {
refuses(() => check((d) => { delete d[key]; }), new RegExp(`requires "${key}"`));
}
refuses(() => check((d) => { d.vars.harness = "pi"; }), /harness can't be set at the business layer/);
@@ -0,0 +1,124 @@
// 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,71 @@
// Q247 / row 324 (#1820): optional business tracker; role credential rules remain exact.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { rmSync } from "node:fs";
import { join } from "node:path";
import { loadBusiness, validateBusinessDocument } from "../src/index.mjs";
import { businessDoc, REPO, REPO_ROLES, systemConfig, tmp, writeJson } from "./helpers.mjs";
function fixture(t) {
const root = tmp();
t.after(() => rmSync(root, { recursive: true, force: true }));
const doc = businessDoc(root);
const config = systemConfig(root);
const file = join(root, "config", "businesses", "acme.json");
return { root, doc, config, file };
}
const check = (x) => validateBusinessDocument(x.doc, x.file, { rolesDir: REPO_ROLES });
test("Q247: omitting tracker loads as null and preserves independent role bots", (t) => {
const x = fixture(t);
delete x.doc.tracker;
writeJson(x.file, x.doc);
const b = loadBusiness("acme", { dir: join(x.root, "config"), rolesDir: REPO_ROLES });
assert.equal(b.tracker, null);
assert.equal(b.roles.pm.tracker.bot, x.doc.roles.pm.tracker.bot);
assert.equal(b.roles.pm.credentials.vikunja.file, x.doc.roles.pm.credentials.vikunja.file);
assert.equal(Object.hasOwn(x.doc, "tracker"), false);
});
test("Q247: real business validate passes without a sync credential reference or file", (t) => {
const x = fixture(t);
rmSync(x.doc.tracker.sync.credentials.vikunja.file);
delete x.doc.tracker;
writeJson(x.file, x.doc);
const r = spawnSync(process.execPath, [join(REPO, "packages/business/src/cli.mjs"), "validate", "acme"], {
env: { ...process.env, HOME: x.root, MOSAIC_CONFIG: x.config, MOSAIC_ROLES_DIR: REPO_ROLES },
encoding: "utf8", timeout: 30000,
});
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.equal(JSON.parse(r.stdout).business, "acme");
assert.doesNotMatch(r.stderr, /credential reference problem|not found/);
});
test("Q247: present tracker retains its values and validation without mutating the input", (t) => {
const x = fixture(t);
const before = structuredClone(x.doc);
const b = check(x);
assert.equal(b.tracker.sync.bot, x.doc.tracker.sync.bot);
assert.equal(b.tracker.sync.botId, x.doc.tracker.sync.botId);
assert.equal(b.tracker.sync.credentials.vikunja.file, x.doc.tracker.sync.credentials.vikunja.file);
assert.deepEqual(b.tracker.labels, {});
assert.deepEqual(x.doc, before);
assert.ok(Object.isFrozen(b.tracker.sync));
});
test("Q247: present null, undefined and malformed trackers still refuse", (t) => {
const x = fixture(t);
for (const tracker of [null, undefined, false, [], {}, { sync: {} }]) {
x.doc.tracker = tracker;
assert.throws(() => check(x), /tracker/, String(tracker));
}
});
test("Q247: absent business tracker does not waive a role's Vikunja bot requirement", (t) => {
const x = fixture(t);
delete x.doc.tracker;
delete x.doc.roles.pm.tracker;
assert.throws(() => check(x), /roles\.pm needs "tracker"/);
});
+31
View File
@@ -1080,6 +1080,37 @@ 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)
```
+1
View File
@@ -55,6 +55,7 @@ function business(fn) {
// and no task verbs; two projects naming one each refuse, since the boot
// shape holds one tracker project per business.
function trackerFor(system, b, warn) {
if (!b.tracker) return null;
const first = Object.keys(b.roles)[0];
const base = resolveInstance({ system, business: b, instance: first }).vars;
if (base["tracker.baseUrl"] === undefined) return null;
+30 -4
View File
@@ -2,16 +2,20 @@
// 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.
// 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.
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";
"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)";
const MAX_TOKEN = 16384;
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by"];
const MAX_SCOPE = 65536;
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by", "--revision"];
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
@@ -40,6 +44,21 @@ 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"] },
@@ -49,6 +68,8 @@ 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) => {
@@ -71,6 +92,9 @@ 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;
@@ -79,7 +103,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" && rest.at(-1) !== "gitea") ||
(verb !== "run" && verb !== "scope" && verb !== "scope set" && rest.at(-1) !== "gitea") ||
(verb === "admin rotate" && parsed.flags.has("--finish") === Object.hasOwn(parsed.values, "--rotate-by"))
)
throw new CliError(GRANTS_USAGE, 4);
@@ -93,6 +117,8 @@ 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" }];
+74
View File
@@ -0,0 +1,74 @@
// 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, []);
}
});
@@ -0,0 +1,72 @@
// Q247 / row 324 (#1820): no tracker adapter, credential registration or Console sync row.
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { bootConfig } from "../src/config.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { Client } from "../../bus/src/client.mjs";
import { loadBusiness } from "../../business/src/index.mjs";
import { businessDoc, launchDoc, REPO_ROLES, systemConfig, tmp, writeJson } from "../../business/tests/helpers.mjs";
import { businessView } from "../../webui/src/reads.mjs";
function fixture(t, { present = false } = {}) {
const root = tmp();
t.after(() => rmSync(root, { recursive: true, force: true }));
const doc = businessDoc(root);
// Credential-free roles, so an empty runtime credential list proves no phantom @sync entry.
doc.roles = {
chief: { definition: "chief-of-staff", vars: { harness: "claude-code", model: "claude-opus-5-5", thinking: "high" } },
assistant: { definition: "assistant", vars: { harness: "claude-code", model: "claude-opus-5-5", thinking: "high" } },
};
doc.arbiters = { delivery: "chief", technical: "chief" };
doc.launch = launchDoc("chief", ["assistant"]);
doc.plugins = { harness: ["claude-code"] };
// Deliberately retain fixture tracker.baseUrl; missing business tracker must still mean no sync.
if (!present) delete doc.tracker;
// No credential files remain, so boot cannot accidentally rely on the old fixture's sync token.
rmSync(join(root, "secrets"), { recursive: true });
writeJson(join(root, "project/.mosaic/project.json"), { projectVersion: 1, id: "stack", vars: { "tracker.project": 3 } });
const config = systemConfig(root);
const system = JSON.parse(readFileSync(config, "utf8"));
mkdirSync(system.dataRoot, { mode: 0o700 });
writeJson(join(root, "config/businesses/acme.json"), doc);
const env = { HOME: root, MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: REPO_ROLES };
return { root, doc, system, env, b: loadBusiness("acme", { dir: join(root, "config"), rolesDir: REPO_ROLES }) };
}
test("Q247: bootConfig omits tracker setup even with tracker variables left in the business", (t) => {
const x = fixture(t);
assert.ok(x.doc.vars["tracker.baseUrl"]);
const boot = bootConfig({ system: x.system, businessId: "acme", env: x.env });
assert.equal(boot.businesses.acme.tracker, null);
assert.equal(Object.hasOwn(boot, "trackers"), false);
});
test("Q247: trackerless business boots and registers no @sync credential", async (t) => {
const x = fixture(t);
const boot = bootConfig({ system: x.system, businessId: "acme", env: x.env });
const runtime = await startBroker(boot);
t.after(() => runtime.close());
assert.deepEqual(runtime.credentials.status(), []);
await assert.rejects(runtime.credentials.useFile("acme/@sync", "vikunja", () => assert.fail("no sync token may be used")),
(e) => e.code === "credential-unknown");
const reader = new Client({ path: runtime.path, cap: runtime.readers[0].cap });
assert.deepEqual(await reader.call("credentials"), []);
});
test("Q247: Console business read works with tracker absent and shows no sync credential", (t) => {
const x = fixture(t);
const view = businessView(x.b, { system: x.system });
assert.equal(view.id, "acme");
assert.deepEqual(view.credentials, []);
assert.deepEqual(view.instances.map((entry) => entry.instance), ["chief", "assistant"]);
});
test("Q247: a present tracker with a missing token still refuses bus boot", async (t) => {
const x = fixture(t, { present: true });
const boot = bootConfig({ system: x.system, businessId: "acme", env: x.env });
assert.equal(boot.businesses.acme.tracker.sync.bot, x.doc.tracker.sync.bot);
await assert.rejects(startBroker(boot), (e) => e.code === "credential-file");
});
+1 -1
View File
@@ -163,7 +163,7 @@ export function businessView(b, { system = null, now = Date.now() } = {}) {
for (const [instance, r] of Object.entries(b.roles)) {
for (const [service, ref] of Object.entries(r.credentials)) credentials.push(credential(service, ref, service === 'vikunja' ? r.tracker?.bot ?? null : null, instance, now));
}
for (const [service, ref] of Object.entries(b.tracker.sync.credentials)) credentials.push(credential(service, ref, b.tracker.sync.bot, 'tracker sync', now));
for (const [service, ref] of Object.entries(b.tracker?.sync?.credentials ?? {})) credentials.push(credential(service, ref, b.tracker.sync.bot, 'tracker sync', now));
return {
id: b.id, human: b.human, arbiters: b.arbiters, projects: Object.keys(b.projects), actions: [...ACTIONS],
vars: pick(b.vars, VAR_KEYS), instances, launch: b.launch, credentials,