Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a4bef6c13 | ||
|
|
ec86f2c847 | ||
|
|
4ec279dc43 | ||
|
|
3d2569ce25 | ||
|
|
3a20f29d66 | ||
|
|
c5f96836d5 | ||
|
|
99930362e4 | ||
|
|
579720e84a | ||
|
|
c25799928d | ||
|
|
6a0f0f4f80 | ||
|
|
571376c30e | ||
|
|
6002698c32 | ||
|
|
dd6cb5872b | ||
|
|
64ea540838 | ||
|
|
854b783cda | ||
|
|
dbdfcfd52c | ||
|
|
7e59ba8b3d | ||
|
|
3569bf54f5 | ||
|
|
3fd5307e1e | ||
|
|
5283d3745f | ||
|
|
467b05fe2b | ||
|
|
dca5831e7e | ||
|
|
021422e921 | ||
|
|
920b691263 | ||
|
|
8d40ed20a8 | ||
|
|
860afc2b66 | ||
|
|
cd366fc685 | ||
|
|
535b05adbf | ||
|
|
e3fb4e6c90 | ||
|
|
156e5c6040 | ||
|
|
8b1a2b0fed |
No files matched your search
@@ -5423,3 +5423,49 @@ 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 322 / #1818 F8/F9 before | The library installer runs the clone importer, whose yaml dependency is absent on fresh clones, and tail -n 1 hides uncaught errors behind Node's version banner. Plan: run the same-commit copy importer, include it in COPY_PATHS, keep the first error line with last-line fallback, append isolated regression coverage. Mos-1550 approves setup-only world(t) additions and the one-path copy-file pin addition. No tests run yet; Sage slot required.
|
||||
|
||||
2026-10-23 | Goldie | Row 322 / #1818 F8/F9 after | Importer now runs from the same-commit dependency-equipped copy; COPY_PATHS includes it. One-line refusal selects an actual Error/ERR_ line, avoiding Node's source excerpt/banner, with last-line fallback. Mos-1550-approved fixture setup and one-file pin changes; two append-only regression tests. Under sage-3808 CLEAR, serialized timeout -k 10 runs in /mnt/storage/scratch/tmp/goldieuQ2664: install26/26, copy18/18, pinned-copy10/10; new tests2/2 first run and restored; N installer expected-red2/2, F8 and F9 mutants killed, installer SHA256 restored each time. No unexpected red. Evidence and exact old-test changes in agents/goldie/work/queue-322/BUILD.md; full gate and Morgana review belong to Sage.
|
||||
|
||||
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.
|
||||
|
||||
2026-10-23 | Goldie | Row 324 / #1820 rebased verification (sage-3850) | Rebased onto3569bf54f as7e59ba8b3; only append-log conflicts, eight manifest files byte-equal. Serialized timeout-k10 scratch TMPDIR goldieOdgIjr: new business5/5, new CLI4/4 (includes Console absent read), existing business21/21; presence mutant killed, bytes restored, new files rerun5/5 and4/4. No unexpected red. Published rebased branch goldie/row324-r2 preserves original remote draft without force-push. Evidence/mutant diff/reader list in BUILD.md; review/full gate pending.
|
||||
|
||||
2026-10-23 | Goldie | Row 323 / #1819 before | Case 1 lacks a committed business file. Brief requires actual .53 values and refuses guessing. New branch goldie/row323 from local refactor da537e7d1; source review shows project root, literal human id, tracker sync identity/reference metadata and launch model values absent from DESIGN, plus nonempty launch.instances requires another role. Asked Sage (goldie-0008); no config or test written, no test run. Details agents/goldie/work/queue-323/BUILD.md.
|
||||
|
||||
2026-10-23 | Goldie | Row 323 / #1819 static blocker (sage-3823) | Mos-1554 requires no bot and an absent, never-created tracker token path. Static tracing confirms this refuses install validation (business cli84-97, credentials78-83) and bus boot (runtime131/157, bus credentials225-228), even without tracker.baseUrl; unset env also refuses at bus credentials278. Reported Sage goldie-0009; no JSON/test/credential written, no tests. Accepted source values/role and refusal-test rulings are recorded in BUILD.md; waiting for product-boundary resolution.
|
||||
|
||||
2026-10-24 | Goldie | Row 323 / #1819 resumed draft (sage-3859) | Rebasing prior notes onto Q247 landing dd6cb5872 resolved only append logs. Added trackerless .53 factory JSON: jason, approved project root, chief+assistant for required nonempty launch.instances, claude-code/Opus5.5/high, tester on. No credentials or token placeholder. Seven new tests prepare exact-doc loading plus scratch real validate/install/boot and three refusal paths; one restoring tester-switch mutant prepared. No old test edits; no tests before Sage CLEAR. Source choices/rulings in BUILD.md.
|
||||
|
||||
2026-10-24 | Goldie | Row 323 / #1819 pre-test commit (sage-3861 CLEAR) | Sage explicitly requests commit before runs; JSON/new7-case test/restoring mutant and field provenance committed untested before slot work. Exact sources distinguish mos-1554 human/no-bot intent, sage-3820 roles, sage-3822 project/model, and Q247 tracker omission. No old test edits. Next runs: new file, business whole, CLI whole, mutant/restored file, serialized scratch TMPDIR timeout-k10.
|
||||
|
||||
2026-10-24 | Goldie | Row 323 / #1819 first slot STOP (sage-3861) | On committed571376c30: new file7/7 and business261/261 green; whole CLI hit unchanged revoke-window.test.mjs170 (test579, row306 R4), no RESULT after20.1s at ask167/Promise.all194. Stopped suite on observing red, cleaned only positively identified own scratch fixture. Partial631 pass/1 fail before termination, not full-suite green. No mutant/restored run, no old-test edit/rerun. Manifest unchanged; Sage notified goldie-0016 and slot returned. Evidence local under queue-323, awaits ruling.
|
||||
|
||||
2026-10-24 | Goldie | Row 323 / #1819 second red STOP (mos-1560, sage-3864) | Isolated revoke-window whole-file run1 on unchanged571376c30 failed0/1 after20.126s, same no RESULT at ask167/test194. Start00:24:22Z, load1.46/1.68/2.70, busyhelperCLEAR, fresh scratch goldieBObDQ9. No run2/3/CLI retry/mutant/restored run; no source/old-test edit. Sage goldie-0018 has log/load/error; slot returned, awaits base-dd6cb5872 root cause. Remote remains unverified571.
|
||||
|
||||
2026-10-24 | Goldie | Row 323 / #1819 accepted evidence (mos-1561, sage-3869) | Sage traced revoke-window reds to37-byte TMPDIR making109-byte tools.sock >107 limit (session.mjs278), not Codex. Fresh31-byte /scratch/tmp/XXXXXX confirmation passes1/1 in0.84s. On unchanged571376c30 serialized timeout-k10: business261/261, new7/7, tester-switch mutant killed/restored SHA85e6b9ac, restored new7/7. Every log starts UTC/load/helperCLEAR; all positive runs0 fail/skip/cancel. CLI whole acceptance comes from Sage gate under mos-1561; no local CLI rerun. Evidence-only notes/log commit follows, no old test/source changes.
|
||||
|
||||
2026-10-24 | Goldie | Row325 / #1821 before (mos-1563/1564) | Real .mosaic/project.json idstack disagrees with Case1 project's mosaic-stack key; row323 scratch fixture omitted project file and only warned. New goldie/row325 at99930362e changes only that JSON key and expressly authorized old assertion key; new real-byte project-copy validation tests require valid and reject old key. Mutant prepared; no tests before Sage CLEAR, no queue writes/host actions.
|
||||
|
||||
2026-10-24 | Goldie | Row325 / #1821 after (sage-3882 CLEAR) | Only Case1 projects key becomesstack; only old test70 expected key updated under mos-1564. New3-case test copies real .mosaic/project.json bytes, requires CLI project valid, rejects old key. Serialized shortTMP timeout-k10: new3/3, business264/264, old-key mutant killed/restored SHA9e43bfef, restored new3/3; all positive0 fail/skip/cancel. Manifest4/4 verifies including unchanged project document. Logs/load lines and mutant diff in BUILD.md; no queue writes/host action, Gladstone review/Sage gate pending.
|
||||
|
||||
2026-10-24 — Poe, row 208 / 193d (#1706), before: building Console shared list,
|
||||
share controls and per-grant viewer read-only against approved design e2d161e2e.
|
||||
Base ec86f2c84. Isolated tests and UI-guard mutants pending Sage CLEAR; no live action.
|
||||
|
||||
2026-10-24 — Poe, row 208 / 193d (#1706), after: Console shared list, share/unshare
|
||||
controls and live per-grant viewer UI implemented. Webui 418/418 and plugins
|
||||
119/119 green under sage-3925 CLEAR; four UI guard mutants killed with viewer
|
||||
bus bypass refusals. Prior old-test draft regression fixed under sage-3918;
|
||||
missing root oauth4webapi corrected by pinned npm ci under sage-3924. Evidence:
|
||||
agents/poe/work/queue-208/BUILD.md. Independent reviews and Sage's global gate
|
||||
remain; no live state, schema or bus policy change.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Bentina
|
||||
|
||||
Builder seat, Codex gpt-6.1-sol, started by Sage under Q246 and mos-1566. Owns row 157, I2: the install images, the
|
||||
install config key, the endpoint helper and the egress proxy (docs/plans/2026-10-18_containerized-install-rows.md),
|
||||
reassigned from Researcher so the coding runs on Codex.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no credentials of its
|
||||
own; it pushes only its own `bentina/*` branches through the repository credential helper (mos-0981), never `refactor`,
|
||||
`main` or another seat's branch. Claude seats orchestrate and review the install path (mos-1542 rule 4). Registered by
|
||||
Sage on 2026-10-24 so the queue can name the seat as row owner (store.mjs isSeatDir).
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,74 @@
|
||||
# Row 323 round 1 verdict: goldie/row323 6a0f0f4f8 (duckworth-0300)
|
||||
|
||||
Request: sage-3865 (early look at draft 571376c30) and sage-3870 (candidate 6a0f0f4f8, base dd6cb5872,
|
||||
manifest 903ef0225f2f…cda79f). Brief: docs/plans/2026-10-23_case1-business-file-brief.md, section "Brief:
|
||||
the Case 1 factory business file".
|
||||
Method: static. Git reads of local objects only. No node, no tests, no fetch, no queue reads.
|
||||
|
||||
## Verdict: APPROVE (static), with one condition
|
||||
|
||||
- **The manifest matches.** MANIFEST.sha256 hashes to 903ef0225f2f…cda79f, and both of its blobs match at
|
||||
6a0f0f4f8: the JSON 85e6b9ac… and the test aecd1c0f…. Neither file changed between 571376c30 and
|
||||
6a0f0f4f8. The rest of that range is BUILD.md, logs, BUILD-LOG and SESSIONS. dd6cb5872 is an ancestor.
|
||||
No existing test or source file changed.
|
||||
- **Fields against the brief and the cited rulings:**
|
||||
- id `mosaic-stack` (DESIGN section 7). `vars.bus.tester` true. There is no `library` key and no
|
||||
library project.
|
||||
- human `jason`. I can't read mos-1554 myself, so this rests on Sage's relay in sage-3823. BUILD.md is
|
||||
explicit that this is the only literal value that comes from mos-1554.
|
||||
- The project root /srv/mosaic/src/mosaic-stack is under the DESIGN home /srv/mosaic, and the value
|
||||
comes from sage-3822. It passes the normalized-absolute check at business.mjs:274. A missing project
|
||||
file there only gives a warning in validate (cli.mjs:73-77), so the path doesn't need to exist at
|
||||
install.
|
||||
- Roles are chief (chief-of-staff) and assistant. The assistant is needed because checkLaunch requires
|
||||
`launch.instances` to be nonempty and to exclude the launcher (business.mjs:186-190). BUILD.md names
|
||||
that check, as the brief asks. Both arbiters are chief, under sage-3820. launch.by is chief, which
|
||||
F7 (case1-accept.sh:545-559) reads.
|
||||
- Claude only. `plugins.harness` is [claude-code] and both roles use harness claude-code. There is no
|
||||
pi.
|
||||
- No tracker, no credentials, no library, and no credential value or reference anywhere in the file.
|
||||
Q247 covers the tracker by omission.
|
||||
- **The model, provider and effort have sources.**
|
||||
- The model claude-opus-5-5 is Sage's ruling in sage-3822.
|
||||
- The provider mapping anthropic/claude-opus-5-5 matches packages/business/README.md:185.
|
||||
- `thinking: high` is one of the claude-code LEVELS (index.mjs:28) and matches the chief convention at
|
||||
session-launch.test.mjs:83.
|
||||
- With no `launch.limits`, there is no ceiling: limitHit (launch.mjs:137-155) checks only the limits
|
||||
that are defined. That is a valid choice, and N1 records it.
|
||||
- **The tests read the shipped bytes.**
|
||||
- DOCUMENT is `REPO/docs/guides/case1-business.mosaic-stack.json`. Case 1 writes those exact bytes,
|
||||
loads them through loadBusiness, checks the bytes didn't change, and then asserts every field.
|
||||
- The validate, install, boot and refusal cases copy the document and change only the project root,
|
||||
under sage-3820. Since an absent root only warns, the copy differs from the shipped file only in a
|
||||
way validate doesn't refuse.
|
||||
- The brief's three tests are all there:
|
||||
- (1) The loader case.
|
||||
- (2) The real case2-install.sh factory profile with `--business-file`, with only stack-copy stubbed.
|
||||
- (3) `bus-tester-off`, the literal library key through the final validate (sage-3820), and the
|
||||
library project through `profile-library-business`.
|
||||
- The boot case also covers the 324 boundary: bootConfig gives no trackers, credentials.status() is
|
||||
[], and the reader's credentials are [].
|
||||
- **The tester-switch mutant is a real kill target.**
|
||||
- mutant.py asserts that the needle `"bus.tester": true` occurs exactly once, flips only that value,
|
||||
and restores in a finally block with a byte-equality check.
|
||||
- The value is asserted at test :66. mutant.log shows `not ok 1`, `false !== true`, `# fail 1`, and the
|
||||
restored sha256 85e6b9ac… equals the manifest.
|
||||
- The flip would also make the install case at :95 fail, because the install refuses with
|
||||
bus-tester-off.
|
||||
- **The run, as Goldie reported it (sage-3869 CLEAR, 31-byte TMPDIRs, on 571376c30). I didn't rerun it.**
|
||||
- The new file passed 7/7, the business suite 261/261, and the restored file 7/7 again.
|
||||
- The mutant was killed.
|
||||
- The earlier revoke-window red is the tools.sock length limit (session.mjs:278, dda2d07b4). It fails
|
||||
the same way on the bare base dd6cb5872, so it isn't this row's.
|
||||
|
||||
## Condition
|
||||
|
||||
- C1. Under mos-1561, the cli suite counts only from Sage's full gate on 6a0f0f4f8. This approval
|
||||
depends on that gate passing with no new failures.
|
||||
|
||||
## Notes
|
||||
|
||||
- N1 (doc). `launch` has no `limits`, so the business has no session ceiling. If Mos wants a cap on .53,
|
||||
it's a later launch-settings edit and doesn't need a new file.
|
||||
- N2 (doc). An install on .53 with no project file at /srv/mosaic/src/mosaic-stack still passes. It
|
||||
warns "no project variables" (cli.mjs:76), which the run sheet could mention.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Row 324 round 1 verdict: goldie/row324-r2 dbdfcfd52 (duckworth-0298)
|
||||
|
||||
Request: sage-3853 (#1820, comment 31469, round 1, rev 3768/3769). Candidate: dbdfcfd52 on refactor
|
||||
3569bf54f, code commit 7e59ba8b3. Brief: docs/plans/2026-10-23_optional-tracker-brief.md.
|
||||
Method: static. Git reads of local objects only. No node, no tests, no fetch, no queue reads.
|
||||
|
||||
## Verdict: APPROVE (static)
|
||||
|
||||
- **The manifest matches.** agents/goldie/work/queue-324/MANIFEST.sha256 hashes to 52874356…2b473d,
|
||||
the digest Sage gave. All 8 listed blobs match the files at dbdfcfd52.
|
||||
- **The loader change is narrow.**
|
||||
- business.mjs:239 drops "tracker" from the required-key loop.
|
||||
- business.mjs:296 is `Object.hasOwn(document, "tracker") ? checkTracker(document.tracker, file) : null`.
|
||||
Omission gives `tracker: null`. A present key always goes through checkTracker.
|
||||
- business.mjs:298 guards the role tracker comparison on `tracker &&`. An absent business tracker
|
||||
doesn't waive the role pm tracker check, and the new test covers that.
|
||||
- **A present null still refuses.** checkTracker (business.mjs:85) calls requireObject first, so a
|
||||
present null, false, [] or a non-object refuses. {} and {sync:{}} refuse further in. The new
|
||||
refusal test covers each of them.
|
||||
- **Every business.tracker reader handles absence.** I read each hit in Goldie's reader-audit.txt and
|
||||
the two outside it in scripts:
|
||||
- bus runtime.mjs:131 registers `<business>/@sync` only under `b.tracker?.sync?.credentials`. An
|
||||
absent tracker registers nothing.
|
||||
- bus runtime.mjs:247 hashes `b.tracker?.sync ?? null`.
|
||||
- broker.mjs:6242 reads `b.tracker?.sync?.bot`. Broker 3969 and 4497 read the role-level tracker
|
||||
only.
|
||||
- cli.mjs:85 and webui reads.mjs:166 use `tracker?.sync?.credentials ?? {}`. The
|
||||
`b.tracker.sync.bot` inside the reads.mjs loop runs only when there are entries.
|
||||
- cli config.mjs:58 trackerFor returns null on an absent tracker before the vars check. bootConfig
|
||||
(config.mjs:138-139) sets config.trackers only for a non-null result.
|
||||
- tasks/adapter.mjs loads only for configured trackers (process.mjs:136), and its settings refuse
|
||||
with tracker-config at :25 before the :35 and :81 reads.
|
||||
- instance-settings.mjs:127 reads `old.tracker?.sync`.
|
||||
- stack-release.sh:2074 and :2120 use `b.tracker?.sync?.credentials`.
|
||||
- launch-settings, resolve, instances.mjs and public/bus.js:785 read role-level trackers only.
|
||||
conversation/controller exec.tracker is a different object.
|
||||
- **Writers don't serialize the loader's null.** The setters build `next` from the raw file bytes
|
||||
(organization.mjs:88-93), not from the loaded object, and validate before writing
|
||||
(organization.mjs:201, :208). A business file without a tracker stays without one.
|
||||
- **The brief's tests are there.**
|
||||
- business/tests/optional-tracker.test.mjs has 5 tests: omission loads null, CLI validate with the
|
||||
sync file gone, a present tracker preserved and frozen, the refusal set, and the role pm check.
|
||||
- cli/tests/optional-tracker.test.mjs has 4 tests: no trackers from bootConfig with tracker vars
|
||||
set, startBroker with no @sync credential, businessView with no credentials, and a present
|
||||
tracker with a missing token refusing credential-file.
|
||||
- The existing-tracker case runs through business.test.mjs unchanged apart from :98.
|
||||
- **The one old-test edit is in bounds.** business.test.mjs:98 drops "tracker" from the
|
||||
required-key loop, as mos-1556 allows.
|
||||
- **The run, as Goldie reported it (sage-3850 CLEAR, I didn't rerun it):** 5/5, 4/4 and
|
||||
business.test 21/21. The presence mutant at business.mjs:296 is killed, and the restore matched
|
||||
sha256 e76db421.
|
||||
|
||||
## Notes
|
||||
|
||||
- N1 (doc). The 'undefined' refusal case only exists in memory. A JSON file can't carry undefined,
|
||||
so on disk it is the same as omission. The test is still correct.
|
||||
@@ -0,0 +1,171 @@
|
||||
# Row 321: F7 wrapper environment and F10 first adoption (#1817)
|
||||
|
||||
Owner Gladstone; reviewer Boyd (separate Codex thread); queue/review/landing Sage.
|
||||
Authorized by sage-3791. Branch gladstone/row321 starts at fetched origin/refactor
|
||||
833ec1cc8d7c11842efb82e57c1a0b256c8d22d7. The row320 branch is unchanged.
|
||||
Brief: docs/plans/2026-10-23_host-fixes-1816-brief.md, section
|
||||
"Brief: F7, the wrapper step's PATH and umask under sudo", including F10;
|
||||
verified blob 7333f94ba9cfd58f87c5a3580d428b84c65845b6 (requested pin 7333f94ba9cf).
|
||||
Only the run sheet and this BUILD.md change. No scripts, tests, config, credentials,
|
||||
queue state, deployment or live host operation changed. No tests or Node commands run.
|
||||
|
||||
## Before and after
|
||||
|
||||
F7: sudo secure_path omits the pinned Node directory, so wrapper creation reaches
|
||||
instance.sh's bare node and exits127. An inherited umask can leave the bin dir0775.
|
||||
The MOSAIC_WRAPPER heredoc now sets umask077 and the sheet's existing pinned Node
|
||||
PATH before mkdir. One sentence names secure_path and why the step sets both.
|
||||
The existing Mos go remains explicit. No wrapper command is executed here.
|
||||
|
||||
F10: first install omits adoption, so the first activate refuses no-pointer.
|
||||
The release-transport section now places explicit adoption after both units are
|
||||
running (section10) and before any activate/upgrade, run as mosaic on Mos's go by
|
||||
Jason or Mos, citing stack-release.sh:14. It uses the configured dev instance and
|
||||
external units, with the same pinned Node PATH under sudo.
|
||||
Adjacent to the upgrade command, the sheet now says only the first upgrade after
|
||||
adopt takes --no-rehearsal: adopt writes no backup; missing flag refuses no-backup;
|
||||
later upgrades omit it, because a backup with that flag refuses rehearsal-required.
|
||||
Source checks: stack-release.sh pointer_for_business refuses no-pointer and directs
|
||||
adopt; stack-upgrade.sh rehearse handles rc3/no-backup and rc0/rehearsal-required.
|
||||
This records existing behavior and authority; it adds no requirement or live go.
|
||||
|
||||
## Test-reference grep (negative and positive control)
|
||||
|
||||
The required negative grep covers both F7 and F10 because neither is read by any
|
||||
matched test. The positive control locates the precheck path that the unit/install
|
||||
tests actually execute, showing the pathspecs reach their test files.
|
||||
|
||||
Negative grep:
|
||||
|
||||
```sh
|
||||
git grep -n 'host-run-sheet' -- 'packages/*/tests/*' 'scripts/test-*.sh'
|
||||
# exit 1
|
||||
# no matches
|
||||
```
|
||||
|
||||
Positive control:
|
||||
|
||||
```sh
|
||||
git grep -n 'case2-precheck.sh' -- 'packages/*/tests/*' 'scripts/test-*.sh'
|
||||
# exit 0
|
||||
packages/cli/tests/case1-accept.test.mjs:26:const PRECHECK = `${HOME}/.local/share/mosaic-stack/copies/${COPY}/scripts/case2-precheck.sh`;
|
||||
packages/cli/tests/case2-install.test.mjs:2:// scripts/case2-precheck.sh's library checks and --library-status mode. Brief:
|
||||
packages/cli/tests/case2-install.test.mjs:23:const PRECHECK = join(REPO, "scripts", "case2-precheck.sh");
|
||||
packages/cli/tests/case2-install.test.mjs:271:// ---- case2-precheck.sh, the library profile (D6a) ----
|
||||
packages/cli/tests/case2-install.test.mjs:334:// ---- case2-precheck.sh --library-status (D6b) ----
|
||||
packages/cli/tests/case2-unit.test.mjs:2:// and scripts/case2-precheck.sh checks the host before they start. Brief: docs/plans/2026-10-20_case2-rows.md
|
||||
packages/cli/tests/case2-unit.test.mjs:20:const PRECHECK = join(REPO, "scripts", "case2-precheck.sh");
|
||||
packages/cli/tests/case2-unit.test.mjs:588:// ---- case2-precheck.sh ----
|
||||
```
|
||||
|
||||
## Run-sheet diff against the pinned base (git diff --unified=0)
|
||||
|
||||
```diff
|
||||
diff --git a/docs/guides/host-run-sheet.md b/docs/guides/host-run-sheet.md
|
||||
index d2ba28d7c..96fd4f363 100644
|
||||
--- a/docs/guides/host-run-sheet.md
|
||||
+++ b/docs/guides/host-run-sheet.md
|
||||
@@ -209,0 +210,9 @@ sudo -u mosaic -H git clone -q -b refactor /srv/mosaic/incoming/<sha>.bundle /sr
|
||||
+After a first install, once both units are running (section 10), adopt the installed copy before
|
||||
+any activate or upgrade. This step runs only on Mos's go, with Jason or Mos running it
|
||||
+(`scripts/stack-release.sh:14`):
|
||||
+
|
||||
+```sh
|
||||
+sudo -u mosaic -H env PATH=/opt/mosaic-stack/node-<v>/bin:/usr/sbin:/usr/bin:/sbin:/bin \
|
||||
+ /srv/mosaic/src/mosaic-stack/scripts/stack-release.sh adopt <business> --instance dev --units external
|
||||
+```
|
||||
+
|
||||
@@ -211,0 +221,4 @@ Each upgrade, before `stack-upgrade.sh upgrade`:
|
||||
+Only the first upgrade after adopt takes `--no-rehearsal`: append it to the upgrade command below.
|
||||
+Adopt writes no backup, so omitting the flag then refuses `no-backup`. Subsequent upgrades omit it;
|
||||
+when a backup exists, supplying it refuses `rehearsal-required`.
|
||||
+
|
||||
@@ -246,0 +260 @@ step requiring Mos's go (`scripts/stack-copy.sh:3`); the install does not create
|
||||
+The step runs under sudo's `secure_path`, so it sets its own PATH and umask.
|
||||
@@ -250,0 +265,2 @@ cd / || exit 1
|
||||
+umask 077
|
||||
+PATH=/opt/mosaic-stack/node-<v>/bin:/usr/sbin:/usr/bin:/sbin:/bin; export PATH
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
No matching test reads the changed lines, so the stop condition did not apply.
|
||||
No test run or slot is required under sage-3791. Verification is static: the
|
||||
required grep and positive control above, exact brief/base pins, scoped diff,
|
||||
and git diff --check. Reviewer Boyd and Sage own review/landing; no approval
|
||||
or landing is claimed here.
|
||||
|
||||
Receipt correction: the first commit embedded ordinary diff context lines that
|
||||
contain a single space. git diff --check flagged those as trailing whitespace.
|
||||
This follow-up uses the exact zero-context diff instead. The run sheet is
|
||||
unchanged, no tests or Node commands run, and no amend or force-push is used.
|
||||
|
||||
## F11 addition (sage-3805, mos-1549, ops-0275)
|
||||
|
||||
The brief was extended after F7/F10 drafting and re-pinned at revision3733:
|
||||
canonical local commit a9bae9af2, same F7 section anchor, full verified blob
|
||||
0e684ef2103447dcc2013b6bf81b1c4ca5bbc0bd (requested pin0e684ef21034).
|
||||
Read using git show from the canonical checkout; no fetch, rebase or queue write
|
||||
is required. This new pin supersedes the earlier brief pin for this follow-up.
|
||||
|
||||
Defect: a precheck one second after unit start found no bus.sqlite yet.
|
||||
Section10 now requires loopback healthz success after a unit start before any
|
||||
case2-precheck.sh, including section9's --library-status. It uses the same
|
||||
127.0.0.1:<port>/healthz curl form as scripts/case1-accept.sh:458. The subshell
|
||||
tries at most30 times, each curl capped at2 seconds, with1 second between failed
|
||||
attempts (at most89 seconds plus scheduling overhead). Exhaustion prints STOP
|
||||
and exits only the subshell. The instruction says to stop before precheck/adopt;
|
||||
it does not exit the interactive operator shell. The section9 library-gitea row
|
||||
now explicitly requires this bounded wait before --library-status. <port> is the
|
||||
installed Console unit's --port, not an assumed port or business name.
|
||||
No host action, test, Node command or credential access occurred.
|
||||
|
||||
Required grep repeated for the widened scope:
|
||||
|
||||
```sh
|
||||
git grep -n 'host-run-sheet' -- 'packages/*/tests/*' 'scripts/test-*.sh'
|
||||
# exit1, no matches
|
||||
git grep -n 'case2-precheck.sh' -- 'packages/*/tests/*' 'scripts/test-*.sh'
|
||||
# exit0, positive control; output follows
|
||||
packages/cli/tests/case1-accept.test.mjs:26:const PRECHECK = `${HOME}/.local/share/mosaic-stack/copies/${COPY}/scripts/case2-precheck.sh`;
|
||||
packages/cli/tests/case2-install.test.mjs:2:// scripts/case2-precheck.sh's library checks and --library-status mode. Brief:
|
||||
packages/cli/tests/case2-install.test.mjs:23:const PRECHECK = join(REPO, "scripts", "case2-precheck.sh");
|
||||
packages/cli/tests/case2-install.test.mjs:271:// ---- case2-precheck.sh, the library profile (D6a) ----
|
||||
packages/cli/tests/case2-install.test.mjs:334:// ---- case2-precheck.sh --library-status (D6b) ----
|
||||
packages/cli/tests/case2-unit.test.mjs:2:// and scripts/case2-precheck.sh checks the host before they start. Brief: docs/plans/2026-10-20_case2-rows.md
|
||||
packages/cli/tests/case2-unit.test.mjs:20:const PRECHECK = join(REPO, "scripts", "case2-precheck.sh");
|
||||
packages/cli/tests/case2-unit.test.mjs:588:// ---- case2-precheck.sh ----
|
||||
```
|
||||
|
||||
No test reads the changed run-sheet lines; the stop condition does not apply.
|
||||
No test run is needed under the docs-only brief. Static verification checks both
|
||||
locations, retry limit/timeout, the loopback URL, unchanged F7/F10 and scoped diff.
|
||||
|
||||
F11 delta against d9400f0d3 (git diff --unified=0):
|
||||
|
||||
```diff
|
||||
diff --git a/docs/guides/host-run-sheet.md b/docs/guides/host-run-sheet.md
|
||||
index 96fd4f363..e9fa87f24 100644
|
||||
--- a/docs/guides/host-run-sheet.md
|
||||
+++ b/docs/guides/host-run-sheet.md
|
||||
@@ -322 +322 @@ file with `stat -c '%U %a %n' PATH`, never `cat`. `<business>` is the host's bus
|
||||
-| `/srv/mosaic/.config/mosaic-dev/secrets/<business>/` | `library-gitea` | 0600 | mosaic | library | `case2-precheck.sh --business <business> --library-status`, after the bus is up: `library-last-error library-credential-missing` until it's placed, then `library <project> last fetched ...` |
|
||||
+| `/srv/mosaic/.config/mosaic-dev/secrets/<business>/` | `library-gitea` | 0600 | mosaic | library | After a unit start, wait for loopback `/healthz` with the bounded retry in section 10 before `case2-precheck.sh --business <business> --library-status`: `library-last-error library-credential-missing` until it's placed, then `library <project> last fetched ...` |
|
||||
@@ -357,0 +358,16 @@ None by hand. I2's `case2-unit.sh` enables both units, starts the bus, then the
|
||||
+After a unit start, wait for the Console's loopback `/healthz` to succeed before running
|
||||
+`case2-precheck.sh`, including `--library-status` in section 9. Replace `<port>` with the installed
|
||||
+Console unit's `--port`. This retry is bounded to 30 attempts, each with a 2-second timeout, with
|
||||
+1 second between failed attempts. If it prints STOP, stop here; do not run the precheck or adopt.
|
||||
+
|
||||
+```sh
|
||||
+(
|
||||
+ attempts=0
|
||||
+ until curl -fsS --max-time 2 -o /dev/null 'http://127.0.0.1:<port>/healthz'; do
|
||||
+ attempts=$((attempts + 1))
|
||||
+ [ "$attempts" -lt 30 ] || { echo "STOP: loopback healthz did not become ready; do not continue" >&2; exit 1; }
|
||||
+ sleep 1
|
||||
+ done
|
||||
+)
|
||||
+```
|
||||
+
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
# Row 322 / #1818 — F8 and F9
|
||||
|
||||
Goldie, 2026-10-23. Branch goldie/row322, base 833ec1cc8d7c11842efb82e57c1a0b256c8d22d7.
|
||||
Brief: docs/plans/2026-10-23_host-fixes-1816-brief.md, F8/F9 section at 7333f94ba9cf.
|
||||
Issue #1818 is supplied by Sage; no new issue is needed. Reviewer Morgana; Sage owns landing/full gate.
|
||||
|
||||
## Implementation and authority
|
||||
|
||||
F8 runs the importer from `$COPIES/$COMMIT/scripts/case2-persona-import.mjs`. This uses Node's normal ESM resolution, finding yaml beside the copy and the relative harness module in that same commit. It avoids a custom loader or ambient resolution setting. The copy closure already includes harness/src and business/src, but omitted the importer itself; COPY_PATHS now includes that one script. No new dependency or dependency version change.
|
||||
|
||||
F9 selects the first line beginning with an Error type (including TypeError etc.) or ERR_, falling back to the last line for the importer's handled refusal. awk exits after the first matching line and emits exactly one line, keeping the stack and version banner out of the install refusal. Anchoring avoids selecting Node's earlier `throw new ERR_MODULE_NOT_FOUND(...)` source excerpt instead of the actual Error line.
|
||||
|
||||
Mos-1550 authorizes the copy-path addition, setup-only additions to world(t), and adding the single new filename to any existing copy-file pin. Existing test edits outside the appended row-322 block:
|
||||
|
||||
- case2-install.test.mjs: the fake stack-copy setup gains a loop copying the importer, harness bundle.mjs/tools.mjs, and business vocabulary.mjs; it copies yaml into the fake copy's node_modules. The existing last template-string line is extended to add those setup commands. No existing assertion changes.
|
||||
- packages/webui/tests/pinned-copy.test.mjs:76: the scripts file-list assertion gains only `case2-persona-import.mjs`, in sorted order. No other change in this test.
|
||||
|
||||
The new append-only block builds a scratch clone outside the repository with no node_modules, real installer and importer, stubbed bootstrap/business validation, and a copy with the real importer, relative module closure and yaml. F8 checks installed persona bytes/mode; F9 removes yaml from that copy to produce a real uncaught Node missing-package error, then checks one refusal line carries the cause and no stack/banner. Existing tests missed F8 because the real importer ran from the development checkout, which has yaml installed; the fake copy held only scripts/mosaic. The old fixtures thus never exercised the dependency boundary or an uncaught importer load error.
|
||||
|
||||
## Class check and copy-test audit
|
||||
|
||||
Before change:
|
||||
|
||||
```
|
||||
grep -n 'node "$REPO/' scripts/case2-install.sh scripts/case2-precheck.sh scripts/stack-copy.sh
|
||||
scripts/case2-install.sh:185: out="$(node "$REPO/scripts/case2-persona-import.mjs" ...
|
||||
```
|
||||
|
||||
After change: same grep has no hits (exit 1). Direct clone-side .mjs entry points in the three scripts with a bare package import: **scripts/case2-persona-import.mjs → yaml**, moved to the copy by F8. No other direct entry point imports a bare package. Precheck's inline Node programs dynamically import cli/src/config.mjs, business/src/index.mjs, library/src/index.mjs and cli/src/cli.mjs; those entry files import builtins/relative modules only. Library reader.mjs uses its own front-matter parser and has no yaml import. stack-copy sources instance.sh, whose mosaic-config.mjs imports builtins only. Installer bootstrap likewise runs mosaic-config.mjs, and business validation runs business/src/cli.mjs, also no direct bare imports. No other product fix made.
|
||||
|
||||
Audit commands (read-only):
|
||||
|
||||
```
|
||||
rg -n 'COPY_PATHS|copy carries|copy.*(hash|sha256|files|file set)|sha256.*copy' packages/*/tests/* scripts/test-*.sh
|
||||
rg -n 'COPY_PATHS|stack-copy\.sh|readdirSync|sha256|deepEqual' packages/webui/tests/pinned-copy.test.mjs packages/cli/tests/stack-copy.test.mjs
|
||||
```
|
||||
|
||||
Relevant hits: cli/tests/stack-copy.test.mjs:35-47 checks harness file closure (positive control: COPY_PATHS at :36), :52 asserts a required-file subset, :248-264 computes hashes/modes and compares bundle/check-out copy trees dynamically, :274 compares another bundle tree. None freezes a complete path list/hash. webui/tests/pinned-copy.test.mjs:75 pins top-level entries (unchanged), :76 pins scripts' exact file set (approved one-path update). Other readdir hits in those files check cleanup/existing-target behavior, not COPY_PATHS identity. Broad initial grep also hits business/tests/helpers.mjs:33, cli/tests/stack-upgrade.test.mjs:295, webui/tests/views.test.mjs:172, cli/tests/stack-release.test.mjs:1751 and :1987; inspected contexts are unrelated comments/UI copy or different fixture copies. No scripts/test-*.sh hit for these path/hash patterns.
|
||||
|
||||
## Verification plan
|
||||
|
||||
Pending Sage CLEAR before any test: run new tests on fixed build first, then whole case2-install.test.mjs, whole stack-copy.test.mjs and webui pinned-copy.test.mjs. Demonstrate expected failure at 094124b4e with new block applied (baseline importer path), and kill the `$REPO` reversion mutant. Record all outcomes. First unexpected new-test red is recorded/reported under mos-1272; existing-test edits outside mos-1550 or a second unexpected red stop for Sage.
|
||||
|
||||
No tests run yet. No commit/push yet. No host changes, live state, secrets, deploy or release actions.
|
||||
|
||||
## Verified results — sage-3808 CLEAR, 2026-10-23T22:47Z
|
||||
|
||||
All runs were serialized with `TMPDIR=/mnt/storage/scratch/tmp/goldieuQ2664` (created by `mktemp -d /mnt/storage/scratch/tmp/goldieXXXXXX`), using `timeout -k 10 90` for focused tests and `timeout -k 10 180` for whole files/negative-proof driver. Node v26.8.1. Dependencies were read through the ignored, uncommitted node_modules symlink approved by sage-3804; each scratch clone had no node_modules. No /tmp fixture was used.
|
||||
|
||||
| Run | Result | Evidence |
|
||||
| --- | --- | --- |
|
||||
| New tests, first run | 2/2 pass, exit 0 | test-new.log |
|
||||
| Whole case2-install.test.mjs | 26/26 pass, exit 0 | test-install.log |
|
||||
| Whole stack-copy.test.mjs | 18/18 pass, exit 0 | test-copy.log |
|
||||
| Whole webui pinned-copy.test.mjs | 10/10 pass, exit 0 | test-pinned-copy.log |
|
||||
| Installer from 094124b4e, new tests | 0/2 pass, exit 1, expected red | baseline-094124b4e.log |
|
||||
| F8 mutant: restore `$REPO` importer path only | 0/1 pass, exit 1, killed | mutant-F8.log |
|
||||
| F9 mutant: restore tail-only refusal only | 0/1 pass, exit 1, killed | mutant-F9.log |
|
||||
| Fixed installer restored, new tests rerun | 2/2 pass, exit 0 | test-restored.log |
|
||||
|
||||
The baseline proof temporarily installs the exact installer bytes from `git show 094124b4e:scripts/case2-install.sh`, retaining the newly appended tests and approved fixture setup. Relevant installer/importer/copy/test source paths have no changes between N and the branch base 833ec1cc8 (`git diff 094124b4e 833ec1cc8 --` those paths is empty). It is a targeted baseline proof, not a full checkout/suite run at N. Baseline failures show `persona-import: Node.js v26.8.1`; the F8-only mutant exposes the underlying `Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'yaml'` from the scratch clone. F9-only mutant again prints the banner. All expected failures are deliberate acceptance checks; no unexpected first-run red occurred.
|
||||
|
||||
negative-proof.py records each run, checks the expected failure count/exit, restores in a finally block, and verifies fixed installer bytes after each run. Restored SHA-256 each time: `c23a08324528cc29895014093c661948a5c8eb40316c7b2e21c6f833cf5d6462`. Final focused green confirms restoration. git diff --check passed.
|
||||
|
||||
`git diff --numstat -- packages/cli/tests/case2-install.test.mjs`: **57 additions, 1 deletion**. Outside the new appended block, original line 70's template-string close moves after four new setup lines: the existing mkdir/empty mosaic/echo command is unchanged, followed by the `for file ...` line, mkdir/cp loop body, `done`, and mkdir/cp yaml line. All pre-existing assertions remain byte-for-byte unchanged. The only pre-existing assertion edit anywhere is the expressly approved one-filename addition at packages/webui/tests/pinned-copy.test.mjs:76.
|
||||
|
||||
Sage's full package/script gate and Morgana's review remain pending. This builder ran only the approved three whole test files and targeted acceptance checks. Sage retains queue transition/review posting and integration authority; no PR was created by Goldie.
|
||||
|
||||
Evidence log lines have trailing whitespace removed for git diff --check; test content and outcomes are unchanged.
|
||||
@@ -0,0 +1,4 @@
|
||||
c23a08324528cc29895014093c661948a5c8eb40316c7b2e21c6f833cf5d6462 scripts/case2-install.sh
|
||||
cb1a4fef997b9696406e324878d7a0e5181892622162c9ce77a8fd625b5cd59a scripts/stack-copy.sh
|
||||
a7b02af4b5df92e6d727fe8c67565ea0483d6d57531765dee68f54fd97e7c23f packages/cli/tests/case2-install.test.mjs
|
||||
2574b414459f4cfb187de64b9a2fbe34ab4b4d64f582caae8baf6a1985e8cd3c packages/webui/tests/pinned-copy.test.mjs
|
||||
@@ -0,0 +1,70 @@
|
||||
TAP version 13
|
||||
# Subtest: row 322 F8: library personas install from the copy with no clone node_modules
|
||||
not ok 1 - row 322 F8: library personas install from the copy with no clone node_modules
|
||||
---
|
||||
duration_ms: 90.885888
|
||||
type: 'test'
|
||||
location: '/home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row322/packages/cli/tests/case2-install.test.mjs:451:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
case2-install: config /mnt/storage/scratch/tmp/goldieuQ2664/case2-install-rQM4ct/home/.config/mosaic-dev/config.json
|
||||
case2-install: copy /mnt/storage/scratch/tmp/goldieuQ2664/case2-install-rQM4ct/home/.local/share/mosaic-stack/copies/cccccccccccccccccccccccccccccccccccccccc
|
||||
case2-install: refused: persona-import: Node.js v26.8.1
|
||||
|
||||
|
||||
1 !== 0
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected: 0
|
||||
actual: 1
|
||||
operator: 'strictEqual'
|
||||
stack: |-
|
||||
TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row322/packages/cli/tests/case2-install.test.mjs:454:10)
|
||||
Test.runInAsyncScope (node:async_hooks:226:14)
|
||||
Test.run (node:internal/test_runner/test:1402:25)
|
||||
Test.start (node:internal/test_runner/test:1262:17)
|
||||
startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17)
|
||||
...
|
||||
# Subtest: row 322 F9: importer refusal keeps the error line rather than the Node banner
|
||||
not ok 2 - row 322 F9: importer refusal keeps the error line rather than the Node banner
|
||||
---
|
||||
duration_ms: 91.239243
|
||||
type: 'test'
|
||||
location: '/home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row322/packages/cli/tests/case2-install.test.mjs:462:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
The input did not match the regular expression /persona-import: Error \[ERR_MODULE_NOT_FOUND\]: Cannot find package 'yaml'/. Input:
|
||||
|
||||
'case2-install: config /mnt/storage/scratch/tmp/goldieuQ2664/case2-install-O6VWs2/home/.config/mosaic-dev/config.json\n' +
|
||||
'case2-install: copy /mnt/storage/scratch/tmp/goldieuQ2664/case2-install-O6VWs2/home/.local/share/mosaic-stack/copies/cccccccccccccccccccccccccccccccccccccccc\n' +
|
||||
'case2-install: refused: persona-import: Node.js v26.8.1\n'
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected:
|
||||
actual: |-
|
||||
case2-install: config /mnt/storage/scratch/tmp/goldieuQ2664/case2-install-O6VWs2/home/.config/mosaic-dev/config.json
|
||||
case2-install: copy /mnt/storage/scratch/tmp/goldieuQ2664/case2-install-O6VWs2/home/.local/share/mosaic-stack/copies/cccccccccccccccccccccccccccccccccccccccc
|
||||
case2-install: refused: persona-import: Node.js v26.8.1
|
||||
|
||||
operator: 'match'
|
||||
stack: |-
|
||||
refused (file:///home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row322/packages/cli/tests/case2-install.test.mjs:137:10)
|
||||
TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row322/packages/cli/tests/case2-install.test.mjs:469:3)
|
||||
Test.runInAsyncScope (node:async_hooks:226:14)
|
||||
Test.run (node:internal/test_runner/test:1402:25)
|
||||
Test.processPendingSubtests (node:internal/test_runner/test:974:18)
|
||||
Test.postRun (node:internal/test_runner/test:1542:19)
|
||||
Test.run (node:internal/test_runner/test:1467:12)
|
||||
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
|
||||
...
|
||||
1..2
|
||||
# tests 2
|
||||
# suites 0
|
||||
# pass 0
|
||||
# fail 2
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 287.462723
|
||||
@@ -0,0 +1,37 @@
|
||||
TAP version 13
|
||||
# Subtest: row 322 F8: library personas install from the copy with no clone node_modules
|
||||
not ok 1 - row 322 F8: library personas install from the copy with no clone node_modules
|
||||
---
|
||||
duration_ms: 90.468116
|
||||
type: 'test'
|
||||
location: '/home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row322/packages/cli/tests/case2-install.test.mjs:451:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
case2-install: config /mnt/storage/scratch/tmp/goldieuQ2664/case2-install-bzVkal/home/.config/mosaic-dev/config.json
|
||||
case2-install: copy /mnt/storage/scratch/tmp/goldieuQ2664/case2-install-bzVkal/home/.local/share/mosaic-stack/copies/cccccccccccccccccccccccccccccccccccccccc
|
||||
case2-install: refused: persona-import: Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'yaml' imported from /mnt/storage/scratch/tmp/goldieuQ2664/case2-install-bzVkal/clone/scripts/case2-persona-import.mjs
|
||||
|
||||
|
||||
1 !== 0
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected: 0
|
||||
actual: 1
|
||||
operator: 'strictEqual'
|
||||
stack: |-
|
||||
TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row322/packages/cli/tests/case2-install.test.mjs:454:10)
|
||||
Test.runInAsyncScope (node:async_hooks:226:14)
|
||||
Test.run (node:internal/test_runner/test:1402:25)
|
||||
Test.start (node:internal/test_runner/test:1262:17)
|
||||
startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17)
|
||||
...
|
||||
1..1
|
||||
# tests 1
|
||||
# suites 0
|
||||
# pass 0
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 194.231229
|
||||
@@ -0,0 +1,41 @@
|
||||
TAP version 13
|
||||
# Subtest: row 322 F9: importer refusal keeps the error line rather than the Node banner
|
||||
not ok 1 - row 322 F9: importer refusal keeps the error line rather than the Node banner
|
||||
---
|
||||
duration_ms: 92.814376
|
||||
type: 'test'
|
||||
location: '/home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row322/packages/cli/tests/case2-install.test.mjs:462:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
The input did not match the regular expression /persona-import: Error \[ERR_MODULE_NOT_FOUND\]: Cannot find package 'yaml'/. Input:
|
||||
|
||||
'case2-install: config /mnt/storage/scratch/tmp/goldieuQ2664/case2-install-6HcR0m/home/.config/mosaic-dev/config.json\n' +
|
||||
'case2-install: copy /mnt/storage/scratch/tmp/goldieuQ2664/case2-install-6HcR0m/home/.local/share/mosaic-stack/copies/cccccccccccccccccccccccccccccccccccccccc\n' +
|
||||
'case2-install: refused: persona-import: Node.js v26.8.1\n'
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected:
|
||||
actual: |-
|
||||
case2-install: config /mnt/storage/scratch/tmp/goldieuQ2664/case2-install-6HcR0m/home/.config/mosaic-dev/config.json
|
||||
case2-install: copy /mnt/storage/scratch/tmp/goldieuQ2664/case2-install-6HcR0m/home/.local/share/mosaic-stack/copies/cccccccccccccccccccccccccccccccccccccccc
|
||||
case2-install: refused: persona-import: Node.js v26.8.1
|
||||
|
||||
operator: 'match'
|
||||
stack: |-
|
||||
refused (file:///home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row322/packages/cli/tests/case2-install.test.mjs:137:10)
|
||||
TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row322/packages/cli/tests/case2-install.test.mjs:469:3)
|
||||
Test.runInAsyncScope (node:async_hooks:226:14)
|
||||
Test.run (node:internal/test_runner/test:1402:25)
|
||||
Test.start (node:internal/test_runner/test:1262:17)
|
||||
startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17)
|
||||
...
|
||||
1..1
|
||||
# tests 1
|
||||
# suites 0
|
||||
# pass 0
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 200.516406
|
||||
@@ -0,0 +1,6 @@
|
||||
baseline-094124b4e: exit 1, expected failures 2
|
||||
restored sha256 c23a08324528cc29895014093c661948a5c8eb40316c7b2e21c6f833cf5d6462
|
||||
mutant-F8: exit 1, expected failures 1
|
||||
restored sha256 c23a08324528cc29895014093c661948a5c8eb40316c7b2e21c6f833cf5d6462
|
||||
mutant-F9: exit 1, expected failures 1
|
||||
restored sha256 c23a08324528cc29895014093c661948a5c8eb40316c7b2e21c6f833cf5d6462
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Run row-322 expected-red proofs, always restoring installer bytes."""
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
root = Path(__file__).resolve().parents[4]
|
||||
installer = root / 'scripts/case2-install.sh'
|
||||
logs = Path(__file__).resolve().parent
|
||||
fixed = installer.read_bytes()
|
||||
baseline = subprocess.check_output(['git', 'show', '094124b4e:scripts/case2-install.sh'], cwd=root)
|
||||
f8 = fixed.replace(b'$COPIES/$COMMIT/scripts/case2-persona-import.mjs', b'$REPO/scripts/case2-persona-import.mjs')
|
||||
assert f8 != fixed
|
||||
old_error = baseline.splitlines()[185]
|
||||
new_error = fixed.splitlines()[185]
|
||||
f9 = fixed.replace(new_error, old_error)
|
||||
assert f9 != fixed
|
||||
try:
|
||||
for label, source, pattern, failures in [
|
||||
('baseline-094124b4e', baseline, 'row 322', 2),
|
||||
('mutant-F8', f8, 'row 322 F8', 1),
|
||||
('mutant-F9', f9, 'row 322 F9', 1),
|
||||
]:
|
||||
installer.write_bytes(source)
|
||||
result = subprocess.run([
|
||||
'timeout', '-k', '10', '90', 'node', '--test', '--test-reporter=tap',
|
||||
f'--test-name-pattern={pattern}', 'packages/cli/tests/case2-install.test.mjs',
|
||||
], cwd=root, env=os.environ, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
(logs / f'{label}.log').write_text(result.stdout)
|
||||
print(f'{label}: exit {result.returncode}, expected failures {failures}', flush=True)
|
||||
assert result.returncode == 1, result.stdout
|
||||
assert f'# fail {failures}\n' in result.stdout, result.stdout
|
||||
assert 'ERR_MODULE_NOT_FOUND' in result.stdout, result.stdout
|
||||
installer.write_bytes(fixed)
|
||||
assert installer.read_bytes() == fixed
|
||||
print('restored sha256 ' + hashlib.sha256(fixed).hexdigest(), flush=True)
|
||||
finally:
|
||||
installer.write_bytes(fixed)
|
||||
assert installer.read_bytes() == fixed
|
||||
@@ -0,0 +1,26 @@
|
||||
✔ a copy carries every file the harness modules read from it (891.310838ms)
|
||||
✔ shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation (537.82282ms)
|
||||
✔ wrapper preserves Q81 lease and marker, rejects foreign/symlink; check uses effective roots (713.621566ms)
|
||||
✔ row 190 N6: check --instance passes only when the wrapper exports the instance's MOSAIC_CONFIG (657.082198ms)
|
||||
✔ bus unit copy/pm pins, install refuses all busy states and failed reads, drop-in refusal (799.380065ms)
|
||||
✔ missing package manifest refuses before npm; npm failure removes the unpublished temp copy (499.872009ms)
|
||||
✔ real pinned install loads bus and Console; same-copy mosaic talk passes proof, other trees refuse (3824.368055ms)
|
||||
✔ bus status reads effective copy/pm/drop-ins without writes; check refuses an overridden ExecStart (668.500662ms)
|
||||
✔ drop-ins in either override field and malformed metadata refuse; uninstall also checks --no-reload (399.490955ms)
|
||||
✔ install never shows a template name on systemd 261; template drop-ins still refuse (318.110324ms)
|
||||
✔ unlisted instance drop-in is found in another unit lookup directory; path discovery errors refuse (347.788225ms)
|
||||
✔ inherited mosaic-.service.d override refuses without changing the unit (403.142763ms)
|
||||
✔ inherited service.d override refuses without changing the unit (384.337811ms)
|
||||
✔ inherited drop-in unreadable directory or file refuses before writing (370.630432ms)
|
||||
✔ row 232: copy --bundle copies a SHA the bundle's refactor reaches, byte-identical to copy SHA, and refuses the rest (2257.394077ms)
|
||||
✔ row 232: wrapper --create writes the first wrapper only, the same bytes wrapper ID writes, and refuses over any file (779.276348ms)
|
||||
✔ row 232: check --units external reads the system units, the default reads the user units (641.792634ms)
|
||||
✔ Vikunja 614: bundle and checkout copy leave an unreadable caller directory before find (1047.143772ms)
|
||||
ℹ tests 18
|
||||
ℹ suites 0
|
||||
ℹ pass 18
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 15638.417631
|
||||
@@ -0,0 +1,34 @@
|
||||
✔ the library install writes the business file, personas and slot dir, makes the copy and starts nothing (258.707115ms)
|
||||
✔ a library re-run passes and leaves every file as it was (391.278216ms)
|
||||
✔ a re-run refuses business-file-differs and leaves the file unchanged (274.686097ms)
|
||||
✔ a re-run refuses persona-differs when an installed persona isn't the staged one, and writes nothing (274.084555ms)
|
||||
✔ the factory install writes no library mapping, persona or slot dir (175.791904ms)
|
||||
✔ the factory install refuses a library project and bus.tester off, before anything is written (72.301025ms)
|
||||
✔ the library install refuses bus.tester on and a persona var in the input (76.469642ms)
|
||||
✔ the install refuses root, a business id mismatch, another --home and a config dir with group bits (57.89739ms)
|
||||
✔ the library install needs --personas (5.495139ms)
|
||||
✔ a validate refusal removes the business file this run wrote (230.311754ms)
|
||||
✔ N4: a validate refusal on a re-run leaves the matching business file in place, byte for byte (397.123924ms)
|
||||
✔ a clean library host passes the precheck with no mirror yet (404.408689ms)
|
||||
✔ the library precheck refuses a persona file at 0644 (399.17119ms)
|
||||
✔ the library precheck refuses a persona whose digest isn't persona.sha256 (402.47159ms)
|
||||
✔ the library precheck refuses a persona dir with group bits (390.108413ms)
|
||||
✔ the library precheck refuses a business file at 0644 (402.829663ms)
|
||||
✔ the library precheck refuses a changes.jsonl at 0644 (573.861228ms)
|
||||
✔ the library precheck refuses a Discord binding (386.405794ms)
|
||||
✔ --library-status refuses library-mirror-missing before the bus has made the mirror (309.213305ms)
|
||||
✔ --library-status refuses library-never-synced on a fresh mirror (330.922116ms)
|
||||
✔ --library-status names a recorded lastError before the null lastFetch it leaves (N3) (335.576032ms)
|
||||
✔ --library-status prints each R-problem's path as an escaped JSON string, then the count, and passes (379.69927ms)
|
||||
✔ --library-status takes no --profile (5.544173ms)
|
||||
✔ Vikunja 614: install preserves the explicit notifier decision bytes and mode and creates none when absent (465.015156ms)
|
||||
✔ row 322 F8: library personas install from the copy with no clone node_modules (139.662489ms)
|
||||
✔ row 322 F9: importer refusal keeps the error line rather than the Node banner (95.452368ms)
|
||||
ℹ tests 26
|
||||
ℹ suites 0
|
||||
ℹ pass 26
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 7340.739711
|
||||
@@ -0,0 +1,10 @@
|
||||
✔ row 322 F8: library personas install from the copy with no clone node_modules (178.794273ms)
|
||||
✔ row 322 F9: importer refusal keeps the error line rather than the Node banner (101.739988ms)
|
||||
ℹ tests 2
|
||||
ℹ suites 0
|
||||
ℹ pass 2
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 394.59041
|
||||
@@ -0,0 +1,18 @@
|
||||
✔ copy writes the Console paths at the full id, read-only, and leaves an existing copy alone (872.104795ms)
|
||||
✔ copy refuses a commit off refactor, an unknown one, a worktree and a bad copies directory (1245.376299ms)
|
||||
✔ copy writes nothing when an archived path is missing at the commit (409.033887ms)
|
||||
✔ a copy that loses the rename race leaves no read-only temp directory behind (548.743561ms)
|
||||
✔ a copy whose rename fails with no target refuses, writes no copy and leaves no temp directory (534.468073ms)
|
||||
✔ a copy that meets a target holding files says exists: and moves nothing into it (541.303916ms)
|
||||
✔ a copy that meets an empty target directory replaces it by rename, not by moving into it (547.966283ms)
|
||||
✔ serve runs from a copy: /healthz 200, the config check, roles and RELEASE from the copy, the queue from --checkout (856.865742ms)
|
||||
✔ serve refuses a --checkout that is not an absolute directory path (274.552284ms)
|
||||
✔ consoleReads runs the queue read under queueRoot and reads RELEASE under root (49.311324ms)
|
||||
ℹ tests 10
|
||||
ℹ suites 0
|
||||
ℹ pass 10
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 6003.825092
|
||||
@@ -0,0 +1,10 @@
|
||||
✔ row 322 F8: library personas install from the copy with no clone node_modules (148.410784ms)
|
||||
✔ row 322 F9: importer refusal keeps the error line rather than the Node banner (94.205865ms)
|
||||
ℹ tests 2
|
||||
ℹ suites 0
|
||||
ℹ pass 2
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 344.990842
|
||||
@@ -0,0 +1,141 @@
|
||||
# Row 323 / #1819 — Case 1 factory business file
|
||||
|
||||
Goldie, 2026-10-23. New isolated worktree `/home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row323`, branch `goldie/row323`, base local refactor `da537e7d159be0bf39a36eaaa65cd16c20373b72` (descends from brief commit 68f3bded5). Row322 worktree remains unchanged.
|
||||
|
||||
## Sources and input boundary
|
||||
|
||||
Read brief docs/plans/2026-10-23_case1-business-file-brief.md, row195 DESIGN.md (host values sections1/2/6/7), installer factory HELPER, precheck profile checks, case1-accept F3/F7, business loader and credentials/launch checks, role definitions and business fixture helpers. No host access or credential read.
|
||||
|
||||
DESIGN fixes business `mosaic-stack`, OS user mosaic, home `/srv/mosaic`, config `/srv/mosaic/.config/mosaic-dev`, data `/srv/mosaic/.mosaic-dev`, Claude only, tester on. Human id is explicitly left to the go (:82/:300), no project root is named. Business loader requires at least one project/root (:261-275), tracker.sync bot/botId/Vikunja credential reference (:85-94), launch.models and nonempty launch.instances excluding launcher (:186-193). These host/business values are not supplied by DESIGN. Per sage-3815 and brief, stop configuration rather than guess. Asked Sage in goldie-0008 for exact values.
|
||||
|
||||
Recommended minimal role set: chief-of-staff instance chief with claude-code harness plus credential-free assistant solely because launch.instances must contain a declared non-launcher. Both arbiters may reference chief; no additional PM/CTO role credential required. This remains a recommendation pending input, not a written host config.
|
||||
|
||||
Literal top-level `library` is ignored by the installer plan and rejected later by business validation as unsupported key; a library project is refused by the factory plan as profile-library-business. Asked Sage which intended refusal assertion to use; recommend covering both through their real paths without a product change.
|
||||
|
||||
## Authority and status
|
||||
|
||||
Sage-3815 explicitly authorizes claimant queue moves despite brief's generic no-queue-writes boundary. Claim op goldie-0323-start-1 retries every20s on lock contention per sage-3814; no lock touched. Tests and mutant require new Sage CLEAR; none run. No business JSON or test written yet. No commit/push yet. BUILD/SESSIONS/BUILD-LOG notes record investigation only.
|
||||
|
||||
## Rulings and static blocker, 23:02Z
|
||||
|
||||
Sage-3820 accepts chief + credential-free assistant because launch.instances must be nonempty and exclude chief, both arbiters chief. It accepts literal library-key rejection through final real business validation and library-project rejection through factory planning, no production change. Sage-3822 supplies project mosaic-stack at /srv/mosaic/src/mosaic-stack and model claude-opus-5-5 (provider/thinking to be sourced). Sage-3823 relays Mos-1554: human jason, no real tracker bot, token path must be absent and never created; stop immediately if this refuses boot/launch.
|
||||
|
||||
Static trace proves refusal, reported to Sage in goldie-0009:
|
||||
|
||||
- cli/src/config.mjs:127 loads business. trackerFor :60 returns null without tracker.baseUrl; :137-138 suppresses only config.trackers, not business tracker.sync.
|
||||
- bus/src/runtime.mjs:131 registers business tracker.sync credentials under business/@sync; :157 constructs Credentials regardless of config.trackers.
|
||||
- bus/src/credentials.mjs:134-139 eagerly reads every reference. Ordinary ref.file lstat/realpath failure at :225-228 throws credential-file during boot, before chief can launch.
|
||||
- The business credential check warns for unset env, but bus/src/credentials.mjs:268 sends undefined to #format; :278 rejects credential-format. An env reference is not a bypass and is outside the brief's file requirement.
|
||||
- Install refuses even earlier: business/src/cli.mjs:84-88 checks tracker.sync references, business/src/credentials.mjs:78-83 records a missing ordinary file as a problem, CLI :95-97 exits2. case2-install.sh:217-220 wraps validate refusal and removes the business file it created.
|
||||
- business/src/business.mjs:41 requires tracker bot names to begin bot-, :42 requires positive botId. Mos's illustrative no-bot-mosaic-stack-sync would fail that shape; a bot-no-bot marker still cannot solve the absent-file refusal.
|
||||
|
||||
Status: real fail-closed product boundary. No business JSON, test, placeholder credential, host action or tests written/run. Await Sage/Mos resolution; do not bypass by inventing a token or expanding product scope. Only investigation notes exist in this worktree. Claim attempts continue separately under the queue window's explicit retry instruction.
|
||||
|
||||
## Resumed after Q247 landing — sage-3859
|
||||
|
||||
Current base is pushed `dd6cb5872b972bcabb99b82589b2905e4e47dd3e`. Preserved earlier investigation as a notes-only commit, then rebased onto that tip; only append-only BUILD-LOG/SESSIONS conflicts, resolved retaining the entire landed files and appending this row's original suffix. Earlier equivalent local refactor doc commits were skipped by git as already applied. No other row323 source existed before this resume.
|
||||
|
||||
Q247 removes the static blocker: this file now omits tracker entirely, with no dummy bot/token/reference. No tracker credential or token file is required or created. The separate Claude session login file remains the operator's run-sheet step, as described in DESIGN section3; this document contains no credential value or reference, and this work reads no credential.
|
||||
|
||||
Approved values and minimal role set:
|
||||
|
||||
- business mosaic-stack, factory bus.tester true, no library key/project (DESIGN sections1/2/7).
|
||||
- human jason (Mos-1554, sage-3823); project mosaic-stack at /srv/mosaic/src/mosaic-stack (sage-3822).
|
||||
- chief-of-staff chief and credential-free assistant, both arbiters chief, launch.by chief, launch.instances [assistant] (sage-3820). The loader requires nonempty launch.instances excluding the launcher (business.mjs:186-191), so a chief-only document cannot load. No other role is needed.
|
||||
- Both roles use claude-code; plugins.harness enables only claude-code. No pi route or fallback.
|
||||
- chief model claude-opus-5-5 is Sage's explicit choice (sage-3822). Provider anthropic/model claude-opus-5-5 is the repository model mapping at packages/business/README.md:185. There is no separate per-model default thinking catalog in the implementation: choose high from the Claude harness's supported LEVELS at packages/plugins/harness/claude-code/index.mjs:28, following the existing chief route convention at packages/cli/tests/session-launch.test.mjs:83. Both roles use that one declared model/effort; no extra model or capacity policy is introduced.
|
||||
|
||||
One new file packages/business/tests/case1-business-file.test.mjs has seven cases. It first loads the shipped JSON bytes unchanged from a temporary business-file location (loader checks shape, no host path access). Its runtime/CLI/install cases copy the document into a scratch input and replace only the project root with a scratch fixture root, per sage-3820's instruction to use test fixture paths. The real factory installer takes that input via --business-file and uses its real bootstrap/business validation, with only stack-copy stubbed (no clone/install/network). The boot case uses bootConfig + actual startBroker and asserts no tracker config or credential status. Negative cases cover bus.tester false (bus-tester-off), literal library (final validate unsupported key, per sage-3820), and a library project (factory plan profile-library-business). No pre-existing test changed.
|
||||
|
||||
The new tester-switch mutant flips only bus.tester in the shipped JSON, targets the new exact-document assertion, then restores/checks bytes in finally. Prepared but not run. Tests and mutant remain held until Sage CLEAR; only syntax/diff/static JSON checks have run. Row323 claimant is still in-progress from rev3749.
|
||||
|
||||
## Field provenance and pre-test commit — sage-3861
|
||||
|
||||
Sage-3861 CLEAR explicitly requires committing the draft before any run, so test counts attach to a commit. This is a specific exception to the usual green-before-commit rule. Until recorded below, this commit is untested. No old test is modified.
|
||||
|
||||
| Document field | Value and source |
|
||||
| --- | --- |
|
||||
| businessVersion | 1, business loader's version requirement (business/src/business.mjs:233). Schema value, not a mos-1554 host choice. |
|
||||
| id | mosaic-stack, row195 DESIGN section7 (sage-1813), restated by row323 brief. |
|
||||
| human | jason, mos-1554 relayed by sage-3823. |
|
||||
| arbiters.delivery / arbiters.technical | chief / chief, sage-3820's explicit minimal-role ruling; both reference the chief-of-staff definition. |
|
||||
| projects.mosaic-stack.root | /srv/mosaic/src/mosaic-stack, sage-3822's explicit project-id/root decision. DESIGN supplied /srv/mosaic as home but did not supply this root. |
|
||||
| vars.bus.tester | true, DESIGN section2 factory profile and installer bus-tester-off requirement. |
|
||||
| plugins.harness | [claude-code], brief's Claude-only/P19 mos-0865 boundary and DESIGN section3. |
|
||||
| roles.chief.definition | chief-of-staff, brief/roles/chief-of-staff.json; no credential or tracker bot. |
|
||||
| roles.assistant.definition | assistant, sage-3820 acceptance of this one extra credential-free role, required because launch.instances must be nonempty and exclude chief. |
|
||||
| roles.*.vars.harness | claude-code for both; same Claude-only rule, no fallback route. |
|
||||
| roles.*.vars.model | claude-opus-5-5 for both, chief model explicitly chosen in sage-3822; reuse the single declared model for assistant to avoid adding another model decision. |
|
||||
| roles.*.vars.thinking | high, supported effort in packages/plugins/harness/claude-code/index.mjs:28; existing chief convention packages/cli/tests/session-launch.test.mjs:83. The repository has no per-model default-effort catalog; this is an explicit supported-convention choice, not an invented catalog default. |
|
||||
| launch.by | chief, brief and F7's requirement to name the chief launch; sage-3820. |
|
||||
| launch.instances | [assistant], sage-3820 and business.mjs:186-191's nonempty/non-launcher rule. |
|
||||
| launch.models.claude-opus-5-5.provider / model | anthropic / claude-opus-5-5, repository model mapping packages/business/README.md:185 and sage-3822. The model id is also the map key so no extra alias is introduced. |
|
||||
| Omitted tracker / credentials / library | Q247/mos-1555 and sage-3859 supersede mos-1554's proposed absent-token placeholder; no bot, token path, credential value or library project/key is present. |
|
||||
|
||||
Only human=jason is a literal field value supplied by mos-1554. The no-bot intent also comes from mos-1554; Q247 implements it by omission. Other fields deliberately cite the actual design/schema/Sage source rather than attributing them to mos-1554.
|
||||
|
||||
Planned runs under sage-3861: new file whole; business suite whole; CLI suite whole; tester-switch mutant; restored new file whole. Fresh scratch TMPDIR and timeout-k10 for each, one process at a time. Existing-test red or any additional old-test edit stops work; first-run new red is recorded and reported. Push remains own branch goldie/row323 after the test sequence/report evidence is concrete.
|
||||
|
||||
## First slot result — stopped on existing CLI test red
|
||||
|
||||
Committed draft before any test: `571376c30e77f46bbe41069633390cc001f57ac8` (base dd6cb5872b972bcabb99b82589b2905e4e47dd3e), per sage-3861. Fresh TMPDIR `/mnt/storage/scratch/tmp/goldielKvBJv`, allocated by mktemp under the mandated scratch parent. All runs serialized with timeout -k 10. Source and test bytes remained unchanged throughout.
|
||||
|
||||
| Run | Command after TMPDIR/timeout | Outcome |
|
||||
| --- | --- | --- |
|
||||
| New whole file | timeout -k 10 180 node --test --test-reporter=tap packages/business/tests/case1-business-file.test.mjs | 7/7 pass, 0 fail/skip/cancel; test-new.log |
|
||||
| Whole business suite | timeout -k 10 600 node --test --test-reporter=tap --test-concurrency=1 packages/business/tests/ | 261/261 pass, 0 fail/skip/cancel; test-business-suite.log |
|
||||
| Whole CLI suite | timeout -k 10 1200 node --test --test-reporter=tap --test-concurrency=1 packages/cli/tests/ | Interrupted on existing-test failure; 631 top-level passes/1 failure recorded before termination, no final full-suite totals; test-cli-suite.log |
|
||||
| Tester-switch mutant | Not run | Stop rule reached first |
|
||||
| Restored new file | Not run | Mutant not run; source already matches manifest |
|
||||
|
||||
Existing failure: CLI test579, packages/cli/tests/revoke-window.test.mjs:170: `row 306 REQ-LAUNCH-1 R4 a revoked run whose call lands before the SIGTERM ends stopped/0; a run the human stopped in the same window still ends session-invalid/22`. After 20111.683685ms it failed `no RESULT on 61c275d4-42f9-4a92-8a43-1ae94c25b730`, ask assertion at167, Promise.all index1, test at194. This file has no changes from base. No cause or flakiness claim is established.
|
||||
|
||||
On observing the red in the suite log, sent SIGTERM to this run's verified timeout process; the suite ended exit1 with `Interrupted while running: packages/cli/tests/stack-release-external.test.mjs` (it had progressed while output was being inspected). One remaining process was positively identified by an argument under this exact scratch TMPDIR and cleaned with SIGTERM; no live host process/state touched. No further tests or reruns, no old test changes.
|
||||
|
||||
Reported immediately to Sage as goldie-0016, returned slot, requested the bounded investigation/retry ruling. JSON/new-test manifest still verifies. Candidate is not verified/ready for review and has not been pushed as such. These failure notes/logs remain local evidence until Sage's ruling; no mutant acceptance claim.
|
||||
|
||||
## Mos-1560 isolated rerun — second red, stopped
|
||||
|
||||
Sage-3864 CLEAR authorized three whole-file revoke-window runs on unchanged571376c30, followed only on three passes by CLI/mutant/restored runs. Every run had to begin with date -u, uptime and the last line of sage-busy-now.sh. Only run1 occurred; it failed, reaching the second-red stop condition.
|
||||
|
||||
Log: revoke-window-run-1.log. Exact start/load lines:
|
||||
|
||||
```
|
||||
Sat Oct 24 00:24:22 UTC 2026
|
||||
19:24:22 up 10 days, 10:44, 2 users, load average: 1.46, 1.68, 2.70
|
||||
CLEAR
|
||||
TMPDIR=/mnt/storage/scratch/tmp/goldieBObDQ9
|
||||
```
|
||||
|
||||
Command: `TMPDIR=<fresh mktemp above> timeout -k 10 180 node --test --test-reporter=tap packages/cli/tests/revoke-window.test.mjs`.
|
||||
|
||||
Whole file result: 0/1 pass, 1 failure, exit1, no skips/cancellations. Same unchanged row306 R4 test at revoke-window.test.mjs:170 failed after20126.967034ms: `no RESULT on dc4ccc94-4d08-4da1-bc66-2f4fcfc098dc`, ask167, Promise.all index1, test194. No edits, no run2/3, no full CLI rerun, no mutant/restored run. No remaining process with this exact run's scratch path was found after the file finished. Manifest still matches. This proves repeat failure under the recorded CLEAR/load conditions, not a root cause.
|
||||
|
||||
Reported to Sage as goldie-0018 with full log path, time/load/busy lines, test and error. Slot returned; holding for root-cause assignment/ruling on base dd6cb5872. Remote goldie/row323 stays the unverified draft571376c30; post-run failure evidence remains local. All row323 product/test sources are still exactly those committed before the first slot.
|
||||
|
||||
## Root cause correction and accepted evidence — mos-1561, sage-3869
|
||||
|
||||
The repeated revoke-window red was caused by TMPDIR length, not Codex. Sage identified and reproduced it (sage-3869; recorded in their local dda2d07b4): newRun rejects a tools.sock path exceeding107 bytes at packages/seat/src/session.mjs:278. The 37-byte `/mnt/storage/scratch/tmp/goldieXXXXXX` combined with revoke-window's fixture root produces109 bytes, while Sage's35-byte prefix fits107. The test drops the launcher refusal log, so the symptom was a20s no-RESULT wait. The detached base dd6cb5872 also failed0/1 after20106.869486ms with long TMPDIR (log copied as revoke-window-detached-base.log). Prior messages attributing the difference to an unspecified environment are superseded by this diagnosis; no product/old-test fix is needed for row323.
|
||||
|
||||
From now on every suite uses `export TMPDIR=$(mktemp -d /mnt/storage/scratch/tmp/XXXXXX)` (31bytes). Under sage-3869 CLEAR, all source/test bytes remained exactly571376c30; each run got its own fresh31-byte TMPDIR and began with date-u, uptime, and busy-helper's last line:
|
||||
|
||||
| Run / log | UTC start | Load averages | Busy | Result |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| revoke-window-short-tmp.log | 2026-10-24 00:36:41 | 1.01,1.34,1.91 | CLEAR | Whole file1/1 pass; test703.937594ms, file838.51754ms; TMPDIR lCsnmg |
|
||||
| test-business-short-tmp.log | 2026-10-24 00:36:50 | 1.08,1.35,1.91 | CLEAR | Whole suite261/261 pass, 6415.261266ms; TMPDIR flQGnB |
|
||||
| test-new-short-tmp.log | 2026-10-24 00:37:03 | 1.37,1.40,1.92 | CLEAR | Whole new file7/7 pass, 611.516605ms; TMPDIR HNhoti |
|
||||
| mutant-driver.log + mutant.log | 2026-10-24 00:37:12 | 1.38,1.41,1.92 | CLEAR | Mutant killed: targeted exact-document test0/1, expected exit1; driver exit0; TMPDIR XAmmxp |
|
||||
| test-new-restored.log | 2026-10-24 00:37:25 | 1.61,1.46,1.93 | CLEAR | Restored new file7/7 pass, 616.699665ms; TMPDIR IVLOcQ |
|
||||
|
||||
All TMPDIRs above are under /mnt/storage/scratch/tmp/. Every run used timeout-k10 (180s limit, business suite600s), one run at a time. Positive runs have0 failures/skips/cancellations. These are fresh accepted row323 results on committed571376c30. No full CLI rerun was authorized or performed: under mos-1561, Sage's landing gate supplies the CLI suite acceptance/counts, while Goldie's evidence is the business suite, new file and mutant. The earlier partial CLI log remains diagnostic evidence, not a passing-suite claim.
|
||||
|
||||
Required tester-switch mutant:
|
||||
|
||||
```diff
|
||||
- "bus.tester": true
|
||||
+ "bus.tester": false
|
||||
```
|
||||
|
||||
It fails the new committed-document assertion at case1-business-file.test.mjs:66 (`false !== true`). mutant.py restores exact original bytes in finally; restored JSON SHA256 `85e6b9ac62785734fb9d854ea70ea550e171b5060b5cad0417e2f42f0b60c59e`. Both manifest entries verify after restoration, and the whole new file passes again. No pre-existing test or production source changed. git diff --check passes.
|
||||
|
||||
Committing these notes and preserved logs is evidence-only on top of the tested571376c30. The product manifest remains the same two paths/digests. Duckworth's static review and Sage's integration/full gate remain their authority. Logs have trailing whitespace removed for git diff --check; substantive output/results are unchanged. Earlier stopped runs remain recorded honestly above and in their logs.
|
||||
@@ -0,0 +1,2 @@
|
||||
85e6b9ac62785734fb9d854ea70ea550e171b5060b5cad0417e2f42f0b60c59e docs/guides/case1-business.mosaic-stack.json
|
||||
aecd1c0f0fe1eb46800a7dea6f1f2f6a965f1daa33a39f9837bb1e2c19e0abfc packages/business/tests/case1-business-file.test.mjs
|
||||
@@ -0,0 +1,6 @@
|
||||
Sat Oct 24 00:37:12 UTC 2026
|
||||
19:37:12 up 10 days, 10:57, 2 users, load average: 1.38, 1.41, 1.92
|
||||
CLEAR
|
||||
TMPDIR=/mnt/storage/scratch/tmp/XAmmxp bytes=31
|
||||
bus.tester mutant: exit 1
|
||||
restored sha256 85e6b9ac62785734fb9d854ea70ea550e171b5060b5cad0417e2f42f0b60c59e
|
||||
@@ -0,0 +1,34 @@
|
||||
TAP version 13
|
||||
# Subtest: row 323: committed Case 1 document loads unchanged through the business loader
|
||||
not ok 1 - row 323: committed Case 1 document loads unchanged through the business loader
|
||||
---
|
||||
duration_ms: 4.020549
|
||||
type: 'test'
|
||||
location: '/home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row323/packages/business/tests/case1-business-file.test.mjs:57:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
Expected values to be strictly equal:
|
||||
|
||||
false !== true
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected: true
|
||||
actual: false
|
||||
operator: 'strictEqual'
|
||||
stack: |-
|
||||
TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row323/packages/business/tests/case1-business-file.test.mjs:66:10)
|
||||
Test.runInAsyncScope (node:async_hooks:226:14)
|
||||
Test.run (node:internal/test_runner/test:1402:25)
|
||||
Test.start (node:internal/test_runner/test:1262:17)
|
||||
startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17)
|
||||
...
|
||||
1..1
|
||||
# tests 1
|
||||
# suites 0
|
||||
# pass 0
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 113.96636
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Row 323 tester-switch mutant; run only after Sage CLEAR."""
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
root = Path(__file__).resolve().parents[4]
|
||||
source = root / 'docs/guides/case1-business.mosaic-stack.json'
|
||||
fixed = source.read_bytes()
|
||||
needle = b'"bus.tester": true'
|
||||
assert fixed.count(needle) == 1
|
||||
try:
|
||||
source.write_bytes(fixed.replace(needle, b'"bus.tester": false'))
|
||||
result = subprocess.run([
|
||||
'timeout', '-k', '10', '90', 'node', '--test', '--test-reporter=tap',
|
||||
'--test-name-pattern=row 323: committed Case 1 document loads',
|
||||
'packages/business/tests/case1-business-file.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'bus.tester mutant: exit {result.returncode}', flush=True)
|
||||
assert result.returncode == 1, result.stdout
|
||||
assert '# fail 1\n' in result.stdout and 'false !== true' 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,39 @@
|
||||
Sat Oct 24 00:29:57 UTC 2026
|
||||
19:29:57 up 10 days, 10:49, 2 users, load average: 1.29, 1.30, 2.21
|
||||
CLEAR
|
||||
TMPDIR=/mnt/storage/scratch/tmp/goldieccUVab
|
||||
dd6cb5872b972bcabb99b82589b2905e4e47dd3e
|
||||
v26.8.1
|
||||
/usr/bin/node
|
||||
TAP version 13
|
||||
# Subtest: row 306 REQ-LAUNCH-1 R4 a revoked run whose call lands before the SIGTERM ends stopped/0; a run the human stopped in the same window still ends session-invalid/22
|
||||
not ok 1 - row 306 REQ-LAUNCH-1 R4 a revoked run whose call lands before the SIGTERM ends stopped/0; a run the human stopped in the same window still ends session-invalid/22
|
||||
---
|
||||
duration_ms: 20106.869486
|
||||
type: 'test'
|
||||
location: '/mnt/storage/scratch/tmp/goldie-base2qHbGq/worktree/packages/cli/tests/revoke-window.test.mjs:170:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: 'no RESULT on 94ddbce3-5e46-49a7-8f63-60af8995c10d'
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected: true
|
||||
operator: '=='
|
||||
stack: |-
|
||||
ask (file:///mnt/storage/scratch/tmp/goldie-base2qHbGq/worktree/packages/cli/tests/revoke-window.test.mjs:167:10)
|
||||
runNextTicks (node:internal/process/task_queues:65:5)
|
||||
listOnTimeout (node:internal/timers:644:9)
|
||||
process.processTimers (node:internal/timers:618:7)
|
||||
async Promise.all (index 1)
|
||||
async TestContext.<anonymous> (file:///mnt/storage/scratch/tmp/goldie-base2qHbGq/worktree/packages/cli/tests/revoke-window.test.mjs:194:3)
|
||||
async Test.run (node:internal/test_runner/test:1409:7)
|
||||
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
|
||||
...
|
||||
1..1
|
||||
# tests 1
|
||||
# suites 0
|
||||
# pass 0
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 20235.90436
|
||||
@@ -0,0 +1,33 @@
|
||||
Sat Oct 24 00:24:22 UTC 2026
|
||||
19:24:22 up 10 days, 10:44, 2 users, load average: 1.46, 1.68, 2.70
|
||||
CLEAR
|
||||
TMPDIR=/mnt/storage/scratch/tmp/goldieBObDQ9
|
||||
TAP version 13
|
||||
# Subtest: row 306 REQ-LAUNCH-1 R4 a revoked run whose call lands before the SIGTERM ends stopped/0; a run the human stopped in the same window still ends session-invalid/22
|
||||
not ok 1 - row 306 REQ-LAUNCH-1 R4 a revoked run whose call lands before the SIGTERM ends stopped/0; a run the human stopped in the same window still ends session-invalid/22
|
||||
---
|
||||
duration_ms: 20126.967034
|
||||
type: 'test'
|
||||
location: '/home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row323/packages/cli/tests/revoke-window.test.mjs:170:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: 'no RESULT on dc4ccc94-4d08-4da1-bc66-2f4fcfc098dc'
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected: true
|
||||
operator: '=='
|
||||
stack: |-
|
||||
ask (file:///home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row323/packages/cli/tests/revoke-window.test.mjs:167:10)
|
||||
async Promise.all (index 1)
|
||||
async TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row323/packages/cli/tests/revoke-window.test.mjs:194:3)
|
||||
async Test.run (node:internal/test_runner/test:1409:7)
|
||||
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
|
||||
...
|
||||
1..1
|
||||
# tests 1
|
||||
# suites 0
|
||||
# pass 0
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 20266.295192
|
||||
@@ -0,0 +1,22 @@
|
||||
Sat Oct 24 00:36:41 UTC 2026
|
||||
19:36:41 up 10 days, 10:56, 2 users, load average: 1.01, 1.34, 1.91
|
||||
CLEAR
|
||||
TMPDIR=/mnt/storage/scratch/tmp/lCsnmg bytes=31
|
||||
TAP version 13
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r73cb69bf7381","instance":"assistant","turn":"7b64df9d-f571-4f43-a678-dbd5ce7714a9","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r89b03e6f1158","instance":"assistant","turn":"aaa36964-0a78-4ba1-9442-d6f0aa35f18e","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: row 306 REQ-LAUNCH-1 R4 a revoked run whose call lands before the SIGTERM ends stopped/0; a run the human stopped in the same window still ends session-invalid/22
|
||||
ok 1 - row 306 REQ-LAUNCH-1 R4 a revoked run whose call lands before the SIGTERM ends stopped/0; a run the human stopped in the same window still ends session-invalid/22
|
||||
---
|
||||
duration_ms: 703.937594
|
||||
type: 'test'
|
||||
...
|
||||
1..1
|
||||
# tests 1
|
||||
# suites 0
|
||||
# pass 1
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 838.51754
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,56 @@
|
||||
Sat Oct 24 00:37:25 UTC 2026
|
||||
19:37:25 up 10 days, 10:57, 2 users, load average: 1.61, 1.46, 1.93
|
||||
CLEAR
|
||||
TMPDIR=/mnt/storage/scratch/tmp/IVLOcQ bytes=31
|
||||
TAP version 13
|
||||
# Subtest: row 323: committed Case 1 document loads unchanged through the business loader
|
||||
ok 1 - row 323: committed Case 1 document loads unchanged through the business loader
|
||||
---
|
||||
duration_ms: 3.816879
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 323: real business validate accepts the Case 1 fixture without tracker credentials
|
||||
ok 2 - row 323: real business validate accepts the Case 1 fixture without tracker credentials
|
||||
---
|
||||
duration_ms: 70.395579
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 323: real factory install accepts the document's scratch input and writes it unchanged
|
||||
ok 3 - row 323: real factory install accepts the document's scratch input and writes it unchanged
|
||||
---
|
||||
duration_ms: 164.092704
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 323: the Case 1 business boots without a tracker or @sync credential
|
||||
ok 4 - row 323: the Case 1 business boots without a tracker or @sync credential
|
||||
---
|
||||
duration_ms: 48.455987
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 323: factory install refuses bus.tester false before writing
|
||||
ok 5 - row 323: factory install refuses bus.tester false before writing
|
||||
---
|
||||
duration_ms: 32.684801
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 323: literal library key refuses through final business validation
|
||||
ok 6 - row 323: literal library key refuses through final business validation
|
||||
---
|
||||
duration_ms: 147.619094
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 323: a library project refuses through the factory profile plan
|
||||
ok 7 - row 323: a library project refuses through the factory profile plan
|
||||
---
|
||||
duration_ms: 37.280986
|
||||
type: 'test'
|
||||
...
|
||||
1..7
|
||||
# tests 7
|
||||
# suites 0
|
||||
# pass 7
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 616.699665
|
||||
@@ -0,0 +1,56 @@
|
||||
Sat Oct 24 00:37:03 UTC 2026
|
||||
19:37:03 up 10 days, 10:56, 2 users, load average: 1.37, 1.40, 1.92
|
||||
CLEAR
|
||||
TMPDIR=/mnt/storage/scratch/tmp/HNhoti bytes=31
|
||||
TAP version 13
|
||||
# Subtest: row 323: committed Case 1 document loads unchanged through the business loader
|
||||
ok 1 - row 323: committed Case 1 document loads unchanged through the business loader
|
||||
---
|
||||
duration_ms: 4.019742
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 323: real business validate accepts the Case 1 fixture without tracker credentials
|
||||
ok 2 - row 323: real business validate accepts the Case 1 fixture without tracker credentials
|
||||
---
|
||||
duration_ms: 71.809045
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 323: real factory install accepts the document's scratch input and writes it unchanged
|
||||
ok 3 - row 323: real factory install accepts the document's scratch input and writes it unchanged
|
||||
---
|
||||
duration_ms: 158.271252
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 323: the Case 1 business boots without a tracker or @sync credential
|
||||
ok 4 - row 323: the Case 1 business boots without a tracker or @sync credential
|
||||
---
|
||||
duration_ms: 47.408675
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 323: factory install refuses bus.tester false before writing
|
||||
ok 5 - row 323: factory install refuses bus.tester false before writing
|
||||
---
|
||||
duration_ms: 33.173372
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 323: literal library key refuses through final business validation
|
||||
ok 6 - row 323: literal library key refuses through final business validation
|
||||
---
|
||||
duration_ms: 157.331263
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 323: a library project refuses through the factory profile plan
|
||||
ok 7 - row 323: a library project refuses through the factory profile plan
|
||||
---
|
||||
duration_ms: 31.660009
|
||||
type: 'test'
|
||||
...
|
||||
1..7
|
||||
# tests 7
|
||||
# suites 0
|
||||
# pass 7
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 611.516605
|
||||
@@ -0,0 +1,52 @@
|
||||
TAP version 13
|
||||
# Subtest: row 323: committed Case 1 document loads unchanged through the business loader
|
||||
ok 1 - row 323: committed Case 1 document loads unchanged through the business loader
|
||||
---
|
||||
duration_ms: 4.475258
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 323: real business validate accepts the Case 1 fixture without tracker credentials
|
||||
ok 2 - row 323: real business validate accepts the Case 1 fixture without tracker credentials
|
||||
---
|
||||
duration_ms: 60.93225
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 323: real factory install accepts the document's scratch input and writes it unchanged
|
||||
ok 3 - row 323: real factory install accepts the document's scratch input and writes it unchanged
|
||||
---
|
||||
duration_ms: 166.799569
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 323: the Case 1 business boots without a tracker or @sync credential
|
||||
ok 4 - row 323: the Case 1 business boots without a tracker or @sync credential
|
||||
---
|
||||
duration_ms: 48.481068
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 323: factory install refuses bus.tester false before writing
|
||||
ok 5 - row 323: factory install refuses bus.tester false before writing
|
||||
---
|
||||
duration_ms: 33.722908
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 323: literal library key refuses through final business validation
|
||||
ok 6 - row 323: literal library key refuses through final business validation
|
||||
---
|
||||
duration_ms: 151.757296
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 323: a library project refuses through the factory profile plan
|
||||
ok 7 - row 323: a library project refuses through the factory profile plan
|
||||
---
|
||||
duration_ms: 33.339369
|
||||
type: 'test'
|
||||
...
|
||||
1..7
|
||||
# tests 7
|
||||
# suites 0
|
||||
# pass 7
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 613.804933
|
||||
@@ -0,0 +1,90 @@
|
||||
# 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.
|
||||
|
||||
## Rebased verification — sage-3850 CLEAR
|
||||
|
||||
Rebased c4b14952e onto pushed refactor `3569bf54f53870fcd4e92cdbbb95e3412f629f3a` as `7e59ba8b3d6f63c30a0fa308c0607dfc1e47dd12`. The landing range has no changes in this row's business/config/Console source paths. Only BUILD-LOG.md and docs/SESSIONS.md conflicted: retained the entire landed file then appended this row's original suffix. All eight manifest-pinned files remain byte-equal to the draft; range-diff differences are log context only. To obey no-force-push while preserving the published draft, the rebased candidate is published on `goldie/row324-r2`; original remote goldie/row324 remains at c4b14952e.
|
||||
|
||||
Scratch TMPDIR was created by `mktemp -d /mnt/storage/scratch/tmp/goldieXXXXXX`: `/mnt/storage/scratch/tmp/goldieOdgIjr`. All runs were serialized, with timeout -k 10 (120s new files/mutant driver, 180s existing whole file). Node v26.8.1. No test used /tmp or a real host configuration/credential.
|
||||
|
||||
| Order | Whole file / check | Result | Evidence |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | packages/business/tests/optional-tracker.test.mjs | 5/5 pass, exit0 | test-business-optional.log |
|
||||
| 2 | packages/cli/tests/optional-tracker.test.mjs | 4/4 pass, exit0 | test-cli-optional.log |
|
||||
| 3 | packages/business/tests/business.test.mjs | 21/21 pass, exit0 | test-business-existing.log |
|
||||
| 4 | Presence-check mutant, targeted omission test | 0/1 pass, exit1, killed as expected | mutant.log, mutant-driver.log |
|
||||
| 5 | Restored new business file whole | 5/5 pass, exit0 | test-business-restored.log |
|
||||
| 6 | Restored new CLI file whole | 4/4 pass, exit0 | test-cli-restored.log |
|
||||
|
||||
No unexpected first-run red or old-test red. The Console absent-tracker read test is the third test in the new CLI file and passed on both whole-file runs. There is no separate edited webui test file; the only test files touched by this row are the three whole files above. Existing fixture preservation is covered by the whole business.test.mjs, including its untouched fixture, role bot, credential, sync bot collision and example checks. Broader package/script gates remain Sage's.
|
||||
|
||||
Required mutant diff (business/src/business.mjs:296):
|
||||
|
||||
```diff
|
||||
- const tracker = Object.hasOwn(document, "tracker") ? checkTracker(document.tracker, file) : null;
|
||||
+ const tracker = checkTracker(document.tracker, file);
|
||||
```
|
||||
|
||||
The new omission test fails with BusinessError `tracker must be a JSON object`. mutant.py restores in finally and checks exact bytes. Restored business.mjs SHA256: `e76db42158f396ec6a44f97320355d6c2254dccb5fc53cd04e8ac42843b0d60a`. Full manifest check passes after restoration. git diff --check passes. The full reader list remains above; reader-audit.txt is its raw grep receipt.
|
||||
|
||||
The only pre-existing test delta still is exactly one list entry removed at business.test.mjs:98, under mos-1556: tracker is removed; human/arbiters/projects/roles and the assertion remain. No production/test source changed during verification. Only evidence and append-only log notes follow the tested rebased commit. Slot is returned in the completion report; Duckworth review and Sage's full landing gate remain pending.
|
||||
|
||||
Evidence log trailing whitespace is normalized for git diff --check; test results and output content are unchanged.
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
presence mutant: exit 1
|
||||
restored sha256 e76db42158f396ec6a44f97320355d6c2254dccb5fc53cd04e8ac42843b0d60a
|
||||
@@ -0,0 +1,32 @@
|
||||
TAP version 13
|
||||
# Subtest: Q247: omitting tracker loads as null and preserves independent role bots
|
||||
not ok 1 - Q247: omitting tracker loads as null and preserves independent role bots
|
||||
---
|
||||
duration_ms: 4.653674
|
||||
type: 'test'
|
||||
location: '/home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row324/packages/business/tests/optional-tracker.test.mjs:21:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: '/mnt/storage/scratch/tmp/goldieOdgIjr/mosaic-business-IHRJco/config/businesses/acme.json tracker must be a JSON object'
|
||||
code: 'ERR_TEST_FAILURE'
|
||||
name: 'BusinessError'
|
||||
stack: |-
|
||||
refuse (file:///home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row324/packages/business/src/errors.mjs:12:9)
|
||||
requireObject (file:///home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row324/packages/business/src/util.mjs:11:30)
|
||||
checkTracker (file:///home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row324/packages/business/src/business.mjs:87:3)
|
||||
validateBusinessDocument (file:///home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row324/packages/business/src/business.mjs:296:19)
|
||||
loadBusiness (file:///home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row324/packages/business/src/business.mjs:325:10)
|
||||
TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row324/packages/business/tests/optional-tracker.test.mjs:25:13)
|
||||
Test.runInAsyncScope (node:async_hooks:226:14)
|
||||
Test.run (node:internal/test_runner/test:1402:25)
|
||||
Test.start (node:internal/test_runner/test:1262:17)
|
||||
startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17)
|
||||
...
|
||||
1..1
|
||||
# tests 1
|
||||
# suites 0
|
||||
# pass 0
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 54.363835
|
||||
@@ -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));
|
||||
@@ -0,0 +1,136 @@
|
||||
TAP version 13
|
||||
# Subtest: C2-04: library mappings load without roots, remain frozen, and retain mapping refusals
|
||||
ok 1 - C2-04: library mappings load without roots, remain frozen, and retain mapping refusals
|
||||
---
|
||||
duration_ms: 10.358877
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-04: an otherwise valid publish grant cannot name a library repo
|
||||
ok 2 - C2-04: an otherwise valid publish grant cannot name a library repo
|
||||
---
|
||||
duration_ms: 1.405968
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: config directory and file path follow MOSAIC_CONFIG
|
||||
ok 3 - config directory and file path follow MOSAIC_CONFIG
|
||||
---
|
||||
duration_ms: 0.194682
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: the fixture business validates and comes back frozen
|
||||
ok 4 - the fixture business validates and comes back frozen
|
||||
---
|
||||
duration_ms: 0.883527
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: two instances may share a definition
|
||||
ok 5 - two instances may share a definition
|
||||
---
|
||||
duration_ms: 0.869413
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: top-level refusals
|
||||
ok 6 - top-level refusals
|
||||
---
|
||||
duration_ms: 2.622608
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: arbiters and projects
|
||||
ok 7 - arbiters and projects
|
||||
---
|
||||
duration_ms: 3.523393
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: role instances
|
||||
ok 8 - role instances
|
||||
---
|
||||
duration_ms: 2.015683
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: Vikunja bots
|
||||
ok 9 - Vikunja bots
|
||||
---
|
||||
duration_ms: 4.64219
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a role without Vikunja takes no tracker block
|
||||
ok 10 - a role without Vikunja takes no tracker block
|
||||
---
|
||||
duration_ms: 5.70969
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: credential references match the definition's services
|
||||
ok 11 - credential references match the definition's services
|
||||
---
|
||||
duration_ms: 1.94586
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: launch
|
||||
ok 12 - launch
|
||||
---
|
||||
duration_ms: 6.957271
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1 launch.threadIdleSeconds: absent is the default, a positive integer is used, anything else refuses
|
||||
ok 13 - REQ-THREAD-2 REQ-LAUNCH-1 launch.threadIdleSeconds: absent is the default, a positive integer is used, anything else refuses
|
||||
---
|
||||
duration_ms: 3.58016
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: loadBusiness: file checks
|
||||
ok 14 - loadBusiness: file checks
|
||||
---
|
||||
duration_ms: 1.772126
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: loadBusiness: not a regular file
|
||||
ok 15 - loadBusiness: not a regular file
|
||||
---
|
||||
duration_ms: 40.121918
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: loading writes nothing
|
||||
ok 16 - loading writes nothing
|
||||
---
|
||||
duration_ms: 1.07771
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: names that are Object.prototype properties don't count as declared
|
||||
ok 17 - names that are Object.prototype properties don't count as declared
|
||||
---
|
||||
duration_ms: 3.194384
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: the shipped example refuses as written and validates once filled in
|
||||
ok 18 - the shipped example refuses as written and validates once filled in
|
||||
---
|
||||
duration_ms: 0.919964
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: persona vars: agent layer, creative definition only, both or neither
|
||||
ok 19 - persona vars: agent layer, creative definition only, both or neither
|
||||
---
|
||||
duration_ms: 7.866405
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 194 keep, noHire and retired validate, and a file without them loads as before
|
||||
ok 20 - row 194 keep, noHire and retired validate, and a file without them loads as before
|
||||
---
|
||||
duration_ms: 1.704708
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 194 each bad keep, noHire or retired entry refuses
|
||||
ok 21 - row 194 each bad keep, noHire or retired entry refuses
|
||||
---
|
||||
duration_ms: 5.095541
|
||||
type: 'test'
|
||||
...
|
||||
1..21
|
||||
# tests 21
|
||||
# suites 0
|
||||
# pass 21
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 159.214713
|
||||
@@ -0,0 +1,40 @@
|
||||
TAP version 13
|
||||
# Subtest: Q247: omitting tracker loads as null and preserves independent role bots
|
||||
ok 1 - Q247: omitting tracker loads as null and preserves independent role bots
|
||||
---
|
||||
duration_ms: 5.298841
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: Q247: real business validate passes without a sync credential reference or file
|
||||
ok 2 - Q247: real business validate passes without a sync credential reference or file
|
||||
---
|
||||
duration_ms: 71.475563
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: Q247: present tracker retains its values and validation without mutating the input
|
||||
ok 3 - Q247: present tracker retains its values and validation without mutating the input
|
||||
---
|
||||
duration_ms: 2.384907
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: Q247: present null, undefined and malformed trackers still refuse
|
||||
ok 4 - Q247: present null, undefined and malformed trackers still refuse
|
||||
---
|
||||
duration_ms: 3.746
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: Q247: absent business tracker does not waive a role's Vikunja bot requirement
|
||||
ok 5 - Q247: absent business tracker does not waive a role's Vikunja bot requirement
|
||||
---
|
||||
duration_ms: 0.804648
|
||||
type: 'test'
|
||||
...
|
||||
1..5
|
||||
# tests 5
|
||||
# suites 0
|
||||
# pass 5
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 135.467249
|
||||
@@ -0,0 +1,40 @@
|
||||
TAP version 13
|
||||
# Subtest: Q247: omitting tracker loads as null and preserves independent role bots
|
||||
ok 1 - Q247: omitting tracker loads as null and preserves independent role bots
|
||||
---
|
||||
duration_ms: 5.02203
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: Q247: real business validate passes without a sync credential reference or file
|
||||
ok 2 - Q247: real business validate passes without a sync credential reference or file
|
||||
---
|
||||
duration_ms: 68.916604
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: Q247: present tracker retains its values and validation without mutating the input
|
||||
ok 3 - Q247: present tracker retains its values and validation without mutating the input
|
||||
---
|
||||
duration_ms: 2.414133
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: Q247: present null, undefined and malformed trackers still refuse
|
||||
ok 4 - Q247: present null, undefined and malformed trackers still refuse
|
||||
---
|
||||
duration_ms: 3.820979
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: Q247: absent business tracker does not waive a role's Vikunja bot requirement
|
||||
ok 5 - Q247: absent business tracker does not waive a role's Vikunja bot requirement
|
||||
---
|
||||
duration_ms: 0.873886
|
||||
type: 'test'
|
||||
...
|
||||
1..5
|
||||
# tests 5
|
||||
# suites 0
|
||||
# pass 5
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 135.196828
|
||||
@@ -0,0 +1,34 @@
|
||||
TAP version 13
|
||||
# Subtest: Q247: bootConfig omits tracker setup even with tracker variables left in the business
|
||||
ok 1 - Q247: bootConfig omits tracker setup even with tracker variables left in the business
|
||||
---
|
||||
duration_ms: 5.854825
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: Q247: trackerless business boots and registers no @sync credential
|
||||
ok 2 - Q247: trackerless business boots and registers no @sync credential
|
||||
---
|
||||
duration_ms: 48.935736
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: Q247: Console business read works with tracker absent and shows no sync credential
|
||||
ok 3 - Q247: Console business read works with tracker absent and shows no sync credential
|
||||
---
|
||||
duration_ms: 1.663483
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: Q247: a present tracker with a missing token still refuses bus boot
|
||||
ok 4 - Q247: a present tracker with a missing token still refuses bus boot
|
||||
---
|
||||
duration_ms: 11.355417
|
||||
type: 'test'
|
||||
...
|
||||
1..4
|
||||
# tests 4
|
||||
# suites 0
|
||||
# pass 4
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 226.91643
|
||||
@@ -0,0 +1,34 @@
|
||||
TAP version 13
|
||||
# Subtest: Q247: bootConfig omits tracker setup even with tracker variables left in the business
|
||||
ok 1 - Q247: bootConfig omits tracker setup even with tracker variables left in the business
|
||||
---
|
||||
duration_ms: 5.203349
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: Q247: trackerless business boots and registers no @sync credential
|
||||
ok 2 - Q247: trackerless business boots and registers no @sync credential
|
||||
---
|
||||
duration_ms: 46.033814
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: Q247: Console business read works with tracker absent and shows no sync credential
|
||||
ok 3 - Q247: Console business read works with tracker absent and shows no sync credential
|
||||
---
|
||||
duration_ms: 1.547096
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: Q247: a present tracker with a missing token still refuses bus boot
|
||||
ok 4 - Q247: a present tracker with a missing token still refuses bus boot
|
||||
---
|
||||
duration_ms: 11.051525
|
||||
type: 'test'
|
||||
...
|
||||
1..4
|
||||
# tests 4
|
||||
# suites 0
|
||||
# pass 4
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 195.359585
|
||||
@@ -0,0 +1,48 @@
|
||||
# Row 325 / #1821 — Case 1 project key
|
||||
|
||||
Goldie, 2026-10-24. Branch goldie/row325, base99930362e4b8a64d5425b10ca087b2d1f93ae538. Separate worktree /home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row325. Brief pinned at60d78e44e:docs/plans/2026-10-24_case1-project-key-brief.md, section Brief: re-key the Case1 project to stack. Mos-1563 fixes the business document and preserves project id. Reviewer Gladstone; Sage owns review posting/landing. No queue writes authorized or performed.
|
||||
|
||||
## Defect and change
|
||||
|
||||
The real .mosaic/project.json declares id stack; the Case1 business document incorrectly keyed its project mosaic-stack. Real business validation checks equality at packages/business/src/cli.mjs:80, so the install refused once the host held the project file. Row323's scratch execution root omitted that file and exercised only the warning/absent branch, missing the deployment mismatch.
|
||||
|
||||
Only the projects object's key changes to stack in docs/guides/case1-business.mosaic-stack.json. Business id stays mosaic-stack, root stays /srv/mosaic/src/mosaic-stack, and every other field is unchanged. No .mosaic/project.json edit or product/schema/role change.
|
||||
|
||||
The new packages/business/tests/case1-project-key.test.mjs reads the repo's real .mosaic/project.json bytes, asserts exactly one matching Case1 key, copies those bytes unchanged into a scratch project root, and calls the actual business CLI validate path with a temporary system/business config. Positive output must report the project valid, not absent. Negative coverage re-keys the same fixture to mosaic-stack and requires exit2 plus the real project-id mismatch message; missing-file warnings cannot stand in for validation.
|
||||
|
||||
Mos-1564 (mos-1272) authorizes exactly one pre-existing test line: case1-business-file.test.mjs:70's expected key changes to stack; all other bytes/assertions remain unchanged. No other old test is touched. This is explicitly the obsolete expectation caused by the product correction, not a relaxed assertion.
|
||||
|
||||
## Verification plan / state
|
||||
|
||||
No tests run before Sage CLEAR. Static node --check, JSON parse and diff checks only. On CLEAR, one run at a time: new test file whole, business suite whole (includes the one updated old test), one old-key mutant, restored whole new file to verify restoration. Every run gets fresh `export TMPDIR=$(mktemp -d /mnt/storage/scratch/tmp/XXXXXX)`, timeout-k10, and the established UTC/load/busy-helper log header. Stop/report on any other old-test red; no further old-test edits. First unexpected new-test red is recorded/reported under the continuing mos-1272 rule.
|
||||
|
||||
mutant.py changes only the projects key back to mosaic-stack, runs the new exact-key assertion, requires expected exit1/fail1, then restores/checks bytes in finally. Prepared, not run. Sage's landing gate supplies CLI suite counts.
|
||||
|
||||
The dependency setup is an ignored root node_modules symlink to /mnt/storage/src/mosaic-stack/node_modules; no dependency installation/pin change. MANIFEST.sha256 covers business JSON, the one old test, new test, and real project document as an unchanged reference. Final review report goes to both Sage and Gladstone with tip/base/manifest. No host contact, credential access, queue write or refactor push.
|
||||
|
||||
## Verification — sage-3882 CLEAR
|
||||
|
||||
All four runs were serial on the same final source bytes, using timeout-k10, fresh31-byte TMPDIRs from /mnt/storage/scratch/tmp/XXXXXX, and date-u/uptime/busy-helper headers. Node v26.8.1. No first-run new-test red and no old-test red. No CLI suite run; Sage's landing gate supplies that scope.
|
||||
|
||||
| Run / log | UTC start | Load averages | Busy | Result / TMPDIR |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| test-new.log | 2026-10-24 01:13:40 | 1.34,1.60,2.50 | CLEAR | Whole new file3/3 pass, 172.161691ms; hchZu2 |
|
||||
| test-business.log | 2026-10-24 01:13:50 | 1.36,1.59,2.49 | CLEAR | Whole business suite264/264 pass, 6478.924028ms; yWVbwY |
|
||||
| mutant-driver.log + mutant.log | 2026-10-24 01:14:04 | 1.36,1.58,2.47 | CLEAR | Old-key mutant killed, targeted new test0/1, expected exit1; driver exit0; YsOE6N |
|
||||
| test-restored.log | 2026-10-24 01:14:14 | 1.15,1.53,2.44 | CLEAR | Restored new file3/3 pass, 169.948442ms; Y43Jju |
|
||||
|
||||
All TMPDIRs are under /mnt/storage/scratch/tmp/. New/restored/mutant driver limits180s, business600s, each timeout-k10. Positive runs have0 failures/skips/cancellations. The business suite includes the entire old case1-business-file.test.mjs, with only the mos-1564-approved key assertion changed.
|
||||
|
||||
Mutant diff in business JSON:
|
||||
|
||||
```diff
|
||||
"projects": {
|
||||
- "stack": {
|
||||
+ "mosaic-stack": {
|
||||
```
|
||||
|
||||
The exact-key new test fails deepStrictEqual (actual mosaic-stack, expected stack); raw evidence is mutant.log. mutant.py restores in finally and checks exact bytes. Restored JSON SHA256 `9e43bfef326e568488aaa4f0d8882e9b932750aeb39f587a113a226bfb30cf0f`; all4 MANIFEST.sha256 entries verify, including unchanged .mosaic/project.json. Whole new file passed again after restoration. git diff --check passed.
|
||||
|
||||
Final source accounting: business JSON1+/1-, only projects key; old test1+/1-, only expected key at70; new test file with3 cases; no other old-test line touched. Business id/root remain unchanged. Root-cause gap is now covered by byte-identical real project fixture plus required valid result and old-key refusal. No host action, credentials, queue writes, refactor push or unapproved scope change.
|
||||
|
||||
Logs have trailing whitespace normalized for git diff --check, with substantive output/results unchanged. The slot is returned in the report accompanying the pushed candidate; Gladstone first review and Sage landing gate remain pending.
|
||||
@@ -0,0 +1,4 @@
|
||||
9e43bfef326e568488aaa4f0d8882e9b932750aeb39f587a113a226bfb30cf0f docs/guides/case1-business.mosaic-stack.json
|
||||
9c3bf7f349d0ee8626743e1388b9a596c7f3c274beaedad5106db5f2ff8f65e7 packages/business/tests/case1-business-file.test.mjs
|
||||
b543ac6afbfb336c5566f359ff357904b36f413d5f75fcd54a5a8b72aef1dbd6 packages/business/tests/case1-project-key.test.mjs
|
||||
044714a37c267b70279977ea8ca429df7a28463271b884afd79a01bc27afa259 .mosaic/project.json
|
||||
@@ -0,0 +1,6 @@
|
||||
Sat Oct 24 01:14:04 UTC 2026
|
||||
20:14:04 up 10 days, 11:33, 2 users, load average: 1.36, 1.58, 2.47
|
||||
CLEAR
|
||||
TMPDIR=/mnt/storage/scratch/tmp/YsOE6N bytes=31
|
||||
old-key mutant: exit 1
|
||||
restored sha256 9e43bfef326e568488aaa4f0d8882e9b932750aeb39f587a113a226bfb30cf0f
|
||||
@@ -0,0 +1,40 @@
|
||||
TAP version 13
|
||||
# Subtest: row 325: the Case 1 project's sole key matches the repository project id
|
||||
not ok 1 - row 325: the Case 1 project's sole key matches the repository project id
|
||||
---
|
||||
duration_ms: 2.058961
|
||||
type: 'test'
|
||||
location: '/home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row325/packages/business/tests/case1-project-key.test.mjs:38:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
Expected values to be strictly deep-equal:
|
||||
+ actual - expected
|
||||
|
||||
[
|
||||
+ 'mosaic-stack'
|
||||
- 'stack'
|
||||
]
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected:
|
||||
0: 'stack'
|
||||
actual:
|
||||
0: 'mosaic-stack'
|
||||
operator: 'deepStrictEqual'
|
||||
stack: |-
|
||||
TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/goldie-row325/packages/business/tests/case1-project-key.test.mjs:42:10)
|
||||
Test.runInAsyncScope (node:async_hooks:226:14)
|
||||
Test.run (node:internal/test_runner/test:1402:25)
|
||||
Test.start (node:internal/test_runner/test:1262:17)
|
||||
startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17)
|
||||
...
|
||||
1..1
|
||||
# tests 1
|
||||
# suites 0
|
||||
# pass 0
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 44.416722
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Row 325 old-project-key mutant; run only in Sage's test slot."""
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
root = Path(__file__).resolve().parents[4]
|
||||
source = root / 'docs/guides/case1-business.mosaic-stack.json'
|
||||
fixed = source.read_bytes()
|
||||
needle = b'"projects": {\n "stack": {'
|
||||
assert fixed.count(needle) == 1
|
||||
try:
|
||||
source.write_bytes(fixed.replace(needle, b'"projects": {\n "mosaic-stack": {'))
|
||||
result = subprocess.run([
|
||||
'timeout', '-k', '10', '90', 'node', '--test', '--test-reporter=tap',
|
||||
'--test-name-pattern=row 325: the Case 1 project',
|
||||
'packages/business/tests/case1-project-key.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'old-key mutant: exit {result.returncode}', flush=True)
|
||||
assert result.returncode == 1, result.stdout
|
||||
assert '# fail 1\n' in result.stdout and 'mosaic-stack' in result.stdout, result.stdout
|
||||
finally:
|
||||
source.write_bytes(fixed)
|
||||
assert source.read_bytes() == fixed
|
||||
print('restored sha256 ' + hashlib.sha256(fixed).hexdigest(), flush=True)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,32 @@
|
||||
Sat Oct 24 01:13:40 UTC 2026
|
||||
20:13:40 up 10 days, 11:33, 2 users, load average: 1.34, 1.60, 2.50
|
||||
CLEAR
|
||||
TMPDIR=/mnt/storage/scratch/tmp/hchZu2 bytes=31
|
||||
TAP version 13
|
||||
# Subtest: row 325: the Case 1 project's sole key matches the repository project id
|
||||
ok 1 - row 325: the Case 1 project's sole key matches the repository project id
|
||||
---
|
||||
duration_ms: 4.262251
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 325: real business validate reports the copied repository project valid
|
||||
ok 2 - row 325: real business validate reports the copied repository project valid
|
||||
---
|
||||
duration_ms: 67.962164
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 325: the old mosaic-stack project key refuses against those same project bytes
|
||||
ok 3 - row 325: the old mosaic-stack project key refuses against those same project bytes
|
||||
---
|
||||
duration_ms: 57.926665
|
||||
type: 'test'
|
||||
...
|
||||
1..3
|
||||
# tests 3
|
||||
# suites 0
|
||||
# pass 3
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 172.161691
|
||||
@@ -0,0 +1,32 @@
|
||||
Sat Oct 24 01:14:14 UTC 2026
|
||||
20:14:14 up 10 days, 11:34, 2 users, load average: 1.15, 1.53, 2.44
|
||||
CLEAR
|
||||
TMPDIR=/mnt/storage/scratch/tmp/Y43Jju bytes=31
|
||||
TAP version 13
|
||||
# Subtest: row 325: the Case 1 project's sole key matches the repository project id
|
||||
ok 1 - row 325: the Case 1 project's sole key matches the repository project id
|
||||
---
|
||||
duration_ms: 4.401838
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 325: real business validate reports the copied repository project valid
|
||||
ok 2 - row 325: real business validate reports the copied repository project valid
|
||||
---
|
||||
duration_ms: 60.555647
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 325: the old mosaic-stack project key refuses against those same project bytes
|
||||
ok 3 - row 325: the old mosaic-stack project key refuses against those same project bytes
|
||||
---
|
||||
duration_ms: 63.16989
|
||||
type: 'test'
|
||||
...
|
||||
1..3
|
||||
# tests 3
|
||||
# suites 0
|
||||
# pass 3
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 169.948442
|
||||
@@ -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.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Poe
|
||||
|
||||
Builder seat, Codex gpt-6.1-sol, started by Sage under Q246 and mos-1566. Owns row 208, 193d: the Console's shared
|
||||
list, share controls and viewer read-only (docs/plans/2026-10-20_human-users-build.md), reassigned from Morgana so the
|
||||
coding runs on Codex.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no credentials of its
|
||||
own; it pushes only its own `poe/*` branches through the repository credential helper (mos-0981), never `refactor`,
|
||||
`main` or another seat's branch. Claude seats orchestrate and review guards (mos-1542 rule 4). Registered by Sage on
|
||||
2026-10-24 so the queue can name the seat as row owner (store.mjs isSeatDir).
|
||||
@@ -0,0 +1,122 @@
|
||||
# Row 208 / 193d — Console shared threads and viewer reads
|
||||
|
||||
Base ec86f2c84, branch poe/row208; Gitea #1706. Approved design: e2d161e2e,
|
||||
sha256 dd7fcb419ed8510e9d8d74b569183386d0942af8b68eecc0bbb5e182e677b523.
|
||||
|
||||
## Before
|
||||
|
||||
FED R6 supplies per-session user transport and the user-only page, row 192 SSO,
|
||||
and row 206 supplies schema 3l shares and bus refusals. No bus/schema changes needed.
|
||||
The webui channel adapter must forward the three existing share verbs.
|
||||
|
||||
## Choices and acceptance
|
||||
|
||||
Use the existing chat HTTP write fences and per-session transport for shares.
|
||||
Expose only live collaborator grant project/instance scopes as UI hints, never
|
||||
identity or bus authority. Re-read hints when rendering a user page; mixed grants
|
||||
must hide writes on viewer-only threads even when another project allows writes.
|
||||
Shared threads always read without session management, composer, resume or sharing.
|
||||
Keep the exact governance notice on user thread/share pages. Targets are typed
|
||||
owner/user principal names or *, avoiding a new user directory read API.
|
||||
|
||||
Add new isolated HTTP/broker and browser coverage. Remove each UI guard in mutants
|
||||
and show the real broker still refuses viewer create, send, open and share writes.
|
||||
Every test execution requires Sage CLEAR; use scratch TMPDIR, deadlines, npm ci,
|
||||
and MOSAIC_REQUIRE_EDITOR_BUILD=1. No live state or network destinations.
|
||||
|
||||
## Tracking
|
||||
|
||||
Queue next from linked tree refused as designed; canonical next matched pinned brief.
|
||||
Claim poe-0001 initially refused on Sage's queue lock; retry the same operation id.
|
||||
|
||||
Claim poe-0001 succeeded at canonical queue revision 3807 (in-progress).
|
||||
During implementation, three string replacements briefly targeted canonical
|
||||
packages/webui/src/public/{bus.js,user.js,chat.js} due to an incorrect command cwd.
|
||||
Checked the complete diffs, surgically reversed exactly those replacements, and
|
||||
applied them to this worktree. Sage independently confirmed canonical packages/
|
||||
has no remaining diff (sage-3914). No tests or live actions occurred there.
|
||||
|
||||
poe-0002 requested the test slot. sage-3914 approved the exact serialized plan,
|
||||
but CLEAR is pending Bentina returning her slot. No node/npm/browser commands
|
||||
before CLEAR. New tests are thread-sharing.test.mjs with thread-sharing-fixture.mjs;
|
||||
mutants.mjs runs four served-source mutants with assertion kills plus bus bypass.
|
||||
|
||||
## First verification (sage-3917 CLEAR)
|
||||
|
||||
Scratch logs: /mnt/storage/scratch/tmp/YU1G5B/.
|
||||
Syntax 12/12 exit 0; npm-ci.log exit 0; editor-build.log exit 0;
|
||||
thread-sharing.tap 7/7 exit 0. mutants.log and poe-208-mutants.json: four
|
||||
absence-test assertion kills (exit 1 each), four bus bypass proofs (exit 0 each),
|
||||
runner exit 0. Each mutant's individual log is poe-208-NAME-kill.tap or -bypass.tap.
|
||||
Full serialized webui.tap stopped on an existing-test red: 44 pass, 1 fail before
|
||||
termination; runner exit 1. Plugins not run. Slot returned to Sage via poe-0003.
|
||||
|
||||
Existing failure: chat-browser.test.mjs:123, uncertain writes/read failure draft
|
||||
preservation, line 133 expects #chat-body after no-bus-host. The new current.thread
|
||||
is not retained before list reads succeed, unlike old current.threads. A transient
|
||||
read failure therefore hides the existing owner conversation. Proposed fix retains
|
||||
same-thread metadata across refresh, never stale user grant scopes. No old tests
|
||||
changed and no further execution pending Sage's ruling/CLEAR.
|
||||
|
||||
## Approved correction (sage-3918)
|
||||
|
||||
The old test is correct; this change broke its transient-failure draft contract.
|
||||
Retain current.thread and shared only when the old id equals the requested id.
|
||||
User chatGrants still start empty and are read fresh, so a stale hint is never
|
||||
carried forward. A real 403/404 clears thread/shared metadata plus cached history,
|
||||
drafts and operation forms. chat-browser.test.mjs remains byte-identical.
|
||||
Fix applied statically; Sage will CLEAR the four mutants, full webui and plugins
|
||||
suites after Bentina returns the slot. No node/npm/browser commands before then.
|
||||
|
||||
Clarification during static review: retain the existing distinction between a
|
||||
thread-level refusal and a local bad-session selector (old row 272 B1 test).
|
||||
All 403/404 responses clear visible thread/shared metadata, as before; thread-level
|
||||
thread-not-owned/thread-not-found/access-refused also erase sibling cached history
|
||||
and drafts. A bad-session selector never erases valid sibling drafts/recovery keys.
|
||||
This preserves the existing invalidation contract rather than broadening the fix.
|
||||
|
||||
sage-3920 CLEAR: first mutant rerun stopped on a new-test failure, not a valid
|
||||
mutant kill. /mnt/storage/scratch/tmp/8UlAhS/poe-208-composer-kill.tap shows null
|
||||
old.thread on initial /chat: old?.id and undefined route id compared equal.
|
||||
Fixed by requiring old to exist before either same-id metadata carry. This is a
|
||||
new defect in the approved correction, no old test change. Rerunning the plan.
|
||||
|
||||
## Second slot outcome (sage-3920)
|
||||
|
||||
Scratch /mnt/storage/scratch/tmp/8UlAhS/.
|
||||
Initial mutants.log exit 1 was the new null-previous-state defect above. Fixed;
|
||||
mutants-r2.log exit 0: all four assertion kills exit 1, all four bypass proofs
|
||||
exit 0 (individual logs overwritten by the successful rerun; summary JSON kept).
|
||||
Full serialized webui.tap stopped on old tests: 114 top-level pass, 5 fail,
|
||||
runner exit 1. The original draft-preservation failure and row 272 B1 both pass.
|
||||
The new old-test failure is environmental: decide-sso.test.mjs cannot import
|
||||
root dependency oauth4webapi (ERR_MODULE_NOT_FOUND); all five failures share it.
|
||||
packages/webui npm ci installs its editor tooling, not root runtime dependencies.
|
||||
No tests changed; plugins not run. Request root npm ci from pinned lockfile in
|
||||
next slot before rerunning webui and plugins. Slot returned via poe-0004.
|
||||
|
||||
sage-3924 ruled the second red an environment gap, not a product defect. Approved
|
||||
root npm ci --ignore-scripts from the pinned lock, full webui then plugins,
|
||||
serialized. sage-3925 CLEAR starts that sequence. Push only after green; send
|
||||
base/tip and every changed path's SHA-256 to Sage and Gladstone.
|
||||
|
||||
## Final builder verification (sage-3925 CLEAR)
|
||||
|
||||
Root npm ci --ignore-scripts exit 0 from pinned lock.
|
||||
Full webui: exit 0, 418 pass, 0 fail, 0 skipped (271964 ms).
|
||||
Plugins: exit 0, 119 pass, 0 fail, 0 skipped (823 ms).
|
||||
Logs /mnt/storage/scratch/tmp/Et9xAa/{root-npm-ci.log,webui.tap,plugins.tap}.
|
||||
Slot returned immediately after plugins via poe-0005. All new acceptance cases
|
||||
are included in the full webui run. Four latest UI mutants and bus bypass proofs
|
||||
remain green at /mnt/storage/scratch/tmp/8UlAhS/mutants-r2.log and
|
||||
poe-208-mutants.json. No old tests changed. No bus authority/schema changes.
|
||||
|
||||
Acceptance: owner and collaborator can share/unshare own writable threads;
|
||||
shared lists preserve the plain-list shape and show owner names safely as text;
|
||||
shared threads cannot compose, resume, mutate sessions or reshare. Viewer old
|
||||
own threads retain read history with no write controls. Mixed grants and live
|
||||
downgrades hide writes only where coverage requires; revoked shares clear the
|
||||
view. Direct browser POSTs bypass all UI checks and the real broker still
|
||||
refuses viewer create/send/open/share. Existing exact governance notice retained.
|
||||
Global scripts/test-*.sh gate and independent reviewer approval remain Sage's
|
||||
landing gate; this builder does not claim those or any live deployment.
|
||||
@@ -0,0 +1,28 @@
|
||||
// Run only under Sage's CLEAR, with scratch TMPDIR and MOSAIC_REQUIRE_EDITOR_BUILD=1.
|
||||
// Mutants alter only the fixture-served copy of chat.js; repository source is untouched.
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
const file = 'packages/webui/tests/thread-sharing.test.mjs';
|
||||
const results = [];
|
||||
for (const mutant of ['composer', 'open', 'share', 'create']) {
|
||||
for (const [stage, pattern, expected] of [
|
||||
['kill', '193d viewer UI has no', 1],
|
||||
['bypass', '193d bypassed UI checks', 0],
|
||||
]) {
|
||||
const run = spawnSync(process.execPath, ['--test', '--test-concurrency=1', '--test-name-pattern=' + pattern, file], {
|
||||
env: { ...process.env, POE_UI_MUTANT: mutant }, encoding: 'utf8', timeout: 90000,
|
||||
});
|
||||
const log = join(process.env.TMPDIR, `poe-208-${mutant}-${stage}.tap`);
|
||||
writeFileSync(log, (run.stdout ?? '') + (run.stderr ?? ''));
|
||||
const killedByAssertion = /ERR_ASSERTION/.test(run.stdout ?? '');
|
||||
const passed = run.status === expected && (stage !== 'kill' || killedByAssertion);
|
||||
results.push({ mutant, stage, exit: run.status, signal: run.signal, passed, log });
|
||||
console.log(JSON.stringify(results.at(-1)));
|
||||
if (!passed) {
|
||||
writeFileSync(join(process.env.TMPDIR, 'poe-208-mutants.json'), JSON.stringify(results, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
writeFileSync(join(process.env.TMPDIR, 'poe-208-mutants.json'), JSON.stringify(results, null, 2));
|
||||
@@ -1597,3 +1597,28 @@ 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-23 | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row 322 / #1818 F8/F9 | Investigated clone dependency failure, recovered pinned brief and queue assignment; building on goldie/row322 under mos-1550, tests pending Sage CLEAR.
|
||||
2026-10-23T22:48Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row 322 / #1818 F8/F9 builder verification | sage-3808 slot: 54/54 across three whole files, new tests first-run/restored2/2, original-N installer and both mutants fail as intended; bytes restored. Candidate ready for goldie/row322 push and Morgana review; no merge/full-gate claim.
|
||||
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.
|
||||
2026-10-23T23:44Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row 324 / #1820 verified rebased candidate | sage-3850: rebase onto3569bf54f, only append-log conflicts; 5/5 +4/4 +21/21, mutant killed/restored, new files rerun9/9; no unexpected red. Publishing goldie/row324-r2 avoids force push. Duckworth review and Sage full gate pending.
|
||||
2026-10-23T23:43:13Z | Sage (1ef1e4f8) | Landed rows 298 (860afc2b6), 322 (467b05fe2) and 321 (5283d3745..3569bf54f), one full gate GREEN on tip 3569bf54f, pushed 23:32:54Z; #1804 #1817 #1818 closed; queue revs 3733-3765 in fb49fa63e; row 324 CLEAR to Goldie; B2-2 to DEFERRED | landed
|
||||
2026-10-23T23:52Z | Duckworth (Opus 5.5) | row 324 round 1 static review on dbdfcfd52 (sage-3853) | APPROVE (static), manifest 52874356 matches, every business.tracker reader handles absence, present null refuses, no @sync when absent, N1 doc (duckworth-0298) |
|
||||
2026-10-23T22:59Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row 323 / #1819 Case 1 factory business | New goldie/row323 worktree from da537e7d1; source review finds host/project/human/tracker/model values absent from DESIGN, asked Sage under explicit no-guess boundary (goldie-0008). No tests/config/host action; claim retries authorized.
|
||||
2026-10-23T23:02Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row 323 / #1819 no-bot static trace | Confirmed required absent tracker token refuses both install validation and bus boot regardless of tracker.baseUrl; reported exact source lines to Sage (goldie-0009), stopped product work per sage-3823. No tests/config/credentials/host action.
|
||||
2026-10-24T00:11Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row 323 / #1819 resumed after Q247 | On dd6cb5872, trackerless Case1 JSON and seven new scratch tests drafted; no old test changes or credentials. Prepared mutant, syntax/diff/static JSON checks only; asking Sage for test CLEAR.
|
||||
2026-10-24T00:12Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row 323 / #1819 pre-test commit | sage-3861 CLEAR explicitly requests a committed draft before testing. Field provenance recorded; no old tests changed. Runs pending, no green claim.
|
||||
2026-10-24T00:22Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row 323 / #1819 first slot stop | Commit571376c30 new7/7/business261/261; CLI existing row306 revoke-window red no RESULT, suite stopped (partial631 pass/1 fail). No old-test changes/reruns/mutant; manifest unchanged. Sage goldie-0016 has failure evidence and slot back; awaits ruling.
|
||||
2026-10-24T00:25Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row 323 / #1819 mos-1560 stop | First isolated revoke-window whole-file rerun failed0/1 at20.126s under load1.46/1.68/2.70/helperCLEAR. Second-red rule: stopped with no further tests/edits, manifest unchanged, remote571 unverified. Log and load lines in BUILD.md; Sage notified goldie-0018, slot returned.
|
||||
2026-10-24T00:38Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row 323 / #1819 accepted verification | mos-1561/sage-3869: short31-byte TMPDIR fixes socket-length refusal; revoke-window1/1, business261/261, new7/7, mutant killed/bytes restored, new7/7 again. Every run logs time/load/helper; no local CLI rerun, Sage gate supplies it. Notes/logs evidence only; Duckworth review/in-review move next.
|
||||
2026-10-24T00:41Z | Duckworth (Opus 5.5) | row 323 round 1 static review on 6a0f0f4f8 (sage-3865, sage-3870) | APPROVE (static) C1 Sage full gate cli, manifest 903ef022 matches, fields sourced, tests read shipped bytes, tester mutant real, N1-N2 doc (duckworth-0300) |
|
||||
2026-10-24T01:13Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row325 / #1821 Case1 project key | New goldie/row325 worktree at99930362e; pinned brief60d78e44e. Draft JSON key correction, new real-project-byte tests, sole old line70 key edit under mos-1564. Static checks only; requesting Sage CLEAR, no queue writes.
|
||||
2026-10-24T01:14Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row325 / #1821 verified | sage-3882: new3/3, business264/264, one key mutant killed/restored, new3/3 again, per-run fresh shortTMP/load/helperCLEAR. Four manifest entries verify; sole old assertion key change, no queue writes. Candidate push/report to Sage and Gladstone next.
|
||||
2026-10-24 | Poe | Q246 / mos-1566, row 208 / 193d (#1706), poe/row208 | Started; approved design hash verified; implementation and isolated acceptance pending.
|
||||
2026-10-24 | Poe | Row 208 / 193d (#1706), poe/row208 | Builder acceptance green: webui 418/418, plugins 119/119, four UI mutants killed with bus bypass proofs; candidate prepared for Gladstone then Duckworth guard review.
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"businessVersion": 1,
|
||||
"id": "mosaic-stack",
|
||||
"human": "jason",
|
||||
"arbiters": {
|
||||
"delivery": "chief",
|
||||
"technical": "chief"
|
||||
},
|
||||
"projects": {
|
||||
"stack": {
|
||||
"root": "/srv/mosaic/src/mosaic-stack"
|
||||
}
|
||||
},
|
||||
"vars": {
|
||||
"bus.tester": true
|
||||
},
|
||||
"plugins": {
|
||||
"harness": ["claude-code"]
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"launch": {
|
||||
"by": "chief",
|
||||
"instances": ["assistant"],
|
||||
"models": {
|
||||
"claude-opus-5-5": {
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-5-5"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,8 +207,21 @@ install -o mosaic -g mosaic -m 0600 /root/<sha>.bundle /srv/mosaic/incoming/<sha
|
||||
sudo -u mosaic -H git clone -q -b refactor /srv/mosaic/incoming/<sha>.bundle /srv/mosaic/src/mosaic-stack
|
||||
```
|
||||
|
||||
After a first install, once both units are running (section 10), adopt the installed copy before
|
||||
any activate or upgrade. This step runs only on Mos's go, with Jason or Mos running it
|
||||
(`scripts/stack-release.sh:14`):
|
||||
|
||||
```sh
|
||||
sudo -u mosaic -H env PATH=/opt/mosaic-stack/node-<v>/bin:/usr/sbin:/usr/bin:/sbin:/bin \
|
||||
/srv/mosaic/src/mosaic-stack/scripts/stack-release.sh adopt <business> --instance dev --units external
|
||||
```
|
||||
|
||||
Each upgrade, before `stack-upgrade.sh upgrade`:
|
||||
|
||||
Only the first upgrade after adopt takes `--no-rehearsal`: append it to the upgrade command below.
|
||||
Adopt writes no backup, so omitting the flag then refuses `no-backup`. Subsequent upgrades omit it;
|
||||
when a backup exists, supplying it refuses `rehearsal-required`.
|
||||
|
||||
```sh
|
||||
echo "<bundle sha256> /root/<sha>.bundle" | sha256sum -c -
|
||||
(
|
||||
@@ -244,10 +257,13 @@ When a run doesn't exit 0, send Mos the exit and the last lines of `/var/log/mos
|
||||
After `case2-install.sh` succeeds, create the human CLI wrapper before running
|
||||
`stack-copy.sh check --units external`. Creating this wrapper is a separate live
|
||||
step requiring Mos's go (`scripts/stack-copy.sh:3`); the install does not create it. Run only on that go:
|
||||
The step runs under sudo's `secure_path`, so it sets its own PATH and umask.
|
||||
|
||||
```sh
|
||||
sudo -u mosaic -H /bin/sh <<'MOSAIC_WRAPPER'
|
||||
cd / || exit 1
|
||||
umask 077
|
||||
PATH=/opt/mosaic-stack/node-<v>/bin:/usr/sbin:/usr/bin:/sbin:/bin; export PATH
|
||||
mkdir -p -- "$HOME/.npm-global/bin" || exit 1
|
||||
"$HOME/src/mosaic-stack/scripts/stack-copy.sh" wrapper <sha> --create
|
||||
MOSAIC_WRAPPER
|
||||
@@ -303,7 +319,7 @@ file with `stat -c '%U %a %n' PATH`, never `cat`. `<business>` is the host's bus
|
||||
|---|---|---|---|---|---|
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/` | `claude-session.env`, one line `ZAI_API_KEY=...` | 0600 | mosaic | library | `<path> sets ZAI_API_KEY` |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/` | the Console's proof file, the path the args file's `--proof-file` names | 0600 or 0400 | mosaic | library | none. `case2-unit.sh` refuses at render. Precheck line missing. |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/<business>/` | `library-gitea` | 0600 | mosaic | library | `case2-precheck.sh --business <business> --library-status`, after the bus is up: `library-last-error library-credential-missing` until it's placed, then `library <project> last fetched ...` |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/<business>/` | `library-gitea` | 0600 | mosaic | library | After a unit start, wait for loopback `/healthz` with the bounded retry in section 10 before `case2-precheck.sh --business <business> --library-status`: `library-last-error library-credential-missing` until it's placed, then `library <project> last fetched ...` |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/agents/<business>/<agent>/` | one file per service | 0600, dirs 0700 | mosaic | both | none. Filled only by `mosaic credentials issue`, or Approve after #436. Never by hand. |
|
||||
|
||||
The installer key, `<configDir>/secrets/hosts/<business>/<hostId>/installer`, is not a slot anyone places.
|
||||
@@ -339,4 +355,20 @@ Precheck line: `ok notifier config <dataRoot>/notify/<business>/notify.json
|
||||
None by hand. I2's `case2-unit.sh` enables both units, starts the bus, then the Console (Q189, mos-1122).
|
||||
`stack-upgrade.sh` stops and starts both units itself.
|
||||
|
||||
After a unit start, wait for the Console's loopback `/healthz` to succeed before running
|
||||
`case2-precheck.sh`, including `--library-status` in section 9. Replace `<port>` with the installed
|
||||
Console unit's `--port`. This retry is bounded to 30 attempts, each with a 2-second timeout, with
|
||||
1 second between failed attempts. If it prints STOP, stop here; do not run the precheck or adopt.
|
||||
|
||||
```sh
|
||||
(
|
||||
attempts=0
|
||||
until curl -fsS --max-time 2 -o /dev/null 'http://127.0.0.1:<port>/healthz'; do
|
||||
attempts=$((attempts + 1))
|
||||
[ "$attempts" -lt 30 ] || { echo "STOP: loopback healthz did not become ready; do not continue" >&2; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
)
|
||||
```
|
||||
|
||||
Precheck line: none. Both units active after the render: precheck line missing.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Case 1 factory business file for .53 (#414 stage 2)
|
||||
|
||||
Written 2026-10-23 by Sage for ops-01's stage-2 request (ops-0277, mos-1551, sage-3807).
|
||||
|
||||
## Brief: the Case 1 factory business file
|
||||
|
||||
**Why.** The .53 factory install (#414) runs `scripts/case2-install.sh --business mosaic-stack
|
||||
--business-file FILE --commit SHA --profile factory`. No Case 1 business file is committed. Row 195 left the
|
||||
role set to the go (agents/darkwing/work/queue-195/DESIGN.md:141 at b65014647). ops-01 needs FILE as a path
|
||||
at a commit.
|
||||
|
||||
**What to write.** One business document, `docs/guides/case1-business.mosaic-stack.json`, and one test that
|
||||
loads it.
|
||||
|
||||
The file's terms come from these sources. Read each one before writing.
|
||||
- Id `mosaic-stack` (DESIGN.md section 7).
|
||||
- `vars` has `bus.tester` true. The file has no `library` key and no library project
|
||||
(case2-install.sh:106-108; case2-precheck.sh's factory profile, `profile-library-business`).
|
||||
- The smallest role set that passes Case 1: a chief of staff from `roles/chief-of-staff.json` with
|
||||
`vars.harness` set to the claude harness, and `launch.by` naming it (case1-accept.sh F3 and F7, :545-559;
|
||||
packages/business/src/business.mjs:123 and :177-199). Add no other role unless the loader or the Case 1
|
||||
checks refuse without it. If one is needed, say which check needs it in BUILD.md.
|
||||
- Claude only. No pi harness and no pi role (P19 #383, mos-0865).
|
||||
- No credential value anywhere. A credential entry, if the loader needs one, is a file path under the
|
||||
install's home, and BUILD.md says which file the operator creates. The chief holds no credential and no
|
||||
tracker bot (packages/business/tests/helpers.mjs:71-72).
|
||||
- `human`, project roots and any other host path use the .53 install's values from DESIGN.md (the `mosaic`
|
||||
user and its home). Where DESIGN.md doesn't give a value, stop and ask me; don't guess a host path.
|
||||
|
||||
**Test.** A new test file, packages/business/tests/case1-business-file.test.mjs, that:
|
||||
1. loads the committed file through the business loader with a temporary config dir and passes;
|
||||
2. runs case2-install.sh's factory-profile checks on it (the same input path the install uses) and passes;
|
||||
3. fails when `bus.tester` is false and when a `library` key is added, with the install's refusal codes.
|
||||
|
||||
No old test changes. If one seems needed, stop and ask me (mos-1272).
|
||||
|
||||
**Gate.** The new test file whole, then packages/business and packages/cli suites whole, on my CLEAR only.
|
||||
One mutant: flip `bus.tester` in the committed file and show the test red, then restore and check bytes.
|
||||
|
||||
**Boundaries.** No host action, no .53 contact, no credential reads, no queue writes, no push to refactor.
|
||||
Work in a new worktree off refactor, on branch `<seat>/row323`. Push the branch and report the tip,
|
||||
base and manifest to Sage. Record design choices in agents/<seat>/work/queue-323/BUILD.md.
|
||||
|
||||
**Acceptance.** The file at a landed commit loads and passes the factory checks. ops-01 can pass its path to
|
||||
case2-install.sh unchanged.
|
||||
@@ -98,6 +98,13 @@ F10, added to this row (ops-0274, mos-1548). Both are confirmed at 094124b4e.
|
||||
ops-01 is testing both on 1013 (mos-1548). F10 is also docs only. The grep in the acceptance list covers its lines too,
|
||||
and BUILD.md records them.
|
||||
|
||||
F11, added to this row (ops-0275, mos-1549): wait for healthz 200 before the precheck. On 1013 a precheck run 1 s
|
||||
after unit start found no `bus.sqlite` yet. Section 10 (Console start) and the `--library-status` row in section 9
|
||||
(":306, after the bus is up") get one instruction: after a unit start, wait until
|
||||
`curl -fsS -o /dev/null http://127.0.0.1:<port>/healthz` succeeds (HTTP 200) before running `case2-precheck.sh`.
|
||||
Use the same loopback form as `scripts/case1-accept.sh:458`, with a bounded retry. F11 is docs only, and the
|
||||
acceptance grep covers its lines too.
|
||||
|
||||
## Brief: F8 and F9, the library persona import runs without the clone's node_modules
|
||||
|
||||
Row: F8 and F9 for the 433 Run B rerun (ops-0273, mos-1546). Builder: a new Codex thread (mos-1542). Reviewer:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Optional tracker in the business file (Q247)
|
||||
|
||||
Written 2026-10-23 by Sage under Mos's mos-1555 (DECISIONS Q247; Jason can reverse it).
|
||||
|
||||
## Brief: tracker becomes optional in the business file
|
||||
|
||||
**Why.** Row 323 (#1819) writes the .53 Case 1 factory business file. The factory has no tracker, and .53
|
||||
has no Vikunja sync bot. Today every business file must carry `tracker.sync` with a bot and a vikunja token
|
||||
file, and both validate and boot refuse a missing token file (goldie-0009). Mos ruled the requirement an
|
||||
implementation constraint, not a PRD one (Q247), and chose an optional tracker over a dummy credential or a
|
||||
new bot (mos-1555).
|
||||
|
||||
**What changes.** A business file may omit the top-level `tracker` key.
|
||||
- Absent `tracker` means no tracker sync, no `@sync` credential, and no `@sync` registration in the bus.
|
||||
- A present `tracker` behaves exactly as today, with the same checks and the same refusals.
|
||||
- Every reader of the business tracker handles the absent case. The ones I found:
|
||||
packages/business/src/business.mjs:85-96 and :293-297 (checkTracker and the own-bot check),
|
||||
packages/business/src/cli.mjs:84-86 (validate's credential refs), packages/bus/src/runtime.mjs:131 and
|
||||
:247, packages/bus/src/broker.mjs:6123, packages/business/src/instance-settings.mjs:126, and
|
||||
packages/cli/src/config.mjs's trackerFor. Grep for the rest
|
||||
(`git grep -n -E 'tracker\.sync|\.tracker\b' -- 'packages/*/src/*'`) and list every reader in BUILD.md
|
||||
with what it does when tracker is absent.
|
||||
- A role whose definition uses vikunja still needs its own `tracker` bot (business.mjs:143). That rule stays.
|
||||
|
||||
**Tests.** New tests only. No pre-existing test changes without Mos's OK (mos-1272); if one seems needed,
|
||||
stop and tell me.
|
||||
1. A business file with no `tracker` passes the loader and `mosaic business validate`.
|
||||
2. The bus boots with that file and raises no credential-file error, and `@sync` isn't registered.
|
||||
3. Existing fixtures, all of which carry a tracker, load unchanged. The existing suites show this.
|
||||
4. One mutant that skips the presence check, treating an absent tracker as present, is killed by a new test.
|
||||
|
||||
**Gate.** New test files whole, then the whole touched test files, then the mutant, all on my CLEAR. My
|
||||
landing gate is the full one (mos-1455).
|
||||
|
||||
**Boundaries.** No host action, no credentials read, no queue writes beyond your own claimant moves, no
|
||||
push to refactor. Use a new worktree off refactor on branch `<seat>/row324`. Push the branch, then report
|
||||
the tip, base and manifest to Sage. Record design notes in agents/<seat>/work/queue-324/BUILD.md.
|
||||
Duckworth reviews, because this touches credential registration.
|
||||
|
||||
**Acceptance.** Row 323's file, written without `tracker`, passes validate and boots. Every file with a
|
||||
tracker behaves exactly as before.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Case 1 business file: project key must match the project file (#414 stage 2)
|
||||
|
||||
Written 2026-10-24 by Sage for ops-0288 and Mos's ruling mos-1563.
|
||||
|
||||
## Brief: re-key the Case 1 project to stack
|
||||
|
||||
**Why.** The .53 stage 2 run on 99930362e stopped at step 8 (ops-0288). `case2-install.sh --profile factory`
|
||||
refused with:
|
||||
`validate: mosaic business: project file /srv/mosaic/src/mosaic-stack/.mosaic/project.json has id stack, but
|
||||
business mosaic-stack declares it as mosaic-stack`. Row 323's `docs/guides/case1-business.mosaic-stack.json`
|
||||
keys its one project `mosaic-stack`. The repo's `.mosaic/project.json` has id `stack`, and the loader requires
|
||||
the key to equal that id (packages/business/src/cli.mjs:80, packages/cli/src/config.mjs:67). Row 323's test
|
||||
missed it because it validates against a scratch root with no project file, which only warns (cli.mjs:74-78).
|
||||
|
||||
**Ruling (mos-1563).** Fix the business file. Don't touch the project id: `stack` is read elsewhere.
|
||||
|
||||
**What to change.**
|
||||
1. In `docs/guides/case1-business.mosaic-stack.json`, rename the `projects` key from `mosaic-stack` to
|
||||
`stack`. Keep its `root` (`/srv/mosaic/src/mosaic-stack`). Business id stays `mosaic-stack`. Change nothing
|
||||
else in the file.
|
||||
2. A new test file, `packages/business/tests/case1-project-key.test.mjs`, that validates the committed case1
|
||||
file against the repo's own `.mosaic/project.json`, so a mismatch fails in CI instead of on a host:
|
||||
- read the repo's `.mosaic/project.json` and assert the case1 file has exactly one project whose key equals
|
||||
its `id`;
|
||||
- run the business `validate` path (the same one cli.mjs:80 runs) with that project's root pointed at a
|
||||
scratch copy holding the repo's real `.mosaic/project.json` bytes, and assert it passes with the project
|
||||
reported `valid`, not `absent`;
|
||||
- a negative case: the same run with the key set back to `mosaic-stack` refuses with the cli.mjs:80
|
||||
message.
|
||||
3. The one old-test line this change breaks, `packages/business/tests/case1-business-file.test.mjs:70`
|
||||
(`assert.deepEqual(b.projects, { "mosaic-stack": ... })`), changes its key to `stack` and nothing else. This
|
||||
edit has Mos's go (mos-1564, under mos-1272). Don't change any other line of any old test. If another old
|
||||
test reds, stop and report it.
|
||||
|
||||
**Gate.** Use `export TMPDIR=$(mktemp -d /mnt/storage/scratch/tmp/XXXXXX)` (BRIEF-TEMPLATE Gate). On my CLEAR
|
||||
only: the new test file whole, then the packages/business suite whole. One mutant: set the case1 key back to
|
||||
`mosaic-stack`, show the new test red, restore and check the bytes. The cli suite counts from my landing gate.
|
||||
|
||||
**Boundaries.** No host action, no .53 contact, no credential reads, no queue writes, no push to refactor.
|
||||
Work in a new worktree off refactor at 99930362e or later, on branch `goldie/row325`. Push the branch and
|
||||
report the tip, base and manifest to Sage and to the reviewer. Record design choices in
|
||||
agents/goldie/work/queue-325/BUILD.md.
|
||||
|
||||
**Acceptance.** At a landed commit, `case2-install.sh --business mosaic-stack --business-file
|
||||
docs/guides/case1-business.mosaic-stack.json --profile factory` passes step 8's validate against
|
||||
`/srv/mosaic/src/mosaic-stack/.mosaic/project.json`, and the new test fails if the key and the project id
|
||||
drift apart again.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Host run sheet: five operator gaps from the .53 and Run B installs (#414 stage 2)
|
||||
|
||||
Written 2026-10-24 by Sage for Mos's mos-1566 item 1, from ops-0282, ops-0288, ops-0291, ops-0292,
|
||||
Duckworth's 323 N2 and Mos's rulings mos-1563 and mos-1565.
|
||||
|
||||
## Brief: five run-sheet gaps
|
||||
|
||||
**Why.** Five things stopped or slowed operators on the .53 stage 2 install and 433 Run B. Each is a gap
|
||||
in `docs/guides/host-run-sheet.md` (checked at 3d2569ce2), not a product bug. The code they describe has
|
||||
landed. This row writes them down.
|
||||
|
||||
**What to write.** Edit only `docs/guides/host-run-sheet.md`. Source every command, path and message from
|
||||
the code or the receipts named here, and cite the line you read, the way the sheet already does
|
||||
(`scripts/stack-release.sh:14`). Don't invent a flag, path or exit code.
|
||||
|
||||
1. **B2-2, the Console proof file format** (DEFERRED.md "ops-0282 B2-2", ops-0282). In section 9's .54
|
||||
library table (:321), the proof file row's File cell gives mode and owner but no format. Add it: one
|
||||
base64url line of at least 32 characters, `[A-Za-z0-9_-]` only (packages/webui/src/serve.mjs:264). The
|
||||
Console exits 2 at start otherwise (:267). `case2-unit.sh` doesn't open the file, so a bad proof fails
|
||||
only at Console start. Read serve.mjs:255-270 yourself and match what it checks, including whether a
|
||||
trailing newline is accepted.
|
||||
2. **N2, a missing project file only warns** (Duckworth 323 round 1 N2, ROUND1-6a0f0f4f8.md). When the
|
||||
project root a business file names has no `.mosaic/project.json`, `mosaic business validate` passes
|
||||
with the warning "no project file at ... it has no project variables" (packages/business/src/cli.mjs:74-78).
|
||||
An install on such a host passes step 7 with the project's variables missing. Say so where the sheet
|
||||
describes `case2-install.sh` (near :278), and tell the operator to treat that warning as a stop.
|
||||
3. **The pre-install validate step** (mos-1563, ops-0288). Before `case2-install.sh`, check by hand that
|
||||
each `projects` key in the business file equals the `id` in `<that project's root>/.mosaic/project.json`
|
||||
in the stack's user's clone, and that the file exists. The loader refuses a mismatch
|
||||
(packages/business/src/cli.mjs:80) at step 7, after the copy is made. The check must use only what the
|
||||
host has at that point: coreutils, grep, or the pinned node under `/opt/mosaic-stack/node-<v>/bin`
|
||||
(section 2), run as the stack's user. Give the expected output for a match and say a mismatch is a
|
||||
stop: fix the business file, not the project id (mos-1563).
|
||||
4. **The retry path after a failed first install** (mos-1565, ops-0291). Section 6 has no retry form.
|
||||
What happened on .53: a refused `case2-install.sh` left `<home>/.config/mosaic-dev/config.json` behind
|
||||
(bootstrap ran at step 1), and the first refusal also left an empty `businesses/` dir in that config
|
||||
dir. The operator then used the upgrade form's fetch, which writes the bundle to
|
||||
`refs/remotes/origin/refactor` and doesn't move `refs/heads/refactor`, so `stack-copy.sh copy` refused
|
||||
with "not reachable from refs/heads/refactor". Mos's ruling: move the clones aside and rerun section
|
||||
6's first-install form. Write that as a short "If the first install refuses" block in section 6:
|
||||
- which paths move aside: root's checkout `/root/mosaic-stack`, the stack's user's clone
|
||||
`/srv/mosaic/src/mosaic-stack`, and the stack's user's config dir. Read `scripts/case2-install.sh`
|
||||
(bootstrap at step 1, :172, :200, :210-211) and `scripts/bootstrap.sh` to list what an install
|
||||
writes before it can refuse, and name only those paths;
|
||||
- move, never delete: `mv` to a name with a UTC timestamp suffix, as root, keeping owner and mode;
|
||||
- what the operator must re-place afterwards if a credential slot under that config dir was already
|
||||
filled (section 9). If nothing in section 9 can be filled before the first install, say that;
|
||||
- then rerun the first-install block from its first line. Never use the upgrade form's fetch on a
|
||||
clone that a first install hasn't finished with.
|
||||
5. **Checking a backup's SHA256SUMS by hand** (ops-0292). The sheet has no backup check today. A backup set
|
||||
under `<dataRoot>/backups/stack/<activation>/` holds `SHA256SUMS` with paths relative to `bus/`
|
||||
(scripts/stack-release.sh:482 writes `(cd "$o/bus" && sha256sum bus.sqlite) > "$o/SHA256SUMS"`), and
|
||||
the tool checks it with `(cd "$o/bus" && sha256sum -c --quiet ../SHA256SUMS)` (:502, :2244). Running
|
||||
`sha256sum -c SHA256SUMS` from the backup dir itself fails (rc 1, "FAILED"), because `bus.sqlite` is
|
||||
in `bus/`. Add a short paragraph after the upgrade exits in section 6 that gives the `bus/` form for an
|
||||
operator who wants to check a backup before a rollback or restore. Read :475-510 to confirm the
|
||||
layout first.
|
||||
|
||||
Keep the sheet's voice: short sentences, the path and line for each claim, no em dashes. Don't renumber
|
||||
sections. Don't touch any other file except your BUILD.md.
|
||||
|
||||
**Test.** None ships. Before the review request, show that no test or script reads the sheet:
|
||||
`git grep -n host-run-sheet -- 'packages/*/tests/*' 'scripts/*'` prints nothing, with a positive control
|
||||
that the same command form finds a path you know a test reads. Also grep the tests for a 64-hex pin next
|
||||
to the sheet's name. If any test reads the sheet, stop and tell Sage.
|
||||
|
||||
**Gate.** Doc only (decision 137). No suite run is needed from you, and no CLEAR. Sage's landing gate
|
||||
runs every suite (mos-1455). The reviewer checks every new claim against the cited line.
|
||||
|
||||
**Boundaries.** No host action, no .53, .54 or 1013 contact, no credential reads, no push to refactor.
|
||||
Claimant queue moves are yours (start, in-review). Work in a new worktree off refactor at 3d2569ce2 or
|
||||
later, on branch `goldie/row-run-sheet`. Push the branch and report the tip, base and manifest to Sage
|
||||
and to the reviewer. Record choices in `agents/goldie/work/queue-<row>/BUILD.md`.
|
||||
|
||||
**Acceptance.** An operator following the sheet can see the proof file format, treats the missing
|
||||
project file warning as a stop, checks the project key before install, has a written retry path after a
|
||||
refused first install, and can check a backup by hand with the form that works. Every new claim cites a
|
||||
line that says it.
|
||||
@@ -48,6 +48,17 @@ What this piece deliberately leaves alone, and where that work goes.
|
||||
Who says yes, and on what evidence: the command to run, the output to
|
||||
expect, or the comment to read.
|
||||
|
||||
Every seat, Claude or Codex, runs the suites with a short TMPDIR:
|
||||
`export TMPDIR=$(mktemp -d /mnt/storage/scratch/tmp/XXXXXX)`, 31 bytes. A
|
||||
launch refuses when its run's `tools.sock` path passes 107 bytes
|
||||
(`packages/seat/src/session.mjs:278`). Tests build that path under TMPDIR
|
||||
and drop the launcher log, so the refusal shows only as a 20-second "no
|
||||
RESULT". `packages/cli/tests/revoke-window.test.mjs` needs TMPDIR at 35
|
||||
bytes or less. A seat-named TMPDIR such as `.../tmp/goldieXXXXXX` (37)
|
||||
fails it on any commit (row 323, mos-1561). A builder whose cli suite
|
||||
can't run says so in the row, and the cli suite counts from the lead's
|
||||
landing gate.
|
||||
|
||||
### Handoffs
|
||||
|
||||
Every seat hands off directly (DECISIONS Q210, mos-1221). When the owner
|
||||
|
||||
@@ -711,3 +711,48 @@ webby/reviews c6ea27aab). docs/case2/SURVEY-RUN.md:96 documents the `--mapping`
|
||||
`scopes canon .., playground .., unmapped ..; indexed ..`, which predates the supporting scope 283 adds.
|
||||
Nothing reads the file (decision 137). Bound to the next docs commit that touches SURVEY-RUN.md: add the
|
||||
supporting count to that line.
|
||||
|
||||
## ops-0275 O2 and O3: triage, no row
|
||||
|
||||
Sage, for Mos's mos-1549 (433 factory on 1013, ops-0275). Neither is a defect, so neither gets a row.
|
||||
|
||||
O2, `bus status` shows writer.lock pid 4174 while the unit's host pid is 4078. By design. `scripts/mosaic`
|
||||
execs node (scripts/mosaic:44), so the unit's MainPID is the bus host. The host forks the broker
|
||||
(packages/cli/src/host.mjs:1, :215), and the broker's Store writes its own `process.pid` into
|
||||
`<dataRoot>/bus/writer.lock` (packages/bus/src/store.mjs). The lock holder is the host's child. The
|
||||
status line's liveness check (packages/cli/src/cli.mjs:612) reported it running, so it isn't stale.
|
||||
If an operator note is wanted, the next row that edits `bus status` output can name the broker pid as the
|
||||
host's child; nothing binds it now.
|
||||
|
||||
O3, the copy's npm step warns that @google/genai, esbuild and protobufjs have install scripts outside
|
||||
allowScripts. scripts/stack-copy.sh:74 runs `npm ci --no-audit --no-fund`. On npm 12.0.2 (dragon-lin)
|
||||
dependency install scripts are blocked by default: npm skips any dependency script with no allowScripts
|
||||
entry and lists the skipped packages at the end (`npm help install-scripts`). So the copy skips all three.
|
||||
The 433 factory passed on that copy, and the landing gates (webui included) pass with the same skip.
|
||||
Recommendation: refuse, not allow. An explicit `false` entry for each in package.json allowScripts makes
|
||||
the tested behavior the written policy and drops the warning (`npm help config`, strict-allow-scripts:
|
||||
explicit denials are always skipped silently). That edits package.json, so it waits for the next reviewed
|
||||
row that touches package.json; bound there.
|
||||
|
||||
## ops-0282 B2-2: the run sheet doesn't state the Console proof file format
|
||||
|
||||
Sage, for Mos's mos-1557 (433 Run B P1 at 3569bf54f). docs/guides/host-run-sheet.md:321, the .54 library
|
||||
table, gives the Console proof file's mode (0600 or 0400) and owner but not its format. The Console reads
|
||||
one base64url line of at least 32 characters, `[A-Za-z0-9_-]` only (packages/webui/src/serve.mjs:264), and
|
||||
exits 2 at start otherwise (:267). case2-unit.sh doesn't open the file, so a short or malformed proof fails
|
||||
only at Console start. That's what stopped Run B P1 (B2-1, ops-01's 24-byte fixture). Fix: add the format to
|
||||
the File cell of that row. Doc only, no test reads it; bound to the next doc row that edits the run sheet.
|
||||
|
||||
## Row 323: revoke-window hides a socket-path refusal as a 20-second "no RESULT"
|
||||
|
||||
Sage, for Mos's mos-1560/mos-1561. packages/cli/tests/revoke-window.test.mjs failed on every commit in
|
||||
Goldie's environment (base dd6cb5872 included) and passed in Sage's. The cause is TMPDIR length, not Codex.
|
||||
The test's root is `$TMPDIR/mosaic-revoke-window-XXXXXX`, and a run's `tools.sock` sits at
|
||||
`<root>/data/launches/acme/r<12 hex>/tools.sock`. newRun refuses past 107 bytes
|
||||
(packages/seat/src/session.mjs:278). Measured lengths: `.../tmp/sageXXXXXX` (35 bytes) gives 107 and passes,
|
||||
`.../tmp/goldieXXXXXX` (37 bytes) gives 109 and fails. Sage reproduced it with a 37-byte TMPDIR: 0/1 after
|
||||
20235 ms, the same "no RESULT". The test passes `log: () => {}` to the launcher, so the refusal never shows.
|
||||
The guard is right. The test is the weak part: its prefix leaves no margin, and a launch refusal reads as a
|
||||
timeout. Fix: shorten the prefix and fail on a launch-failed log line instead of waiting 20 s. That's an
|
||||
old-test change, so it needs Mos's go (mos-1272). Bound to the next row that edits revoke-window.test.mjs.
|
||||
Until then, docs/plans/BRIEF-TEMPLATE.md's Gate section sets TMPDIR to `mktemp -d /mnt/storage/scratch/tmp/XXXXXX`.
|
||||
+8
-5
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
|
||||
|
||||
<!-- mosaic-queue:begin -->
|
||||
|
||||
Generated from `docs/plans/queue.json` revision 3732 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 3788 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -78,7 +78,7 @@ Generated from `docs/plans/queue.json` revision 3732 by `queue render`. Do not e
|
||||
| 39 | Slice 1 S4: the mosaic CLI, inbox, decide, tasks, agents, trail | rocko; reviewers darkwing, filbert | #1521 | done | 37 done | filbert approves on #1521, darkwing approves the packages/discord change and the broker host; cli tests, test-discord, every test-*.sh green (sage) | `docs/plans/2026-10-04_slice-1.md` § Slice 1 S4: the `mosaic` CLI (inbox, decide, tasks, agents, trail) | Follow-ups from round 1 (sage): (a) one host that boots every business, since writer.lock allows one broker per dataRoot while the unit is per business; accepted for slice 1 with one business. (b) the queue's PATH_RE refuses '@' in manifest paths, so the S4 unit template was renamed to mosaic-bus.service.in; packages/discord/systemd/[email protected] would hit the same refusal in any future candidate manifest. |
|
||||
| 40 | Slice 1 S5: WebUI inbox, tasks, agents and trails; CHAT-03 Gate E | dewey; reviewers darkwing, filbert | #1522 | done | 39 done | darkwing and filbert approve on #1522; webui, conversation, control-board and every test-*.sh green; row 5 Gate E is Jason's (sage) | `docs/plans/2026-10-04_slice-1.md` § Slice 1 S5: WebUI (inbox, tasks, agents and trails; CHAT-03 Gate E) | — |
|
||||
| 41 | Slice 1 S6: meta-harness and launching; the PM moves off T3 | filbert; reviewers darkwing | #1523 | done | 34 done, 36 done, 38 done | darkwing approves on #1523; harness and seat tests, every test-*.sh; recorded PM launch of a coder (sage) | `docs/plans/2026-10-04_slice-1.md` § Slice 1 S6: meta-harness and launching (the PM moves off T3) | — |
|
||||
| 42 | Slice 1 S7: acceptance, five pieces in a row | sage | #1524 | in-progress | 40 done, 41 done | Jason, from the five trails: only gated decisions reached him, every step left a trail (jason) | `docs/plans/2026-10-04_slice-1.md` § Slice 1 S7: acceptance, five pieces in a row | mos-1184 (2026-10-21T16:58Z): pieces 1-5 are Jason's requests to pm through the Console or mosaic talk pm, a route Q194 suspends until Jason says the Stack is operational. The pieces are paused, the 10-23 23:59Z date is dropped and no new date is set. mos-1148's C1/C2 rules wait with them. The 10-24 window goes ahead without the pieces (sage-1622). |
|
||||
| 42 | Slice 1 S7: acceptance, five pieces in a row | sage | #1524 | in-progress | 40 done, 41 done | Jason, from the five trails: only gated decisions reached him, every step left a trail (jason) | `docs/plans/2026-10-04_slice-1.md` § Slice 1 S7: acceptance, five pieces in a row | mos-1559 (00:00Z 10-24): the 10-23 23:59Z date passed with no pieces sent. Under the mos-1148 default the unopened pieces stay unopened. New latest-by date: Vikunja 442 due 2026-10-26 18:00Z. Pieces stay paused under Q194 until Jason says the Stack is operational; Mos raises them with Jason in the 10-24 session. |
|
||||
| 43 | S2b: gated approvals are single-use (authorize records consumption) | rocko; reviewers darkwing | #1525 | done | 37 done | darkwing approves on #1525; Sage's integration gate (sage) | `docs/plans/2026-10-05_s2b-single-use-approvals.md` § S2b: gated approvals are single-use (authorize records consumption) | 2026-10-05: S2 is in the tree at 38828a2c. Rocko's README edit (ae643fba, decision 62 human-proof note) is an uncommitted change on packages/bus/README.md and belongs in this row's candidate. |
|
||||
| 44 | S2c: a within-role message cites a decision without consuming it | rocko; reviewers darkwing | #1526 | done | 43 done | darkwing approves on #1526; Sage's integration gate (sage) | `docs/plans/2026-10-05_s2c-message-decision-citation.md` § S2c: a within-role message cites a decision without consuming it | — |
|
||||
| 45 | S4 follow-up: notifier retry limit, journal types and the round 2 test gaps | rocko; reviewers darkwing, filbert | #1527 | done | — | filbert and darkwing approve on #1527; every node package suite and every test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-09_s4-follow-up-and-cohort.md` § S4 follow-up: notifier retry limit, journal types and the round 2 test gaps | lead decision 72; follows row 39 (2f5303c1) |
|
||||
@@ -334,7 +334,7 @@ Generated from `docs/plans/queue.json` revision 3732 by `queue render`. Do not e
|
||||
| 295 | FED R4: revocation | morgana; reviewers dewey | #1801 | done | — | Dewey approves the manifest. The bus, cli and business suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: FED R4, revocation | LANDED 2efd127ce on refactor 10:10Z 10-23 (9 commits cherry-picked onto 07802dc57, range-diff all =, patch-ids match, HEAD~1 tree a86592ffc = gated tree, top commit BUILD.md doc-only). Gate d68b4300d manifest e94c9aeb: 13 whole files, bus 886/0, business 231/0, cli 924/0, harness 151/0, all test-*.sh rc 0, M1-M16 killed, M8 per mos-1436. Dewey approve rev 3500 c31102. Re-pin precheck mos-1436 cond 11 binds. |
|
||||
| 296 | FED R7: scratch two-instance acceptance | lena; reviewers filbert | #1802 | briefed | 190 done, 294 done, 295 done | Filbert approves the manifest. The script test and every scripts/test-*.sh are green on Sage's gate, and no unit, port or socket of the script's outlives the run. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: FED R7, scratch two-instance acceptance | — |
|
||||
| 297 | GitHub grant method (Bushroot's half of #436b) | violet; reviewers dewey | #1803 | briefed | 198 done | Dewey approves the manifest. The touched suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: the GitHub grant method (Bushroot's half of #436b) | — |
|
||||
| 298 | Q228: chief-of-staff grant scope | boyd; reviewers dewey | #1804 | in-review, round 1, request posted | 252 done | Dewey approves the manifest. The touched suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: the chief-of-staff grant scope (Q228) | — |
|
||||
| 298 | Q228: chief-of-staff grant scope | boyd; reviewers dewey | #1804 | done | 252 done | Dewey approves the manifest. The touched suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: the chief-of-staff grant scope (Q228) | LANDED 860afc2b6 (ff from 833ec1cc8), pushed with tip 3569bf54f 23:32:54Z 10-23. Dewey APPROVE c31449 rev 3754. Boyd reruns 29/0, M1-M10 killed. Sage full gate GREEN 23:32:28Z on combined tip 3569bf54f (298+322+321): every package suite 0 fail 0 skip (bus 924, business 249, cli 982, plugins 119, webui 411), every scripts/test-*.sh rc 0, queue 156/0. Live unchanged 45dca69f6 schema 3n. |
|
||||
| 299 | 573: the P10 hop band (Vikunja 573) | gander; reviewers filbert | #1806 | done | 293 done | Filbert approves. The whole file under load at least 3 times, then the cli suite, are green on Sage's gate. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: 573, the P10 hop band (Vikunja 573) | landed ffb2fedfb 08:03Z on 15e688cc0 via Filbert SESSIONS c1f910b4c; 5 commits = gander/row299-gate-277ace0b 63db96d8b (range-diff 5/5 =, tree differs only BUILD-LOG/SESSIONS); manifest 277ace0b sha256 -c OK. Gate green on ffa93b054: case1 whole 3x 53/0/0, cli whole 917/0/0 (59 files), P10 inner 2.27 outer 4.01, M1-M6 killed (gander-0189). Filbert approve rev 3485 c31084; N1 M1 1.23 s margin. |
|
||||
| 300 | R6-N1: HTTP-level test of the user-session option swap (294 N1) | launchpad; reviewers duckworth | #1807 | done | 294 done | Duckworth approves the manifest; new test file whole, webui suite, mutant M12 killed (sage) | `docs/plans/2026-10-23_r6-followup-rows.md` § Brief: R6-N1, an HTTP-level test of the user-session option swap | Landed on refactor as 9d929911c..07315e651 (from launchpad/row300 a00b25b37, range-diff equal), pushed 5b846b048 12:28Z. Duckworth approved r2 (c31158, rev 3559). Sage gate on bcf3c1dac: user-option-swap 1/0, webui 375/0 with pinned esbuild and MOSAIC_REQUIRE_EDITOR_BUILD=1, M12 killed at :151. Run 1 red placed under mos-1272 (headers). N1 deferred: board proxy upstream has no recorder. |
|
||||
| 301 | R6-N4: fold user-bus.mjs back into bus.mjs (294 N4) | launchpad; reviewers duckworth | #1808 | done | 294 done | Duckworth approves the manifest; channel-proof.test, webui suite and 294 user-transport tests whole and green; re-pin only with Mos go (sage) | `docs/plans/2026-10-23_r6-followup-rows.md` § Brief: R6-N4, fold user-bus.mjs back into bus.mjs | Landed on refactor as bb031ec84..5b846b048 (from launchpad/row301 acc97fb9c, range-diff equal), pushed 12:28Z. Re-pin of webui bus.mjs to 39fcb27c is its own commit 59f0e44f4 (mos-1449); fixture stubs per mos-1451. Duckworth approved r2 (c31159, rev 3560). Sage gate: channel-proof 2/0, webui 374/0 pinned esbuild, granted-user 19/0. Live unchanged; 301 makes its own re-pin window. |
|
||||
@@ -357,8 +357,11 @@ Generated from `docs/plans/queue.json` revision 3732 by `queue render`. Do not e
|
||||
| 318 | 571 W8: Console Connections view for Markdown and DOCX story files, before W7's first real import (post-10-27, mos-1470) | unassigned | — | queued | 314 done, 315 done, 317 done | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W8 Console Connections view for Markdown and DOCX story files | fenton-0017 carry: connections.test.mjs must test that links inside comments and tracked deletions are excluded. W8 follows W7's closed routing candidate and lands before W7's first real import opens (mos-1470) |
|
||||
| 319 | GitHub consumer for row 297's protected githubWithToken callback, honoring expiry and cancellation (post-10-27) | unassigned; reviewers dewey | — | queued | 297 done | Dewey approves the design and the manifest. The touched suites and every scripts/test-*.sh are green on Sage's gate, and the signal and expiry mutants are killed. (sage) | `docs/plans/2026-10-23_github-consumer-brief.md` § Brief: a GitHub consumer for Broker.githubWithToken | — |
|
||||
| 320 | Case 1/2 host fixes for the 433 P1 rerun: hostcheck item 5 mode 750, run sheet bundle verify, case2-unit --claude @, stack-copy unreadable cwd, notify.json step and precheck, mosaic wrapper step (10-27 path) | gladstone; reviewers morgana | #1816 | required; done | — | Morgana approves. Touched test files run whole (case1-hostcheck, case2-unit, case2-install, stack-copy) and the 8 named mutants are killed; Sage's full landing gate runs every package suite and every scripts/test-*.sh. After landing Mos sends ops-01 the new N. (sage) | `docs/plans/2026-10-23_host-fixes-1816-brief.md` § Brief: hostcheck item 5, bundle verify, case2-unit @, stack-copy cwd | LANDED 094124b4e 21:53Z (replay of faf55618e onto 45e3ca7bd, code paths patch-id equal, log appends in one final commit). F1-F6 hostcheck item 5, bundle verify, case2-unit scoped paths and cwd, precheck notify decision, run sheet 9a notify dirs 0700, wrapper step. Morgana APPROVE r2 c31403 rev 3715. Full gate GREEN 21:53:00Z, 0 fail (mos-1453/1455/1456). Vikunja 614 done. 433 P1 reruns on this N (mos-1532). |
|
||||
| 321 | F7 and F10: host run sheet wrapper step sets umask 077 and the node PATH under sudo secure_path; adopt step after a first install; --no-rehearsal on the first upgrade after adopt (433, ops-0271/0274); docs only | gladstone; reviewers boyd | #1817 | required; briefed | — | Boyd (Codex) approves. A git grep with a positive control shows no test reads the changed run sheet lines; BUILD.md records the diff and the grep. Doc only, lands with any row (mos-1544). (sage) | `docs/plans/2026-10-23_host-fixes-1816-brief.md` § Brief: F7, the wrapper step's PATH and umask under sudo | — |
|
||||
| 322 | F8/F9: case2-install.sh runs the library persona import from the stack copy, and its refusal carries the error line (433 Run B, ops-0273) | goldie; reviewers morgana | #1818 | required; briefed | — | Morgana approves (install path). The new case2-install test fails on 094124b4e and passes on the candidate; a mutant restoring REPO at :185 is killed; Sage's full landing gate runs every package suite and scripts/test-*.sh. Then ops-01 reruns Run B P1. (sage) | `docs/plans/2026-10-23_host-fixes-1816-brief.md` § Brief: F8 and F9, the library persona import runs without the clone's node_modules | — |
|
||||
| 321 | F7, F10, F11: run sheet wrapper step umask and node PATH under sudo; adopt step after a first install; --no-rehearsal on the first upgrade; wait for healthz 200 before a precheck (433, ops-0271/0274/0275); docs only | gladstone; reviewers boyd | #1817 | required; done | — | Boyd (Codex) approves. A git grep with a positive control shows no test reads the changed run sheet lines; BUILD.md records the diff and the grep. Doc only, lands with any row (mos-1544). (sage) | `docs/plans/2026-10-23_host-fixes-1816-brief.md` § Brief: F7, the wrapper step's PATH and umask under sudo | LANDED 5283d3745, 3fd5307e1, 3569bf54f: cherry-picks of b5d4d7903, d9400f0d3, 9aae73ade with equal patch-ids. Doc only. Pushed tip 3569bf54f 23:32:54Z 10-23. Boyd APPROVE c31450 rev 3757, grep with positive control in BUILD.md. Rode the full gate GREEN 23:32:28Z on 3569bf54f. Live unchanged 45dca69f6. |
|
||||
| 322 | F8/F9: case2-install.sh runs the library persona import from the stack copy, and its refusal carries the error line (433 Run B, ops-0273) | goldie; reviewers morgana | #1818 | required; done | — | Morgana approves (install path). The new case2-install test fails on 094124b4e and passes on the candidate; a mutant restoring REPO at :185 is killed; Sage's full landing gate runs every package suite and scripts/test-*.sh. Then ops-01 reruns Run B P1. (sage) | `docs/plans/2026-10-23_host-fixes-1816-brief.md` § Brief: F8 and F9, the library persona import runs without the clone's node_modules | LANDED 467b05fe2, cherry-pick of 7b4ea4217 on 860afc2b6; added and removed lines identical, patch-id differs only by BUILD-LOG/SESSIONS append context (both sides kept). Pushed with tip 3569bf54f 23:32:54Z 10-23. Morgana APPROVE c31451 rev 3758, N1-N3 non-blocking. Sage full gate GREEN 23:32:28Z on 3569bf54f, all suites 0 fail, every scripts/test-*.sh rc 0. Next: ops-01 Run B P1 rerun on 467b05fe2 (mos-1553). Live unchanged 45dca69f6. |
|
||||
| 323 | Case 1 factory business file for .53: docs/guides/case1-business.mosaic-stack.json plus a loader and factory-check test (#414 stage 2, ops-0277) | goldie; reviewers duckworth | #1819 | done | — | Sage full gate: all package suites except queue plus every scripts/test-*.sh (mos-1455); Duckworth review; new test file and one bus.tester mutant on CLEAR (sage) | `docs/plans/2026-10-23_case1-business-file-brief.md` § Brief: the Case 1 factory business file | LANDED 571376c30 + evidence 6a0f0f4f8 (ff from dd6cb5872), tip 99930362e. Duckworth approve c31477 rev 3778 on 6a0f0f4f8. Sage gate green 00:54Z on 6a0f0f4f8: all package suites 0 fail, every scripts/test-*.sh rc 0. Root cause of the revoke-window red: TMPDIR length, tools.sock over 107 bytes (session.mjs:278). Brief template rule and DEFERRED entry in c25799928 (mos-1561). N1/N2 doc only. |
|
||||
| 324 | Tracker optional in the business file (Q247) | goldie; reviewers duckworth | #1820 | done | — | New tests only. Absent tracker: loader and validate pass, the bus boots with no credential-file error and no @sync. Present tracker unchanged. A mutant that skips the presence check is killed. Then whole touched test files, then the full landing gate. (sage) | `docs/plans/2026-10-23_optional-tracker-brief.md` § Brief: tracker becomes optional in the business file | LANDED 7e59ba8b3 + evidence dbdfcfd52 (ff from 3569bf54f), pushed with tip dd6cb5872 00:06:58Z 10-24. Duckworth APPROVE c31470 rev 3770 (static, N1 doc only). Goldie: new 5/5 and 4/4, business.test 21/21, presence mutant killed. Sage full gate GREEN 00:05:49Z on 64ea54083: every package suite 0 fail 0 skip (business 254, cli 986, bus 924, plugins 119, webui 411), every scripts/test-*.sh rc 0. Live unchanged 45dca69f6. Run B N1 for ops-01 P2/P3 (mos-1558). |
|
||||
| 325 | Case 1 business file: re-key project to stack to match .mosaic/project.json, plus a project-file test (#414 stage 2, ops-0288, mos-1563) | goldie; reviewers gladstone | #1821 | in-review, round 1, request posted | — | New test file and packages/business whole on CLEAR; one key mutant killed; Gladstone review; Sage full landing gate (mos-1455) (sage) | `docs/plans/2026-10-24_case1-project-key-brief.md` § Brief: re-key the Case 1 project to stack | — |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+1831
-21
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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), []);
|
||||
});
|
||||
@@ -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/);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,147 @@
|
||||
// Row 323 (#1819): .53 factory business document; all execution stays in scratch fixtures.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { loadBusiness } from "../src/index.mjs";
|
||||
import { REPO, REPO_ROLES, systemConfig, tmp, writeJson } from "./helpers.mjs";
|
||||
import { bootConfig } from "../../cli/src/config.mjs";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { Client } from "../../bus/src/client.mjs";
|
||||
|
||||
const DOCUMENT = join(REPO, "docs/guides/case1-business.mosaic-stack.json");
|
||||
const COMMIT = "c".repeat(40);
|
||||
const document = () => JSON.parse(readFileSync(DOCUMENT, "utf8"));
|
||||
|
||||
function world(t) {
|
||||
const root = tmp();
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const home = join(root, "home");
|
||||
const configDir = join(home, ".config/mosaic-dev");
|
||||
mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
||||
const doc = document();
|
||||
// Replace the approved host project path only in the scratch execution input.
|
||||
// The exact shipped document is separately loaded below without any substitution.
|
||||
for (const project of Object.values(doc.projects)) {
|
||||
project.root = join(root, "fixture-project");
|
||||
mkdirSync(project.root, { recursive: true });
|
||||
}
|
||||
const input = writeJson(join(root, "input/mosaic-stack.json"), doc);
|
||||
const config = join(configDir, "config.json");
|
||||
const business = join(configDir, "businesses/mosaic-stack.json");
|
||||
const copy = join(root, "fake-stack-copy");
|
||||
writeFileSync(copy, '#!/usr/bin/env bash\n[ "$1" = copy ] && [ "$3" = --copies ] || exit 4\nmkdir -p "$4/$2/scripts" && : >"$4/$2/scripts/mosaic"\n', { mode: 0o755 });
|
||||
const env = {
|
||||
PATH: `${dirname(process.execPath)}:/usr/bin:/bin`, HOME: home,
|
||||
MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: REPO_ROLES, CASE2_STACK_COPY: copy,
|
||||
};
|
||||
const install = () => spawnSync(join(REPO, "scripts/case2-install.sh"), [
|
||||
"--business", "mosaic-stack", "--business-file", input, "--commit", COMMIT, "--profile", "factory",
|
||||
], { env, encoding: "utf8", timeout: 30000 });
|
||||
return { root, home, doc, input, config, configDir, business, env, install };
|
||||
}
|
||||
|
||||
const output = (r) => r.stdout + r.stderr;
|
||||
|
||||
function configure(x) {
|
||||
// Reuse the existing scratch system-config fixture, never a real install config.
|
||||
const fixture = JSON.parse(readFileSync(systemConfig(x.root), "utf8"));
|
||||
fixture.dataRoot = join(x.root, "data");
|
||||
mkdirSync(fixture.dataRoot, { recursive: true, mode: 0o700 });
|
||||
writeJson(x.config, fixture);
|
||||
writeJson(x.business, x.doc);
|
||||
return fixture;
|
||||
}
|
||||
|
||||
test("row 323: committed Case 1 document loads unchanged through the business loader", (t) => {
|
||||
const x = world(t);
|
||||
const bytes = readFileSync(DOCUMENT);
|
||||
mkdirSync(dirname(x.business), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(x.business, bytes, { mode: 0o600 });
|
||||
const b = loadBusiness("mosaic-stack", { dir: x.configDir, rolesDir: REPO_ROLES });
|
||||
assert.deepEqual(readFileSync(x.business), bytes);
|
||||
assert.equal(b.id, "mosaic-stack");
|
||||
assert.equal(b.human, "jason");
|
||||
assert.equal(b.vars["bus.tester"], true);
|
||||
assert.equal(b.tracker, null);
|
||||
assert.equal(Object.hasOwn(document(), "tracker"), false);
|
||||
assert.equal(Object.hasOwn(document(), "library"), false);
|
||||
assert.deepEqual(b.projects, { "stack": { root: "/srv/mosaic/src/mosaic-stack" } });
|
||||
assert.deepEqual(Object.keys(b.roles), ["chief", "assistant"]);
|
||||
assert.deepEqual(b.plugins.harness, ["claude-code"]);
|
||||
assert.equal(b.launch.by, "chief");
|
||||
assert.deepEqual(b.launch.instances, ["assistant"]);
|
||||
assert.deepEqual(b.arbiters, { delivery: "chief", technical: "chief" });
|
||||
assert.deepEqual(b.launch.models["claude-opus-5-5"], { provider: "anthropic", model: "claude-opus-5-5" });
|
||||
for (const role of Object.values(b.roles)) {
|
||||
assert.equal(role.vars.harness, "claude-code");
|
||||
assert.equal(role.vars.model, "claude-opus-5-5");
|
||||
assert.equal(role.vars.thinking, "high");
|
||||
assert.deepEqual(role.credentials, {});
|
||||
assert.equal(role.tracker, null);
|
||||
}
|
||||
});
|
||||
|
||||
test("row 323: real business validate accepts the Case 1 fixture without tracker credentials", (t) => {
|
||||
const x = world(t);
|
||||
configure(x);
|
||||
const r = spawnSync(process.execPath, [join(REPO, "packages/business/src/cli.mjs"), "validate", "mosaic-stack"],
|
||||
{ env: x.env, encoding: "utf8", timeout: 30000 });
|
||||
assert.equal(r.status, 0, output(r));
|
||||
assert.equal(JSON.parse(r.stdout).business, "mosaic-stack");
|
||||
});
|
||||
|
||||
test("row 323: real factory install accepts the document's scratch input and writes it unchanged", (t) => {
|
||||
const x = world(t);
|
||||
const r = x.install();
|
||||
assert.equal(r.status, 0, output(r));
|
||||
assert.deepEqual(JSON.parse(readFileSync(x.business, "utf8")), x.doc);
|
||||
assert.match(output(r), /business mosaic-stack validates/);
|
||||
assert.equal(existsSync(join(x.configDir, "personas")), false);
|
||||
assert.equal(existsSync(join(x.configDir, "secrets/mosaic-stack")), false);
|
||||
});
|
||||
|
||||
test("row 323: the Case 1 business boots without a tracker or @sync credential", async (t) => {
|
||||
const x = world(t);
|
||||
const system = configure(x);
|
||||
const boot = bootConfig({ system, businessId: "mosaic-stack", env: x.env });
|
||||
assert.equal(Object.hasOwn(boot, "trackers"), false);
|
||||
const runtime = await startBroker(boot);
|
||||
t.after(() => runtime.close());
|
||||
assert.deepEqual(runtime.credentials.status(), []);
|
||||
const reader = new Client({ path: runtime.path, cap: runtime.readers[0].cap });
|
||||
assert.deepEqual(await reader.call("credentials"), []);
|
||||
});
|
||||
|
||||
test("row 323: factory install refuses bus.tester false before writing", (t) => {
|
||||
const x = world(t);
|
||||
x.doc.vars["bus.tester"] = false;
|
||||
writeJson(x.input, x.doc);
|
||||
const r = x.install();
|
||||
assert.equal(r.status, 1, output(r));
|
||||
assert.match(output(r), /refused: bus-tester-off:/);
|
||||
assert.equal(existsSync(x.business), false);
|
||||
assert.equal(existsSync(x.config), false);
|
||||
});
|
||||
|
||||
test("row 323: literal library key refuses through final business validation", (t) => {
|
||||
const x = world(t);
|
||||
x.doc.library = {};
|
||||
writeJson(x.input, x.doc);
|
||||
const r = x.install();
|
||||
assert.equal(r.status, 1, output(r));
|
||||
assert.match(output(r), /refused: validate: .*unsupported business file key: "library"/);
|
||||
assert.equal(existsSync(x.business), false);
|
||||
});
|
||||
|
||||
test("row 323: a library project refuses through the factory profile plan", (t) => {
|
||||
const x = world(t);
|
||||
x.doc.projects.books = { kind: "library" };
|
||||
writeJson(x.input, x.doc);
|
||||
const r = x.install();
|
||||
assert.equal(r.status, 1, output(r));
|
||||
assert.match(output(r), /refused: profile-library-business:/);
|
||||
assert.equal(existsSync(x.business), false);
|
||||
assert.equal(existsSync(x.config), false);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// Row 325 (#1821): factory validation must load the repository's actual project document.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { REPO, REPO_ROLES, systemConfig, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
const CASE1 = join(REPO, "docs/guides/case1-business.mosaic-stack.json");
|
||||
const PROJECT = join(REPO, ".mosaic/project.json");
|
||||
const document = () => JSON.parse(readFileSync(CASE1, "utf8"));
|
||||
|
||||
function fixture(t, { oldKey = false } = {}) {
|
||||
const root = tmp();
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const doc = document();
|
||||
assert.equal(Object.keys(doc.projects).length, 1);
|
||||
const [key] = Object.keys(doc.projects);
|
||||
const projectRoot = join(root, "checkout");
|
||||
const projectFile = join(projectRoot, ".mosaic/project.json");
|
||||
const bytes = readFileSync(PROJECT);
|
||||
mkdirSync(join(projectRoot, ".mosaic"), { recursive: true });
|
||||
writeFileSync(projectFile, bytes);
|
||||
assert.deepEqual(readFileSync(projectFile), bytes, "fixture must use the real project's unmodified bytes");
|
||||
const entry = { ...doc.projects[key], root: projectRoot };
|
||||
doc.projects = { [oldKey ? "mosaic-stack" : key]: entry };
|
||||
const config = systemConfig(root);
|
||||
writeJson(join(root, "config/businesses/mosaic-stack.json"), doc);
|
||||
return {
|
||||
project: JSON.parse(bytes), projectFile,
|
||||
validate: () => spawnSync(process.execPath, [join(REPO, "packages/business/src/cli.mjs"), "validate", "mosaic-stack"], {
|
||||
env: { ...process.env, HOME: root, MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: REPO_ROLES },
|
||||
encoding: "utf8", timeout: 30000,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
test("row 325: the Case 1 project's sole key matches the repository project id", () => {
|
||||
const doc = document();
|
||||
const project = JSON.parse(readFileSync(PROJECT, "utf8"));
|
||||
assert.equal(project.id, "stack");
|
||||
assert.deepEqual(Object.keys(doc.projects), [project.id]);
|
||||
assert.equal(doc.id, "mosaic-stack");
|
||||
assert.equal(doc.projects[project.id].root, "/srv/mosaic/src/mosaic-stack");
|
||||
});
|
||||
|
||||
test("row 325: real business validate reports the copied repository project valid", (t) => {
|
||||
const x = fixture(t);
|
||||
const r = x.validate();
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
const result = JSON.parse(r.stdout);
|
||||
assert.equal(result.business, "mosaic-stack");
|
||||
assert.deepEqual(result.projects, { [x.project.id]: "valid" });
|
||||
assert.doesNotMatch(r.stderr, /no project file|project variables/);
|
||||
});
|
||||
|
||||
test("row 325: the old mosaic-stack project key refuses against those same project bytes", (t) => {
|
||||
const x = fixture(t, { oldKey: true });
|
||||
const r = x.validate();
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
assert.equal(r.stdout, "");
|
||||
assert.match(r.stderr, /has id stack, but business mosaic-stack declares it as mosaic-stack/);
|
||||
assert.ok(r.stderr.includes(x.projectFile));
|
||||
assert.doesNotMatch(r.stderr, /no project file/);
|
||||
});
|
||||
@@ -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"/);
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }];
|
||||
|
||||
@@ -67,7 +67,11 @@ function world(t, { profile = "library" } = {}) {
|
||||
const log = join(root, "calls.log");
|
||||
script(join(bin, "stack-copy"), `echo "stack-copy $*" >>'${log}'
|
||||
[ "$1" = copy ] && [ "$3" = --copies ] || exit 4
|
||||
mkdir -p "$4/$2/scripts" && : >"$4/$2/scripts/mosaic" && echo "copied: $4/$2"`);
|
||||
mkdir -p "$4/$2/scripts" && : >"$4/$2/scripts/mosaic" && echo "copied: $4/$2"
|
||||
for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/business/src/vocabulary.mjs; do
|
||||
mkdir -p "$4/$2/$(dirname "$file")" && cp '${REPO}'"$file" "$4/$2/$file" || exit 1
|
||||
done
|
||||
mkdir -p "$4/$2/node_modules" && cp -R '${REPO}node_modules/yaml' "$4/$2/node_modules/yaml"`);
|
||||
script(join(bin, "systemctl"), `echo "systemctl $*" >>'${log}'`);
|
||||
const me = spawnSync("id", ["-un"], { encoding: "utf8" }).stdout.trim();
|
||||
script(join(bin, "getent"), `[ "$1" = passwd ] && [ "$2" = '${me}' ] && echo '${me}:x:${UID}:${GID}::${home}:/bin/bash'`);
|
||||
@@ -417,3 +421,55 @@ test("Vikunja 614: install preserves the explicit notifier decision bytes and mo
|
||||
assert.equal(fresh.code, 0, fresh.out);
|
||||
assert.equal(existsSync(missing), false);
|
||||
});
|
||||
|
||||
// Row 322 (#1818), F8/F9: a clone without dependencies and a dependency-equipped copy.
|
||||
import { cpSync } from "node:fs";
|
||||
|
||||
function dependencyFreeClone(t) {
|
||||
const x = world(t);
|
||||
const clone = join(x.root, "clone");
|
||||
mkdirSync(join(clone, "scripts"), { recursive: true });
|
||||
for (const file of ["case2-install.sh", "case2-persona-import.mjs"]) {
|
||||
cpSync(join(REPO, "scripts", file), join(clone, "scripts", file));
|
||||
}
|
||||
// Isolate the persona step; bootstrap and business validation are covered above.
|
||||
script(join(clone, "scripts", "bootstrap.sh"), "exit 0");
|
||||
script(join(clone, "scripts", "mosaic"), "exit 0");
|
||||
assert.equal(existsSync(join(clone, "node_modules")), false);
|
||||
const run = () => {
|
||||
const r = spawnSync(join(clone, "scripts", "case2-install.sh"), [
|
||||
"--business", "acme", "--business-file", x.input, "--commit", COMMIT,
|
||||
"--profile", "library", "--personas", x.staging,
|
||||
], { env: env(x), encoding: "utf8", timeout: 30000 });
|
||||
const out = r.stdout + r.stderr;
|
||||
clean(out);
|
||||
return { code: r.status, out };
|
||||
};
|
||||
return { ...x, clone, run };
|
||||
}
|
||||
|
||||
test("row 322 F8: library personas install from the copy with no clone node_modules", (t) => {
|
||||
const x = dependencyFreeClone(t);
|
||||
const r = x.run();
|
||||
assert.equal(r.code, 0, r.out);
|
||||
for (const instance of ["bean", "chet"]) {
|
||||
assert.equal(readFileSync(join(x.personaDir, `${instance}.md`), "utf8"), PERSONA_TEXT[instance]);
|
||||
assert.equal(mode(join(x.personaDir, `${instance}.md`)), 0o600);
|
||||
}
|
||||
assert.equal(existsSync(join(x.clone, "node_modules")), false);
|
||||
});
|
||||
|
||||
test("row 322 F9: importer refusal keeps the error line rather than the Node banner", (t) => {
|
||||
const x = dependencyFreeClone(t);
|
||||
const copy = join(x.home, ".local", "share", "mosaic-stack", "copies", COMMIT);
|
||||
// Real Node module-load error: the error precedes its stack and version banner.
|
||||
const fake = join(x.bin, "stack-copy");
|
||||
writeFileSync(fake, `${readFileSync(fake, "utf8")}\nrm -rf '${copy}/node_modules/yaml'\n`);
|
||||
const r = x.run();
|
||||
refused(r, /persona-import: Error \[ERR_MODULE_NOT_FOUND\]: Cannot find package 'yaml'/);
|
||||
const refusal = r.out.split("\n").filter((line) => line.includes("refused:"));
|
||||
assert.equal(refusal.length, 1, r.out);
|
||||
assert.doesNotMatch(r.out, /Node\.js v|\n\s+at /);
|
||||
assert.equal(existsSync(x.business), false);
|
||||
assert.equal(existsSync(x.personaDir), false);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -9,6 +9,9 @@ const VERBS = new Set([
|
||||
"thread.messages",
|
||||
"thread.send",
|
||||
"thread.open",
|
||||
"thread.shares",
|
||||
"thread.share",
|
||||
"thread.unshare",
|
||||
"session.list",
|
||||
"session.create",
|
||||
"session.clear",
|
||||
|
||||
@@ -1091,3 +1091,16 @@ During Q175's read-only interim both buttons show as unavailable and point to
|
||||
`mosaic halt` / `mosaic resume` in the instance CLI. There is no halt-only send
|
||||
exception or alternate authentication path. Landing the UI and activating it
|
||||
remain separately gated.
|
||||
|
||||
Row 208 (193d): Chat includes a **Shared with me** list. Shared threads are
|
||||
read-only, including for the business owner. On your own writable threads,
|
||||
**Share read-only** accepts `user:ID`, the business owner's name, or `*` for
|
||||
everyone with coverage; **Unshare** ends that target's share. A share grants
|
||||
no project or instance access of its own.
|
||||
|
||||
Viewers can read shared threads and their old own threads. Their pages omit
|
||||
creation, composer, resume, share and session mutation controls. Mixed grants
|
||||
apply separately to each project and instance; a collaborator grant elsewhere
|
||||
does not enable writes here. Grant hints refresh on each user chat render and
|
||||
background refresh, while the bus decides every operation from live grants.
|
||||
The existing user notice names the governance records visible to the owner.
|
||||
@@ -18,6 +18,16 @@ export function consoleAccess({ load, business }) {
|
||||
} catch { return null; }
|
||||
};
|
||||
return Object.freeze({
|
||||
// UI hints only: no grants belonging to another user, receipts or subjects escape.
|
||||
chatGrants: session => {
|
||||
try {
|
||||
if (lookup(session.provider, session.sub) !== session.user) return [];
|
||||
const doc = load(), b = business();
|
||||
return doc.grants.filter(g => g.user === session.user && g.business === b &&
|
||||
covers(g, { business: b, project: g.projects[0], instance: g.instances[0] }, new Date(), { level: 'collaborator' }))
|
||||
.map(g => ({ projects: [...g.projects], instances: [...g.instances] }));
|
||||
} catch { return []; }
|
||||
},
|
||||
// The user id this (provider, sub) signs in as, or null.
|
||||
find: ({ provider, sub }) => lookup(provider, sub),
|
||||
// Is this session's user still the holder of its own subject, with a live grant?
|
||||
|
||||
@@ -22,6 +22,7 @@ const STATUS = {
|
||||
'unknown-verb': 503, 'channel-operation': 503,
|
||||
'tester-off': 403, 'tester-required': 403, 'tester-refused': 403,
|
||||
// FED R6 (#1800): the broker's refusals of a granted user.
|
||||
'share-not-covered': 403, 'share-exists': 409, 'share-not-found': 404,
|
||||
'access-refused': 403, 'access-revoked': 401,
|
||||
};
|
||||
function safeMessage(code) {
|
||||
@@ -72,7 +73,7 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site');
|
||||
}
|
||||
try {
|
||||
const match = /^\/api\/chat\/threads\/([^/]+)\/(messages|open|sessions|clear|cancel|operation)$/.exec(path);
|
||||
const match = /^\/api\/chat\/threads\/([^/]+)\/(messages|open|sessions|clear|cancel|operation|shares|share|unshare)$/.exec(path);
|
||||
if (match) {
|
||||
thread = match[1];
|
||||
if (!id(thread)) throw Error();
|
||||
@@ -89,6 +90,9 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
|
||||
if (!exact(v, ['project', 'name', 'instance'], ['project', 'name']) || !id(v.project) || !name(v.name) ||
|
||||
(Object.hasOwn(v, 'instance') && (typeof v.instance !== 'string' || !KEY.test(v.instance)))) throw Error();
|
||||
action = 'createThread'; args = v;
|
||||
} else if (['share', 'unshare'].includes(match?.[2])) {
|
||||
if (!exact(v, ['to']) || typeof v.to !== 'string' || !v.to.length || v.to.length > 200 || /[\r\n\0]/.test(v.to)) throw Error();
|
||||
action = match[2]; args = { thread, to: v.to };
|
||||
} else if (match?.[2] === 'sessions') {
|
||||
if (!exact(v, ['name', 'op']) || !name(v.name) || !KEY.test(v.op ?? '')) throw Error();
|
||||
action = 'createSession'; args = { thread, name: v.name, op: v.op };
|
||||
@@ -109,9 +113,12 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
|
||||
} else if (path === '/api/chat/projects') {
|
||||
query(search, []); action = 'projects'; args = {};
|
||||
} else if (path === '/api/chat/threads') {
|
||||
const q = query(search, ['project']);
|
||||
const q = query(search, ['project', 'shared']);
|
||||
if (q.has('shared') && q.get('shared') !== 'true') throw Error();
|
||||
if (q.has('project') && !id(q.get('project'))) throw Error();
|
||||
action = 'threads'; args = q.has('project') ? { project: q.get('project') } : {};
|
||||
action = 'threads'; args = { ...(q.has('project') ? { project: q.get('project') } : {}), ...(q.has('shared') ? { shared: true } : {}) };
|
||||
} else if (match?.[2] === 'shares') {
|
||||
query(search, []); action = 'shares'; args = { thread };
|
||||
} else if (match?.[2] === 'sessions') {
|
||||
query(search, []); action = 'sessions'; args = { thread };
|
||||
} else if (match?.[2] === 'operation') {
|
||||
@@ -155,6 +162,13 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
|
||||
} : {};
|
||||
return json(res, 200, { rows: value, ...page, at: new Date().toISOString() });
|
||||
}
|
||||
if (action === 'share' || action === 'unshare') {
|
||||
if (value?.thread !== thread || value?.to !== args.to || value?.op !== action) {
|
||||
line('error=outcome-unknown'); return refuse(503, 'outcome-unknown', 'unknown');
|
||||
}
|
||||
line(`thread=${thread} op=${action}`);
|
||||
return json(res, 200, { thread, to: value.to, op: value.op });
|
||||
}
|
||||
if (!value || (action === 'open' ? value.opened !== thread : !id(value.id)) ||
|
||||
(action === 'createThread' && (typeof value.instance !== 'string' || !KEY.test(value.instance)))) {
|
||||
line('error=outcome-unknown');
|
||||
|
||||
@@ -24,7 +24,10 @@ export function createChat({ call, owner = null }) {
|
||||
return Object.freeze({
|
||||
projects: () => channel('project.list', {}),
|
||||
createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
|
||||
threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
|
||||
threads: ({ project, shared } = {}) => channel('thread.list', { ...(project === undefined ? {} : { project }), ...(shared === true ? { shared: true } : {}) }),
|
||||
shares: ({ thread }) => channel('thread.shares', { thread }),
|
||||
share: ({ thread, to }) => channel('thread.share', { thread, to }),
|
||||
unshare: ({ thread, to }) => channel('thread.unshare', { thread, to }),
|
||||
createThread: ({ project, name, instance }) => channel('thread.create', { project, name, ...(instance === undefined ? {} : { instance }) }),
|
||||
sessions: ({ thread }) => channel('session.list', { thread }),
|
||||
createSession: ({ thread, name, op }) => channel('session.create', { thread, name, op }),
|
||||
|
||||
@@ -1232,7 +1232,7 @@
|
||||
window.MosaicChat?.leave(path);
|
||||
if (path === '/chat' || path.startsWith('/chat/')) {
|
||||
// Row 177 (#1675): the tester Console writes to its own threads.
|
||||
await window.MosaicChat.render({ path, container: $('bus-view'), canSend: canSend || isTester, navigated });
|
||||
await window.MosaicChat.render({ path, container: $('bus-view'), canSend: canSend || isTester, tester: isTester, navigated });
|
||||
if (g !== gen || out()) return;
|
||||
shown = route; schedule(); return;
|
||||
}
|
||||
|
||||
@@ -57,23 +57,35 @@
|
||||
const disabledSession = pending => disabled(pending || selectedInactive());
|
||||
const disabled = pending => !current.canSend || out() || pending ? ' disabled' : '';
|
||||
const stamp = at => `<time>${txt(at)}</time>`;
|
||||
// Per-grant UI hints; all writes still use the caller's bus principal.
|
||||
const scopeWrites = (project, instance) => !current.user || current.chatGrants.some(g =>
|
||||
(g.projects.includes('*') || g.projects.includes(project)) &&
|
||||
(instance === undefined || g.instances.includes('*') || g.instances.includes(instance)));
|
||||
const threadWrites = () => !!current.thread && !current.shared && scopeWrites(current.thread?.project, current.thread?.instance);
|
||||
function sharing(t) {
|
||||
if (!current.canSend || !threadWrites() || current.tester) return '';
|
||||
const f = form('share:' + t.id);
|
||||
return `<section aria-label="Thread sharing"><h2>Share read-only</h2><p>Share with a user (user:id), the business owner's name, or * for everyone granted on this thread.</p><ul>${(current.shares ?? []).map(r => `<li>${txt(r.to)} <button class="btn" type="button" data-unshare="${esc(r.to)}"${disabled(f.pending)}>Unshare</button></li>`).join('')}</ul><form id="chat-share-form"><label>Share with<input id="chat-share-target" value="${esc(f.to ?? '')}" maxlength="200" required></label><button class="btn" type="submit"${disabled(f.pending)}>Share read-only</button></form><p role="status">${txt(f.receipt)}</p></section>`;
|
||||
}
|
||||
function creation(kind, key) {
|
||||
const f = form(key), project = kind === 'project';
|
||||
if (current.user && (project || !scopeWrites(key))) return '';
|
||||
const repo = project && current.repos.length ? `<label>Repository (optional)<select id="chat-repo" data-chat-field="repo"><option value="">No repository link</option>${current.repos.map(r => `<option value="${esc(r)}"${r === f.repo ? ' selected' : ''}>${txt(r)}</option>`).join('')}</select></label>` : '';
|
||||
const choices = current.instances ?? [];
|
||||
const choices = (current.instances ?? []).filter(r => scopeWrites(key, r.instance));
|
||||
const picker = project ? '' : `<label>Thread with<select id="chat-instance" data-chat-field="instance" required${disabled(f.pending || !!current.instanceError || !choices.length)}><option value="">Choose who this thread is with</option>${choices.map(row => `<option value="${esc(row.instance)}"${row.instance === f.instance ? ' selected' : ''}>${txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)}</option>`).join('')}</select></label>`;
|
||||
const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));
|
||||
const status = !project && current.instanceError ? current.instanceError : f.receipt || (!project && !choices.length ? 'No thread instances are configured.' : '');
|
||||
return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}${picker}<button class="btn" type="submit"${disabled(f.pending || unavailable)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(status)}</p></form>`;
|
||||
}
|
||||
function threads(rows) {
|
||||
return rows.length ? `<ul class="chat-thread-list">${rows.map(t => `<li>${link('threads', t.id, t.name)} <span class="muted">${txt(t.instance)}</span></li>`).join('')}</ul>` : '<p>No threads yet.</p>';
|
||||
return rows.length ? `<ul class="chat-thread-list">${rows.map(t => `<li>${link('threads', t.id, t.name)} <span class="muted">${txt(t.instance)}${t.shared ? ` · Shared by ${txt(t.owner)}` : ''}</span></li>`).join('')}</ul>` : '<p>No threads yet.</p>';
|
||||
}
|
||||
const sessionPath = (thread, session) => `#/chat/threads/${thread}/sessions/${session}`;
|
||||
const countLabel = value => !value ? 'unavailable (count collector not installed)' :
|
||||
value.known === true && Number.isSafeInteger(value.count) && value.count >= 0 ? String(value.count) :
|
||||
`unknown (${value.reason ?? 'not-collected'})`;
|
||||
function sessionControls(t) {
|
||||
if (!threadWrites()) return '<p>Read-only thread.</p>';
|
||||
if (current.legacyDefault) return '<p role="status">Sessions are not available for this account.</p>';
|
||||
const c = current, selected = c.sessions.find(r => r.id === c.session), f = form('session:' + t.id);
|
||||
const clear = form('clear:' + c.key);
|
||||
@@ -84,7 +96,7 @@
|
||||
const s = state(current.key);
|
||||
const messages = s.rows.map(m => `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(m.from_role)}</strong> ${stamp(m.at)}</div><div class="chat-body">${txt(m.body)}</div></li>`).join('');
|
||||
const receipt = s.receipt;
|
||||
return `<section class="chat-conversation" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}${sessionControls(t)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p><button class="btn" type="button" id="chat-resume"${disabledSession(s.pending)}>Resume assistant</button><ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || '<li class="muted">No messages yet.</li>'}</ol><div class="chat-paging">${s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div><form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabledSession(s.pending)}>Send</button></form><p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
|
||||
return `<section class="chat-conversation" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}${sessionControls(t)}${sharing(t)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending)}>Resume assistant</button>` : ''}<ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || '<li class="muted">No messages yet.</li>'}</ol><div class="chat-paging">${s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div>${threadWrites() ? `<form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabledSession(s.pending)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
|
||||
}
|
||||
function capture() {
|
||||
if (!current) return null;
|
||||
@@ -98,16 +110,16 @@
|
||||
let html = '<div class="chat-area"><p><a href="#/chat">Chat projects</a></p>';
|
||||
if (c.error) html += `<p class="chat-error banner err" role="alert">${txt(c.error)}</p>`;
|
||||
if (c.kind === 'thread') {
|
||||
const t = c.threads.find(t => t.id === c.id);
|
||||
const t = c.thread;
|
||||
if (t && (c.session || c.legacyDefault) && !c.refused) html += conversation(t);
|
||||
else html += heading('Thread unavailable');
|
||||
} else if (c.kind === 'project') {
|
||||
const p = c.projects.find(p => p.id === c.id);
|
||||
html += heading(p?.name ?? 'Project unavailable');
|
||||
if (p && !c.refused) html += threads(c.threads) + creation('thread', c.id);
|
||||
if (p && !c.refused) html += threads(c.threads.filter(t => !t.shared)) + '<h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('thread', c.id);
|
||||
} else {
|
||||
html += heading('Chat') + '<p>Business projects and your named assistant threads.</p><div class="chat-projects">' +
|
||||
c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id))}</section>`).join('') + (c.projects.length ? '' : '<p>No projects yet.</p>') + '</div>' + creation('project', 'projects');
|
||||
c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}</section>`).join('') + (c.projects.length ? '' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects');
|
||||
}
|
||||
if (!c.canSend) html += '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
|
||||
c.container.innerHTML = html + '</div>';
|
||||
@@ -132,7 +144,7 @@
|
||||
err.code === 'thread-not-found' ? 'Thread not found.' :
|
||||
err.code === 'session-not-found' ? 'Session not found in this thread.' : 'Chat could not be read. Refresh to try again; your draft is kept.';
|
||||
}
|
||||
async function render({ path, container, canSend, navigated = false, next = false }) {
|
||||
async function render({ path, container, canSend, user = false, tester = false, navigated = false, next = false }) {
|
||||
if (out()) return;
|
||||
const match = /^\/chat(?:\/(projects|threads)\/([0-9a-f-]+)(?:\/sessions\/([0-9a-f-]+))?)?$/.exec(path);
|
||||
controller?.abort(); controller = new AbortController();
|
||||
@@ -144,23 +156,46 @@
|
||||
paint(); return;
|
||||
}
|
||||
const old = current;
|
||||
current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', refused: false, legacyDefault: false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
|
||||
current = { container, kind, id, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', refused: false, legacyDefault: false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
|
||||
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
|
||||
try {
|
||||
if (user) {
|
||||
const config = await fetch('/api/config', { cache: 'no-store', redirect: 'manual', signal });
|
||||
if (config.type === 'opaqueredirect' || config.status === 401) { signOut(); return; }
|
||||
if (!config.ok) throw new ChatError('invalid-response');
|
||||
const hints = await config.json();
|
||||
if (g !== generation || out()) return;
|
||||
current.chatGrants = Array.isArray(hints.chatGrants) ? hints.chatGrants : [];
|
||||
current.canSend = hints.send === true;
|
||||
}
|
||||
// Resolve the explicit session before touching history/cursors. Missing selectors never
|
||||
// silently fall back to a sibling. The saved default is used only for thread-only routes.
|
||||
const [projects, listed, catalog] = await Promise.all([
|
||||
const [projects, listed, shared] = await Promise.all([
|
||||
request('projects', undefined, signal),
|
||||
request('threads' + (kind === 'project' ? '?project=' + id : ''), undefined, signal),
|
||||
kind === 'thread' ? request(`threads/${id}/sessions`, undefined, signal).catch(err => {
|
||||
// A denied optional catalog does not revoke an existing default-thread grant.
|
||||
// Explicit selectors and every other failure keep their refusal/error state.
|
||||
if (err.code === 'access-refused' && !match[3]) return null;
|
||||
throw err;
|
||||
}) : null,
|
||||
tester ? { rows: [] } : request('threads?shared=true' + (kind === 'project' ? '&project=' + id : ''), undefined, signal).catch(err => {
|
||||
if (['signed-out', 'access-revoked'].includes(err.code)) throw err;
|
||||
return { rows: [] };
|
||||
}),
|
||||
]);
|
||||
if (g !== generation || out()) return;
|
||||
current.projects = projects.rows; current.threads = listed.rows;
|
||||
current.projects = projects.rows;
|
||||
const own = new Set(listed.rows.map(t => t.id));
|
||||
current.threads = [...listed.rows, ...shared.rows.filter(t => !own.has(t.id)).map(t => ({ ...t, shared: true }))];
|
||||
current.thread = current.threads.find(t => t.id === id);
|
||||
current.shared = current.thread?.shared === true;
|
||||
const catalog = kind === 'thread' && !current.shared ? await request(`threads/${id}/sessions`, undefined, signal).catch(err => {
|
||||
if (err.code === 'access-refused' && !match[3]) return null;
|
||||
throw err;
|
||||
}) : null;
|
||||
if (g !== generation || out()) return;
|
||||
if (current.shared && match[3]) throw new ChatError('session-not-found', 404);
|
||||
current.shares = [];
|
||||
if (kind === 'thread' && current.canSend && threadWrites() && !tester) {
|
||||
const shares = await request(`threads/${id}/shares`, undefined, signal).catch(() => ({ rows: [] }));
|
||||
if (g !== generation || out()) return;
|
||||
current.shares = shares.rows;
|
||||
}
|
||||
let s = null, page = null, after = null;
|
||||
if (kind === 'thread' && catalog === null) {
|
||||
current.legacyDefault = true; current.sessions = []; current.session = null;
|
||||
@@ -208,13 +243,13 @@
|
||||
if (g !== generation || out()) return;
|
||||
if (!listed.rows.every(row => typeof row?.instance === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(row.instance) && typeof row.name === 'string'))
|
||||
throw new ChatError('invalid-response');
|
||||
current.instances = listed.rows; current.instanceError = '';
|
||||
current.instances = listed.rows.filter(r => scopeWrites(id, r.instance)); current.instanceError = '';
|
||||
const f = form(id);
|
||||
if (f.instance && !listed.rows.some(row => row.instance === f.instance)) {
|
||||
if (f.instance && !current.instances.some(row => row.instance === f.instance)) {
|
||||
f.instance = ''; f.instanceChosen = true;
|
||||
f.receipt = 'The selected instance is no longer available. Choose who this thread is with.';
|
||||
}
|
||||
if (!f.instanceChosen && !f.instance && listed.rows.length === 1) f.instance = listed.rows[0].instance;
|
||||
if (!f.instanceChosen && !f.instance && current.instances.length === 1) f.instance = current.instances[0].instance;
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError' || out() || g !== generation) return;
|
||||
current.instances = [];
|
||||
@@ -244,7 +279,7 @@
|
||||
for (const key of forms.keys()) if (key.includes(id)) forms.delete(key);
|
||||
}
|
||||
if (err.status === 403 || err.status === 404) {
|
||||
current.projects = []; current.threads = []; current.refused = true;
|
||||
current.projects = []; current.threads = []; current.thread = null; current.shared = false; current.refused = true;
|
||||
}
|
||||
paint(navigated);
|
||||
}
|
||||
@@ -258,6 +293,7 @@
|
||||
document.addEventListener('input', event => {
|
||||
if (!current || out()) return;
|
||||
if (event.target.id === 'chat-operation') { const f = form('clear:' + current.key); if (f.pending) return; f.op = event.target.value; f.record = null; const button = document.getElementById('chat-cancel'); if (button) button.disabled = !current.canSend || f.pending || !f.op; }
|
||||
if (event.target.id === 'chat-share-target') form('share:' + current.id).to = event.target.value;
|
||||
if (event.target.id === 'chat-body') state(current.key ?? current.id).draft = event.target.value;
|
||||
const f = event.target.closest('.chat-create');
|
||||
if (f && event.target.dataset.chatField) {
|
||||
@@ -275,6 +311,8 @@
|
||||
async function write(action, key, value, path) {
|
||||
if (!current || !current.canSend || out()) return;
|
||||
const isThread = action === 'send' || action === 'open';
|
||||
if ((isThread || action === 'createSession') && !threadWrites()) return;
|
||||
if (action === 'createProject' && current.user || action === 'createThread' && !scopeWrites(value.project, value.instance)) return;
|
||||
const targetThread = current.id;
|
||||
const s = isThread ? state(key) : form(key);
|
||||
if (s.pending || isThread && selectedInactive()) return;
|
||||
@@ -314,7 +352,9 @@
|
||||
document.addEventListener('submit', event => {
|
||||
if (!current || !event.target.id.startsWith('chat-')) return;
|
||||
event.preventDefault();
|
||||
if (event.target.id === 'chat-send-form') {
|
||||
if (event.target.id === 'chat-share-form') {
|
||||
void changeShare('share', form('share:' + current.id).to ?? '');
|
||||
} else if (event.target.id === 'chat-send-form') {
|
||||
const id = current.id, session = current.session, key = current.key;
|
||||
void write('send', key, { body: state(key).draft, ...(session ? { session } : {}) }, `threads/${id}/messages`);
|
||||
} else if (event.target.id === 'chat-session-form') {
|
||||
@@ -336,6 +376,7 @@
|
||||
});
|
||||
document.addEventListener('click', event => {
|
||||
if (!current || out()) return;
|
||||
if (event.target.dataset.unshare) void changeShare('unshare', event.target.dataset.unshare);
|
||||
if (event.target.id === 'chat-resume') void write('open', current.key, current.session ? { session: current.session } : {}, `threads/${current.id}/open`);
|
||||
if (event.target.id === 'chat-cancel') void cancelClear();
|
||||
if (event.target.id === 'chat-clear') void clearContext();
|
||||
@@ -347,6 +388,23 @@
|
||||
location.hash = sessionPath(current.id, event.target.value);
|
||||
}
|
||||
});
|
||||
async function changeShare(op, to) {
|
||||
if (!current?.canSend || !threadWrites() || current.tester || out()) return;
|
||||
const id = current.id, f = form('share:' + id);
|
||||
if (f.pending || !to.trim()) return;
|
||||
f.pending = true; f.receipt = ''; paint();
|
||||
try {
|
||||
const r = await request(`threads/${id}/${op}`, { to });
|
||||
if (r.thread !== id || r.to !== to || r.op !== op) throw new ChatError('outcome-unknown');
|
||||
f.receipt = op === 'share' ? 'Shared read-only.' : 'Share ended.';
|
||||
if (op === 'share') f.to = '';
|
||||
} catch (e) {
|
||||
f.receipt = e.sent === false ? `Share refused (${inert(e.code)}).` : 'Outcome unknown. Read shares before trying again; nothing was retried.';
|
||||
} finally {
|
||||
f.pending = false;
|
||||
if (!out() && current?.id === id) { await refresh(); paint(); }
|
||||
}
|
||||
}
|
||||
const clearText = r => r.state === 'cancelled' ? `Operation ${r.op}: ${r.retired === true ? 'startup cancelled; old session remains retired' : 'replacement cancelled; old session active'}. Cancelled inputs are not replayed.` : `Operation ${r.op}: ${r.state}; successor ${r.successor}; start ${r.start ?? 'pending'}${r.refusal ? '; ' + r.refusal : ''}. No stop is forced.`;
|
||||
async function checkClear(thread, key, f) {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(f.op ?? '')) { f.receipt = 'Enter a valid operation key.'; paint(); return; }
|
||||
@@ -361,7 +419,7 @@
|
||||
}
|
||||
async function cancelClear() {
|
||||
const c = current, key = c.key, f = form('clear:' + key);
|
||||
if (!c.canSend || out() || f.pending || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(f.op ?? '')) return;
|
||||
if (!c.canSend || !threadWrites() || out() || f.pending || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(f.op ?? '')) return;
|
||||
if (!window.confirm(`Cancel clear operation ${f.op}? Before retirement this restores the old session to active. After retirement it only cancels successor startup. Cancelled messages are never replayed, and nothing is un-retired.`)) return;
|
||||
const op = f.op;
|
||||
f.pending = true; paint();
|
||||
@@ -379,7 +437,7 @@
|
||||
}
|
||||
async function clearContext() {
|
||||
const c = current, key = c.key, selected = c.sessions.find(r => r.id === c.session), f = form('clear:' + key);
|
||||
if (!c.canSend || out() || f.pending || selectedInactive() || !Number.isSafeInteger(selected?.revision)) return;
|
||||
if (!c.canSend || !threadWrites() || out() || f.pending || selectedInactive() || !Number.isSafeInteger(selected?.revision)) return;
|
||||
if (!window.confirm(`Clear context for "${selected.name}"? This ends this session after a safe stop and starts fresh context. History and the old checkout (including unpushed work) are retained. The successor uses a fresh approved checkout; old work is not copied. This can wait for safe stop or capacity.`)) return;
|
||||
// A lost answer retains this key. Only status reads are allowed afterward.
|
||||
if (f.op && !['cancelled', 'failed'].includes(f.record?.state)) { await checkClear(c.id, key, f); return; }
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
return path.startsWith('/chat') ? path : '/chat';
|
||||
};
|
||||
function show(navigated) {
|
||||
if (ready && window.MosaicChat) void window.MosaicChat.render({ path: route(), container: $('chat'), canSend, navigated });
|
||||
if (ready && window.MosaicChat) void window.MosaicChat.render({ path: route(), container: $('chat'), canSend, user: true, navigated });
|
||||
}
|
||||
document.addEventListener('mosaic-signed-out', () => { $('signed-out').hidden = false; $('sign-out').hidden = true; });
|
||||
window.addEventListener('hashchange', () => show(true));
|
||||
setInterval(() => { if (!document.hidden) show(false); }, 5000);
|
||||
fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(async res => {
|
||||
if (res.type === 'opaqueredirect' || res.status === 401) { document.dispatchEvent(new Event('mosaic-signed-out')); return; }
|
||||
const config = await res.json();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user