Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fc0ab301e | ||
|
|
734f4d2c5b | ||
|
|
5ca83173e9 | ||
|
|
29d233793e | ||
|
|
397ef58beb | ||
|
|
460355ccc8 | ||
|
|
906267c750 | ||
|
|
53a4541859 | ||
|
|
b0c79751cf | ||
|
|
ab64174753 | ||
|
|
4818b7ba47 | ||
|
|
6af63e1d34 | ||
|
|
1f7371b7c5 | ||
|
|
ad5b8a7fa2 | ||
|
|
4d8123b0a5 | ||
|
|
14fe7513ce | ||
|
|
3c57ebb220 | ||
|
|
41b5aae3cc | ||
|
|
f913d0e76a | ||
|
|
f2fd6145ad | ||
|
|
c9c8badc3e | ||
|
|
77c12b5545 | ||
|
|
aad3000fe2 | ||
|
|
4288b7b162 |
No files matched your search
+4
-2
@@ -5412,6 +5412,8 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r
|
||||
|
||||
2026-10-23T17:32:37Z | Gyro sage-3544 fenton-0016 N1/N2 delta | Before: W7 Gate/final real-conversion list omit the W8 build-order cross-reference and W8 human-view filtering cites agent settings. After: both locations explicitly put W8 landing before human opening as mos-1470 build order, not acceptance; comment/tracked-deletion links are not current story links and never appear in the human Connections view regardless of agent settings. New commit atop approved 5af060fb5, no amend. Static diff and append-only checks pass. Docs only, no repo Node/tests/live action. Fenton checks only this delta.
|
||||
|
||||
## 2026-10-23 — Row 288 C2-02 build starts (Bubba, #1793)
|
||||
2026-10-23 | Violet row297 #1803, Q245/mos-1498 | Before: no GitHub grant method or Q-B3 answer; row198 unverified. Recovery: read brief, AGENTS, roster, row198 BUILD, #436b r5 1be535aca and Gitea implementation. Merged 14fe7513c then 4d8123b0a without rewriting upstream commits, both Untested. Q-B3 proposal in agents/violet/work/queue-297/DESIGN.md agrees S1/S2/S3/S5/S6 and proposes per-method S4 with bounded token expiry; repository identity and renewal consumer scope go to Sage before implementation. No node/tests/build/live credential operation. Gate and landing remain Sage's, after row198.
|
||||
|
||||
Before: agents can read the library; supporting-path and push-credential dependencies are separate unlanded heads. After intended: per-seat supporting writes/deletes with exact notices, human revert and one commit per Console save. Work is on bubba/row288, chained on original 283/285/286 commits; 284 tool base still pending. Plan and acceptance evidence: agents/bubba/work/queue-288/BUILD.md. Untested; no Node/test/build/gate run before Sage CLEAR. Landing waits for every dependency's own gate.
|
||||
2026-10-23 | Violet row297 #1803, implementation candidate (UNTESTED) | After Q-B3 reads (dewey-0931, megavolt-0206) and scope sage-3620: protected GitHub App admin/HTTPS/JWT boundary, event-backed durable grants, guarded renewal, token generations, expiry/revoke/recovery, broker/runtime/CLI wiring, additive role/project binding parsing and documentation. All new tests use fake HTTPS/scratch CA/RSA keys; no node or tests run under Sage hold. Old-test delta only packages/business/tests/role.test.mjs:209 1/1 regex, Mos mos-1518; new closed-service-list test preserves its former claim. Consumer/publication gap is explicit in BUILD and README. Static git diff --check only; suites, mutants, manifest approval and gate remain outstanding. Row198 ab6417475 announced as next merge base, still untested.
|
||||
|
||||
2026-10-23 | Violet row297 #1803 static review dewey-0944/0946 follow-up (UNTESTED) | Before: no per-path digest packet and lifecycle-delete test stopped after first revoke job. After: second-request completion asserted; expiry sweep removes expired generation files without removing durable grants; installation-token fallback removed; manual run validates before lookup; official API-version source and admin-remove/event-scan limits documented. Three added mutant killers cover DELETE 401, uncertain 0/5xx mint and shutdown signal veto. Digest file will pin the 24 reviewed git blobs and its own SHA will be reported separately. No Node/tests/mutants run; static diff only, Sage CLEAR still required. Landing must retain the seat's BUILD-LOG and SESSIONS appends alongside refactor's.
|
||||
@@ -1,8 +0,0 @@
|
||||
# Beakley
|
||||
|
||||
Builder seat, Codex gpt-6.1-sol, started by Mos under Q245 (mos-1506). Owns row 217 (C2-06, handed over from Launchpad).
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread (thread 88b97f65) and works in its own worktree. It has no
|
||||
credentials of its own; it pushes only its own `beakley/*` branches through the repository credential helper (mos-0981),
|
||||
never `refactor`, `main` or another seat's branch. Claude seats orchestrate and review (Q245). Registered by Sage on
|
||||
2026-10-23 so the queue can name the seat as row owner (store.mjs isSeatDir).
|
||||
@@ -1,8 +0,0 @@
|
||||
# Boyd
|
||||
|
||||
Builder seat, Codex gpt-6.1-sol, started by Mos under Q245 (mos-1506). Owns row 298 (the chief-of-staff grant scope, handed over from Megavolt).
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread (thread 48a3ae38) and works in its own worktree. It has no
|
||||
credentials of its own; it pushes only its own `boyd/*` branches through the repository credential helper (mos-0981),
|
||||
never `refactor`, `main` or another seat's branch. Claude seats orchestrate and review (Q245). Registered by Sage on
|
||||
2026-10-23 so the queue can name the seat as row owner (store.mjs isSeatDir).
|
||||
@@ -1,8 +0,0 @@
|
||||
# Bubba
|
||||
|
||||
Builder seat, Codex gpt-6.1-sol, started by Mos under Q245 (mos-1506). Owns row 288 (agent write notices and library revert, handed over from Honker).
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread (thread 3d6672b5) and works in its own worktree. It has no
|
||||
credentials of its own; it pushes only its own `bubba/*` branches through the repository credential helper (mos-0981),
|
||||
never `refactor`, `main` or another seat's branch. Claude seats orchestrate and review (Q245). Registered by Sage on
|
||||
2026-10-23 so the queue can name the seat as row owner (store.mjs isSeatDir).
|
||||
@@ -1,386 +0,0 @@
|
||||
# Row 288 (#1793): C2-02 agent writes, notices, revert and Console saves
|
||||
|
||||
Owner: Bubba, Codex gpt-6.1-sol, thread 3d6672b5-5544-4b95-9e09-e79574f1aa14.
|
||||
Reviewer: Webby. Gate and landing: Sage. Append-only build record.
|
||||
|
||||
## 2026-10-23 start and implementation plan
|
||||
|
||||
Base 45dca69f6 on bubba/row288. Direct mandate mos-1499 (Q245), confirmed sage-3613.
|
||||
The canonical `scripts/mosaic queue next bubba` returned `nothing`; Sage owns the owner transition.
|
||||
The initial worktree invocation refused linked-worktree use; retried in canonical checkout.
|
||||
|
||||
Spec: docs/plans/2026-10-20_case2-rows.md, C2-02; agents/gosalyn/work/case2/DESIGN.md ruling after r11.
|
||||
Handoffs: Honker queue-283 BUILD.md Q4 (missing/non-100644 delete refuses); Morgana
|
||||
queue-225 BUILD.md A2 at 66f7becf2 (strict revert arguments/result), confirmed by Fethry fethry-0005.
|
||||
`project` is the business.projects key, never a chat UUID.
|
||||
|
||||
Dependencies merged with their commits intact, no amend or force-push:
|
||||
- 283 origin/honker/row283-build 88ec5e8e4 (new E4 head to follow).
|
||||
- 285 origin/scrooge/row285-r2 53db6b7ca.
|
||||
- 286 origin/ludwig/row286-r2 573fa0be0.
|
||||
- 284: no named remote branch yet. Tool changes come last.
|
||||
All merge commits are marked Untested. Nothing has run under Node. Sage's latest instruction
|
||||
sage-3613 reserves **all Node and test runs** for CLEAR; this supersedes the initial targeted-test
|
||||
permission. Build untested now as instructed, then run RED/GREEN checks/mutants on CLEAR.
|
||||
|
||||
Architecture: one host-only plumbing writer under the existing library lock; broker authorizes and
|
||||
selects credentials, then inserts the fixed notice directly. Human save/revert use editor +
|
||||
library-human independently of seat lifecycle. No worktree, deferred push, role expansion or UI.
|
||||
|
||||
### Tasks and acceptance
|
||||
|
||||
- [ ] Plumbing: new library/src/write.mjs, mirror state publication and package exports; new
|
||||
library/tests/write.test.mjs. One-parent write/delete, byte-exact content, seat identity/trailer,
|
||||
stale-base, nonregular/missing deletes, retry once on an unrelated remote change, push failure
|
||||
without local ref movement, human editor and revert reachability/single-path checks.
|
||||
- [ ] Broker + runtime: library.doc.write/delete, library.file.save, library.revert; strict keys,
|
||||
creative + library.write + live unheld project grant; direct exact notice; human-only save/revert.
|
||||
New bus/tests/library-write.test.mjs covers authorization, unchanged refs, A2 contract and socket.
|
||||
- [ ] Transport: only human library.file.save joins the existing large-save exception. New tests
|
||||
prove >64 KiB save accepted, agent writes retain 64 KiB bound, malformed/oversize saves refuse.
|
||||
- [ ] Tools after 284: library_doc_write/delete in harness tools; exact eight/ten-name snapshots
|
||||
in a new harness test; cli dispatch wiring.
|
||||
- [ ] Verify on CLEAR: new tests, library/bus/harness suites and all scripts/test-*.sh on Sage's
|
||||
assigned slot. Mutants remove supporting guard and force push; both must fail. Fixture-token
|
||||
search over captured output. No gate, landing or queue move claimed before Sage's acceptance.
|
||||
|
||||
Pre-flight interfaces: broker consumes library writer {project,path,commit}; revert adds reverted;
|
||||
writer consumes C2-03b push {pushed} or {raced,tip} and supportingPath(mapping,universe,path,tree).
|
||||
State publication must add optional lastPush while preserving old state readability. Refusals must
|
||||
restore fetched remote-tracking refs as well as preserve served. Runtime must bind credential file
|
||||
references itself; no file, identity, message or credential is supplied by the agent.
|
||||
|
||||
Necessary file-scope additions: packages/bus/src/runtime.mjs (actual adapter injection),
|
||||
packages/library/src/index.mjs (exports), and CLI's existing console adapter if saves require it.
|
||||
Reported to Sage in bubba-0002. Existing tests will not be edited without Mos's OK via Sage.
|
||||
|
||||
Review focus: remote tip races, nonregular entries and folded paths, unsafe human credential before
|
||||
git, revert after revoked/fired writer, and large-request exception never extending agent writes.
|
||||
|
||||
### Existing-test changes
|
||||
|
||||
None authored by Bubba yet. Dependency test changes remain their owners' original commits.
|
||||
|
||||
## 2026-10-23 first static build (not a gate result)
|
||||
|
||||
283 E4 merged intact at 76d651dba. Honker next reports e67c27b32 (test environment only), to merge
|
||||
before push. Della reports row 284 has not been built/pushed; row224's 23816cf28 is not a 284 head.
|
||||
No speculative 284 pin. Typed tools and served-blob lookup integration remain pending 284.
|
||||
|
||||
Implemented (unrun): bare plumbing writer, one-parent commit and fixed trailer, exact-content blobs,
|
||||
base comparison, supporting path guard and delete regular-blob check, one remote-race retry,
|
||||
ref restoration on refusal, immediate served publication/lastPush; human save and reachable one-path
|
||||
business-trailer revert; broker grants/identity/notice path; runtime adapter wiring; large-save socket
|
||||
exception. New tests: library/tests/write.test.mjs and bus/tests/library-write.test.mjs. No old test edits.
|
||||
|
||||
### Approved scope additions, sage-3628
|
||||
|
||||
Every hunk outside the brief's owned list:
|
||||
- `packages/bus/src/runtime.mjs`: `humanWriteOptions` checks editor before choosing the human slot;
|
||||
three library adapter entries bind dataRoot, command and the host-only seat store reference. These
|
||||
are needed to make actual bus calls reach the new writer without accepting agent-supplied files.
|
||||
- `packages/library/src/index.mjs`: export writeLibraryDoc/saveLibraryFile/revertLibraryCommit so
|
||||
runtime's existing library module adapter can call them.
|
||||
- `packages/library/src/sync.mjs`: shared write guard with opaque token compared by identity;
|
||||
push's race fetch recognizes only that owner and does not clear its guard; ordinary sync and a
|
||||
forged token still refuse library-sync-busy. Narrow publishLibraryWrite validates ids, reads the
|
||||
locked state, rebuilds the index via readLibrary, moves served/tracking and writes lastPush. The
|
||||
raw writeState remains private and is not exported through index.mjs.
|
||||
|
||||
Ludwig ludwig-0064 reviewed the intended interface and requested the opaque token (implemented),
|
||||
narrow publication (implemented), whole library + bus library-credential checks and a token mutant
|
||||
(planned). His 573fa0be0 remains current and unrun. 288's additions are separate commits, not folded
|
||||
into 286. Tracking restores only if the whole write refuses, not between the first race and retry.
|
||||
|
||||
Known tradeoff, accepted by Sage sage-3628 for MVP: the brief holds the flock through network Git;
|
||||
readers can hit the existing 5-second library-sync-busy while it runs. Structural supportingPath
|
||||
preflight is pure and precedes Git; served/fetched listTree checks repeat it with the real tree before
|
||||
any remote write. A tree-dependent check necessarily reads the tree first (Honker honker-0184 item2).
|
||||
|
||||
### Mutants for CLEAR
|
||||
|
||||
- Remove the `pathOf` calls for agent operations: the M12 broker/bare-ref tests must fail on a
|
||||
story/unsupported path while checking remote, tracking and served refs.
|
||||
- Prefix the push refspec in syncCommand with `+`: write.test moved-target race must fail, because
|
||||
its command seam preserves production syncCommand's refspec (only remote URL/protocol are fixture).
|
||||
- Replace `writers.get(mirror) === writeToken` with `true`: write.test forged-token case must fail.
|
||||
|
||||
No Node, test, build or mutant has run. `git diff --check` is clean. Counts are not claimed.
|
||||
|
||||
### Console dispatch integration
|
||||
|
||||
The existing cli delegates its transport wiring to console-bus.mjs, so the save dispatch needs that
|
||||
file too: one null `librarySave` option and one `--send` assignment to library.file.save. cli.mjs
|
||||
threads that option into startServer; row C2-11b owns the actual route/page that consumes it. New
|
||||
webui/tests/library-write-options.test.mjs covers --send and the user transport boundary. 225 owns
|
||||
libraryRevert dispatch; 288 adds only librarySave to avoid overlapping that ownership. Additional
|
||||
scope reported to Sage and Fethry. No changes to an existing test.
|
||||
|
||||
## 2026-10-23 second static pass and dependency review
|
||||
|
||||
First push: c867cada9 on origin/bubba/row288, reported bubba-0005 to Sage/Webby/Mos and
|
||||
bubba-0006 to Fethry/Ludwig. No PR created and no review/gate requested as complete.
|
||||
|
||||
Ludwig ludwig-0065 checked the exact sync diff against 286 without running anything. Token identity,
|
||||
ordinary-sync exclusion, 286 R1 preservation, guard key and end-of-write tracking restoration fit.
|
||||
F1: state publication could fail after served moved, leaving old state/new served. Root cause is
|
||||
publication order without rollback, compared against sync's existing rollback. Added an unrun
|
||||
filesystem rename-failure regression, then changed the narrow helper: on failure it validates the
|
||||
state under the same lock, CAS-restoring served if state has not published the new commit. If rename
|
||||
succeeded but directory fsync failed and the state validates against new served, it keeps that pair.
|
||||
All failure cases answer outcome-unknown; tracking remains the commit already pushed remotely.
|
||||
Verification is pending CLEAR, so this is a proposed fix, not a tested resolution.
|
||||
|
||||
Added the actual broker-to-bare tests for each M12 path refusal (both agent verbs), unchanged remote
|
||||
and all mirror refs, exact write/delete notices, and human revert after fixture grant removal/hold.
|
||||
The bare fixture moved to new library/tests/write-fixture.mjs so broker tests reuse real plumbing.
|
||||
Added the raw socket proof-nested-save-name case to pin the parsed-verb large-request check.
|
||||
Missing editor/slot tests now make the mirror metadata invalid first: reaching Git would change the
|
||||
refusal, so the tests check the pre-Git gate rather than merely count remote commands.
|
||||
|
||||
Additional required doc hunk: packages/library/README.md appends the four verb contracts,
|
||||
lastPush shape, exact notice, lock tradeoff and publication failure behavior. No existing test from
|
||||
the dependency/base has changed. All new/modified tests in this pass were authored by this row.
|
||||
`git diff --check` remains clean. No Node/tests/build/mutants run; 284 and CLEAR remain pending.
|
||||
|
||||
## 2026-10-23 early review follow-up (Webby E1-E6; Ludwig F2)
|
||||
|
||||
Sage sage-3652 approves console-bus.mjs wiring (null option + --send dispatch), confirms editor.user
|
||||
as the sole human login source and reserves CLEAR for T3/T4 after T2. Ordinary sync()'s separate
|
||||
post-rename fsync gap stays out of 288 per that ruling; no change to sync()'s existing rollback.
|
||||
|
||||
Honker 283 head 46bad7ea3 merged intact: mapping editor.user is optional, and if present uses the
|
||||
existing plain Gitea-user pattern. The omission at mapping level is valid; human writes refuse it.
|
||||
|
||||
- Ludwig F2 / Webby E6: confirmed the previous runtime used the fetch bot's mapping.user wrongly.
|
||||
Runtime now passes mapping.editor.user to humanCredential, never mapping.user. Missing user gives
|
||||
library-human-missing before Git. New real-runtime cases pin both save and revert with a separate
|
||||
fetch-bot login, positive pushes under her-gitea-login, and no command for an absent editor login.
|
||||
The broker's credential.agent is s.role, not args: added an exact assertion and strict-key refusal
|
||||
cases for caller-supplied agent and credential.
|
||||
- E1: replaced the business-id RegExp interpolation with a literal startsWith(business.id + '/')
|
||||
check and ID_PATTERN on only the instance suffix. New acme.co vs acme-co trailer test.
|
||||
- E2: new broker→bare case holds the seat in the fetch command. beforePush must refuse
|
||||
library-write-no-grant with all local refs and the remote unchanged and no notice. New mutant:
|
||||
remove `options.beforePush?.();` in write.mjs; the E2 test must fail.
|
||||
- E3: new broker cases for expired rotate_by, fire.stopped without fire.done, missing role.publish
|
||||
project, grant/identity Gitea-user mismatch, and missing identity name/email, for both agent verbs.
|
||||
- E4: the exceptional publication failure currently returns outcome-unknown before the broker has
|
||||
a successful result to put in a notice, so a remote agent commit can exist without a Console
|
||||
notice. There is no durable notice outbox/retry in this row. The trailer commit remains human
|
||||
revertible by id after sync; this is an explicit known failure-mode gap for the manifest, not
|
||||
a claim that every failed publication emits a notice. A new real-push test breaks state decoding
|
||||
after push admission, pins outcome-unknown, old served and no notice. It also found a local error
|
||||
propagation gap: errors before publishLibraryWrite's internal try now normalize to outcome-unknown
|
||||
in the writer once push has succeeded, so the caller is never told an ordinary refused-write code.
|
||||
- E5: 284 still has no build. Tools/8-or-10 snapshot and served-blob integration remain pending.
|
||||
librarySave is intentionally only an adapter option until C2-11b supplies its route/page.
|
||||
|
||||
All tests above are newly authored by row288 and remain unrun; no changes to baseline/dependency
|
||||
tests. RED/GREEN and mutants await CLEAR. Proposed fixes are not claimed verified. The new host-only
|
||||
library write module imports the existing pure ID_PATTERN to match the broker's accepted instance ids.
|
||||
|
||||
## 2026-10-23 mos-1520 ruling: post-push notice is required
|
||||
|
||||
Correction to the E4 interpretation above: Mos rejected the known-gap treatment. The requirement
|
||||
stays: a pushed agent commit has the exact notice even if local publication then fails. This is not
|
||||
an outbox or retry. The writer now calls the broker's trusted synchronous onPushed callback as soon
|
||||
as push returns pushed:true, before publication. The callback records the exact library-write notice
|
||||
and action.allowed in the normal Console-store transaction. Publication failure still answers
|
||||
outcome-unknown. The notice's Revert can refuse library-revert-not-agent until sync makes that commit
|
||||
reachable from served; README now says that explicitly and removes the rejected gap description.
|
||||
|
||||
The row's own real-push corruption test now pins the exact notice body with the pushed id, old
|
||||
served and outcome-unknown; after restoring readable state it pins Revert's refusal before sync and
|
||||
success after sync. The successful-write adapter fixture calls onPushed too, matching runtime's
|
||||
writer contract. No existing baseline/dependency test changed (mos-1520 explicitly authorizes this
|
||||
row's test correction without mos-1272).
|
||||
|
||||
Residual Console-store failure: if notice insertion/routing/its event transaction fails, the broker
|
||||
calls a separate host log sink with {event:library-notice-store-failed,code:outcome-unknown,business,
|
||||
instance,op,commit}. It logs no args, path content, credential, raw SQL or exception output. Runtime's
|
||||
new library.log adapter writes this metadata to stderr. The caller gets outcome-unknown even if
|
||||
recording action.refused in that same broken store fails. A new real-push test uses temporary SQLite
|
||||
abort triggers on notice insertion and refusal evidence, checks remote advanced/served unchanged,
|
||||
no message, the exact external log and outcome-unknown. It does not claim a notice can be stored in
|
||||
a failed Console store. If the host log sink itself also fails, no durable log can be promised; the
|
||||
caller still receives outcome-unknown. The runtime log hunk is the required residual-failure wiring.
|
||||
|
||||
Mutant added: move `options.onPushed?.({ project: context.project, path: change.path, commit });`
|
||||
from before `await publishLibraryWrite(...)` to immediately after that await inside the same try.
|
||||
Successful operations keep their notice, but the outcome-unknown publication path drops it. The
|
||||
C2-02 E4 real-push corruption test must fail on its exact notice assertion. Run on CLEAR and restore.
|
||||
|
||||
No Node/tests/build/mutants run. Static git diff --check clean. Della's first284 head0ed4fbab9 is
|
||||
explicitly incomplete and not a finished dependency; waiting for her complete-head pin before tools.
|
||||
|
||||
Webby webby-0253 (static, cef503cb9) closes E4 per mos-1520; no tests ran. N1: a failed notice insert throws from onPushed before publication, so served stays at the prior commit until sync; the store-failure test pins this safe direction. Only E5 (284 tools/snapshot integration) remains open in this early review.
|
||||
|
||||
## 2026-10-23 complete284 merge and final tool integration (unrun)
|
||||
|
||||
Della della-0037 supplied complete untested head47db04eba85ac3ceb7e81385603f97bb6f8c1a38,
|
||||
replacing the incomplete head. Merged intact as c0ca3894c. Its22423816cf28 and2164f3fbc25a
|
||||
lineage is retained. Three conflicts were in overlapping production wiring only:
|
||||
- broker.mjs: keep Della's LIBRARY_LOOKUPS object and #requestLibraryLookup byte-for-byte, and
|
||||
keep288 writer/notice methods; LIBRARY_VERBS contains both sets and request dispatch calls lookup.
|
||||
- runtime.mjs: keep lookup adapter plus288's metadata log and write adapters.
|
||||
- library/index.mjs: keep index/query exports,286's push/humanCredential exports and288's writer exports.
|
||||
No dependency test body was changed in the merge. These are overlapping consumer integrations,
|
||||
not rewrites of dependency commits. Every prerequisite still needs its own gate (mos-1351).
|
||||
|
||||
Added library_doc_write/library_doc_delete after the five lookups, only for a creative definition
|
||||
with a within-role library.write grant and library enabled. Closed schemas carry exactly
|
||||
{universe,path,base,content} and {universe,path,base}; base is string-or-null with 40-lowercase-hex
|
||||
pattern. No project, business, author, credential, commit or message. tools.action is library.write.
|
||||
New harness/tests/library-write-tools.test.mjs pins per-role eight/ten names, both schema invariants,
|
||||
no repo/commit/business/accept, exact required/verb/action, nullable base, and missing library/grant
|
||||
or noncreative exclusion. This satisfies sage-3678 and mos-1523's replacement coverage condition.
|
||||
|
||||
Publication's narrow helper now calls indexFor for the new served commit instead of only readLibrary,
|
||||
so the284 lookup index is rebuilt before publication. New broker integration uses library.get's
|
||||
served blob as write/delete base, independently checks served:path, reads the new text/blob immediately,
|
||||
preserves default-canon null, then sees null after delete. No change to284's query behavior.
|
||||
|
||||
### Authorized baseline-test migration, own commit (mos-1523 via sage-3677)
|
||||
|
||||
File: packages/harness/tests/library-tools.test.mjs. Numstat: +4/-4, exactly.
|
||||
The noncreative LOOKUPS assertion at :33 is unchanged. Every existing lookup schema assertion stays.
|
||||
The four old/new lines are:
|
||||
|
||||
```diff
|
||||
- ? ['web_search', 'web_fetch'] : []), ...LOOKUPS];
|
||||
+ ? ['web_search', 'web_fetch'] : []), ...LOOKUPS, 'library_doc_write', 'library_doc_delete'];
|
||||
-test('M3: per-role snapshots contain six names, or eight with the C2-08 web grant', t => {
|
||||
+test('M3: per-role snapshots contain eight names, or ten with the C2-08 web grant', t => {
|
||||
- for (const [i, tool] of tools.filter(t => t.name.startsWith('library_')).entries()) {
|
||||
+ for (const [i, tool] of tools.filter(t => LOOKUPS.includes(t.name)).entries()) {
|
||||
- assert.deepEqual(tools.map(t => t.name), ['send_message', ...LOOKUPS]);
|
||||
+ assert.deepEqual(tools.map(t => t.name), ['send_message', ...LOOKUPS, 'library_doc_write', 'library_doc_delete']);
|
||||
```
|
||||
|
||||
Additional schema mutant (mos-1523): add `commit: str("caller commit")` to writeBase in tools.mjs,
|
||||
thereby adding it to either/both write schemas. The new-file schema test must fail both its exact
|
||||
property set and forbidden-property assertions. Run on CLEAR and restore. No further assertion
|
||||
change in library-tools.test.mjs is authorized or made.
|
||||
|
||||
All code/tests/mutants remain unrun. No Node, build or syntax check before Sage's named CLEAR.
|
||||
Static diff --check is clean. Scope additions stay listed above, now including indexFor import and
|
||||
call in the previously approved narrow publication helper. E5 implementation is present; review and
|
||||
acceptance are pending evidence, not claimed complete.
|
||||
|
||||
## 2026-10-23 testing and landing boundary (sage-3682)
|
||||
|
||||
Latest complete dependency2845d587a552 (test/docs only over47db04eba) merged intact as f8f79ea2f.
|
||||
Own tool implementation cd2f50dae and separate mos-1523 migration29455c0c5 are pushed; Webby reads
|
||||
E5 and Dewey checks the migration. Static manifest-r1 covered21 own paths at f8f79ea2f.
|
||||
|
||||
Sage reserves first focused runs/RED-GREEN/mutants for T7, provisionally about22:25Z, unless he
|
||||
explicitly frees an earlier lane. No Node before his CLEAR. This is a schedule, not CLEAR. Full
|
||||
library/bus/harness and shell gates still need their own authorized slot; no gate result is claimed.
|
||||
|
||||
Before the gate, incorporate landed refactor after283/224/284 land while preserving every
|
||||
prerequisite. Under the standing add-commits/no-force-push rule, update this published branch by merge
|
||||
rather than rewriting its pushed history. 288 lands last in the chain; no landing is authorized here.
|
||||
|
||||
## 2026-10-23 E5 static closure and N2 wording
|
||||
|
||||
Dewey dewey-0948: the mos-1523 migration at29455c0c5 is static CLEAN, exactly+4/-4, with the
|
||||
noncreative assertion unchanged and new-file schema coverage meeting every condition. Webby
|
||||
webby-0263: E5 closed on29455c0c5/cd2f50dae (f8f79ea2f;98ca25ab1 only BUILD.md), nothing blocking.
|
||||
Both ran nothing; Webby did not read the scratch manifest, so no manifest verification is implied.
|
||||
|
||||
Webby N2: delete inherited the write base description "or null to create". Delete now overrides only
|
||||
that description: "The existing file's blob from library_get; a missing file cannot be deleted".
|
||||
The reviewed nullable-base schema/required keys/verbs remain unchanged. The row's new schema test
|
||||
pins create wording only on write and existing-file wording without create on delete. No further
|
||||
assertion change in the baseline library-tools.test.mjs. No Node/tests run; still await Sage CLEAR.
|
||||
|
||||
## 2026-10-23 dependency283 follow-up (honker-0195)
|
||||
|
||||
Merged origin/honker/row283-build b2a1f4092 intact, fast-forward lineage from46bad7ea3. It changes
|
||||
supporting.test's helper and Honker's BUILD receipts only; no production source changed. Honker
|
||||
reports library98/98, business231/231 and14/14 mutants killed on that dependency head. Those are
|
||||
his dependency receipts, not verification of288's combined branch. No288 Node/tests run; Sage's
|
||||
CLEAR is still required. The dependency's own gate/landing remains Sage's responsibility.
|
||||
|
||||
## 2026-10-23 new candidate after283 landing (sage-3727, honker-0200)
|
||||
|
||||
Created bubba/row288-r2 from the preserved published bubba/row2887c53adf10, then rebased with
|
||||
--rebase-merges --no-update-refs onto93694c5185d0a7f989d2fe78fb620e44599b744e. This supersedes
|
||||
the earlier merge-only refresh plan for this candidate: Sage explicitly requested a fresh branch,
|
||||
not a force-push. Both local and remote bubba/row288 remain7c53adf10, unchanged.
|
||||
|
||||
Git skipped12 duplicate283 commits already replayed upstream. Range-diff of
|
||||
45dca69f6..bubba/row288 against93694c518..bubba/row288-r2 (before this receipt) has35 equal
|
||||
commits,12 dropped,0 modified,0 added. Full output: scratch/bubba-row288/rebase-r2-range-diff.txt
|
||||
under /mnt/storage. The other prerequisite commits retain their patches; row216/224 identities are
|
||||
unchanged where Git could retain them. This is not new verification of any prerequisite.
|
||||
|
||||
Replayed merges had the same launcher-footer and broker/runtime/index conflicts as before. Reused
|
||||
the exact previously reviewed combined file bytes, keeping both launcher test bodies and all lookup
|
||||
and writer paths. Static comparison before this receipt: all21 own manifest paths byte-identical
|
||||
to7c53adf10; all16 other changed paths exactly match93694c518 upstream. These include newly landed
|
||||
226 and coordination/docs changes already on that base, not new288 edits. No baseline assertion or
|
||||
production change was introduced by conflict resolution.
|
||||
|
||||
Sage's base93694c518 is an ancestor of the candidate; old283 b2a1f4092 is not. Static diff --check
|
||||
clean. No Node, test, syntax, build, mutant or gate run. Named CLEAR/T7 still pending;288 lands last.
|
||||
|
||||
## 2026-10-24 static preflight before landed284 base (sage-4282)
|
||||
|
||||
No Node, npm, test, syntax, mutant, queue command or gate ran. Still on bubba/row288-r2,
|
||||
b44152ac6 plus this static follow-up; await Sage's landed284 base before a fresh candidate branch.
|
||||
Do not treat the earlier T7 estimate as CLEAR. Webby remains reviewer.
|
||||
|
||||
### Updated C2-02 brief and contract names (webby-0282 N1)
|
||||
|
||||
Read C2-02 on origin/refactor 95d4991d20a4ef291c824d37c886eaeaa9be7062; current brief blob 85e6fe2ecfc53ed615b7b61aade88ae0cbb222ef
|
||||
(the new ruling is identified by Sage as5e8a720b). Statically checked both the candidate and current
|
||||
refactor versions of all six creative contracts: roles/companion.md, research.md, ideas.md,
|
||||
proofreader.md, fact-checker.md and continuity.md. Every one names library_doc_write and
|
||||
library_doc_delete, exactly the two add() names in candidate harness/src/tools.mjs. No role contract
|
||||
change is needed or made. Exact-name evidence is tool-contract-audit.json in the scratch dir below.
|
||||
|
||||
### mos-1584 fixture audit
|
||||
|
||||
The new row288 harness tests reach businessDoc through resolvedFor, but their callbacks add only a
|
||||
creative role; they add no library business project. library:true is the typedTools option, not a
|
||||
businessDoc project. The broker/runtime/bare-library tests build minimal business objects directly,
|
||||
not businessDoc. The migrated baseline harness test likewise adds no library business project.
|
||||
There is therefore no new row288 businessDoc-with-library fixture requiring a chief-reference edit.
|
||||
The current and future landed-base imported fixtures must be rechecked after the pending rebase;
|
||||
any validating businessDoc fixture that adds a library project must retain/provide chief gitea.
|
||||
|
||||
Shortened only two row288-authored fixture credential values: write-fixture.mjs uses fx288-write,
|
||||
and the runtime test's two slots use fx288-human. Both are11 characters, below24. This corrects the
|
||||
25-character runtime value and avoids an edge at the older23-character write value. No assertion
|
||||
changed, no baseline test edit and no credential file was read. No live credential action occurred.
|
||||
|
||||
### mos-1612 whole-file run-list preparation (not run authorization)
|
||||
|
||||
Static lexer traversed real import/re-export/require and new-URL module-entry edges, skipping
|
||||
comments/quoted fixture code; reverse closures carry a chain witness for every selected test.
|
||||
Used the union of this older candidate and the current refactor source snapshots so newer importers
|
||||
are visible without prematurely rebasing. It selected270 whole test files. Required whole library,
|
||||
bus and harness suites add17 distinct files, making a proposed union of287 (not a narrow grep list):
|
||||
bus115, business2, cli58, harness16, library13, plugins1, processing-control1, seat5, tasks9, webui67.
|
||||
The brief also names all12 scripts/test-*.sh runners. No file/runner was executed.
|
||||
|
||||
packages/cli/tests/case1-accept.test.mjs is explicitly excluded under mos-1592/1612. The11
|
||||
nonliteral-import files have a manual expression audit; all already occur in the import-selected
|
||||
whole-file set. Generated programs and runtime plugin discovery are recorded static limitations,
|
||||
not asserted fully resolved by the lexer. Whole suites and the named script runners retain their
|
||||
brief-required coverage. Regenerate after landed284 rebase; report exact final sha/list before CLEAR.
|
||||
Each approved gate later needs own root/webui npm ci, required editor build, short scratch TMPDIR,
|
||||
whole files and gate skip receipts under the then-current rules. These are plans, not past evidence.
|
||||
|
||||
Artifacts: /mnt/storage/scratch/bubba-row288/static-2026-10-24/
|
||||
- import-run-list.py SHA256 ec18031c87bf6f96b3e794e2e56747bc5e1fe37580de7ce9444c60cd5f18ddc0
|
||||
- whole-files.txt SHA256 b2d6c11349268c347e51a64fe6bf9e7c4d560238a8fc5b5e10f600f1e6e8a39e
|
||||
- import-witnesses.json SHA256 01702657d34c4f2c0635f38741d0a5b57cfdda63d5e96897f188d03971afd5d9
|
||||
- dynamic-import-audit.txt SHA256 42b8721fde783c618e1347f2609ad6e4ae3144f563272f0bbbeb45c34f751018
|
||||
- brief-required-whole-files.txt SHA256 254828414cd9b1b0a785df5c23b7f2b9ea7564d1f85e648899801e46194d135f
|
||||
- proposed-whole-files.txt SHA256 bee666dce2c3f2d0bafe7e963fb443ad356b6ae8719ac3cb8ee413a329578035
|
||||
- brief-script-runners.txt SHA256 02cd25af486c163189977106e055575145bffba688c7836e2a508f5ec91e76d4
|
||||
- tool-contract-audit.json SHA256 bf07865d796b8b0aed85d84c015430ea03468c5331bcaae9eb80efafe1a182be
|
||||
- fixture-audit.json SHA256 436a69a6b8548d8426390e867ed4b0f83693a3116d825c26c6f8a3e788f477d6
|
||||
@@ -0,0 +1,509 @@
|
||||
# Row 198 build: retire the board writer on 7331; the Console serves the board in-process
|
||||
|
||||
Branch `bushroot/row198-r3`, rebased onto `aca16404c` (queue revs 3195-3216 on refactor). Supersedes
|
||||
`bushroot/row198-r2` (base b79ad5f2c), which is kept and not force-pushed.
|
||||
Issue #1495 family, Gitea #1696. Ruling source: Sage sage-2821 answering Webby webby-0150
|
||||
(CHANGES B1-B5, N1-N3); earlier Q220 ruling sage-2803 (Mos mos-1301), reviewer Webby.
|
||||
|
||||
## Status: UNVERIFIED
|
||||
|
||||
No test suite, mutant, or node process has run for this commit. Sage's gate (sage-2803, sage-2821):
|
||||
"no node of any kind and no https git; no test runs until my CLEAR." This commit is local.
|
||||
It must not be pushed until the gate runs green and Sage opens a gap for the push.
|
||||
|
||||
## Ruled: `--board` is dropped for every op (Sage sage-2834 ruling a, sage-2837)
|
||||
|
||||
Design item 8's "keep the flag where the target parses it" part is withdrawn. `webui-service.sh
|
||||
install` has no `--board` branch (exit 4), and `c3b60e513` removed the flag from its parse_flags,
|
||||
so a kept flag fails at install-console. `install_stack` (stack-release.sh:565) runs that install on
|
||||
activate as well as rollback, so a re-pin to a pre-198 copy has the same failure. sage-2837 makes
|
||||
the rule per target, not per op.
|
||||
|
||||
Code: `console_for_target` drops every `--board` pair from `BASE_CONSOLE`, for any op. The reason
|
||||
follows the target's `cli.mjs`:
|
||||
- `parses_flag` true (pre-198 target): `drop --board <url>; the 7331 board is retired, so this
|
||||
Console runs without the board`.
|
||||
- `parses_flag` false (post-198 target): `drop --board <url>; the target Console does not parse it,
|
||||
so the board runs in the Console`.
|
||||
`DROP_BOARD_WHY` carries the reason to both print sites (plan and steps).
|
||||
|
||||
Tests: M12 unchanged (post-198 target, activate --plan). M13 now covers a kept `--board` for a target
|
||||
that parses it, on both `activate --plan` to a pre-198 copy and `rollback --plan`. Each asserts the
|
||||
drop line with the retired-board reason and no `--board` on the `Console:` install line, and that the
|
||||
plan leaves state unchanged. The README rollback note covers any move to a pre-198 copy.
|
||||
|
||||
## What changed in the source
|
||||
|
||||
- `scripts/stack-release.sh` (B2, design item 8): `console_for_target` drops a `--board` pair from
|
||||
`BASE_CONSOLE` unless the target's `cli.mjs` parses `--board`. `parses_flag` matches
|
||||
`flag === '--board'` and ignores lines that contain `throw` (N2: the target names `--board` only
|
||||
in a refusal). A dropped pair sets `DROP_BOARD` and `DROP_BOARD_WHY` (reason by target, see the
|
||||
Ruled section), printed in the plan and the steps. Init at the globals. `bash -n` passes; not run.
|
||||
- `packages/control-board/src/board.mjs` (B4): `createBoard` takes `isPidAlive` and passes it to
|
||||
`scanAsync`, so the stale-pid refusal can be tested with an injected liveness check.
|
||||
- `packages/control-board/src/cli.mjs`: `serve` throws RETIRED, exit 2 (`ConfigError` path).
|
||||
- `packages/webui/src/serve.mjs`: `/api/seen` and `/api/reply` answer 405 for a non-POST, 403
|
||||
`{error:'read-only', sent:false}` without `--send` on the public name, 503 with no board, and
|
||||
otherwise run the in-process board. `boardSend` answers 400 on a body that is not JSON, before
|
||||
any board call.
|
||||
- `packages/webui/src/cli.mjs`: `--board` throws `--board retired: the board runs in the Console
|
||||
(row 198)`. The board is built over `system.dataRoot` (`board/`, `seats/`).
|
||||
- `scripts/webui-service.sh`: the unit no longer carries `--board`; render and install refuse it.
|
||||
- `scripts/stack-copy.sh`: COPY_PATHS adds `packages/control-board/src` and `tools/tmux`.
|
||||
|
||||
## What changed in the tests
|
||||
|
||||
- `packages/cli/tests/stack-release.test.mjs` (B2): new block before the Row 181 section.
|
||||
`setCli` rewrites a copy's `cli.mjs` and restores it with mode 0444 in `t.after`; `withBoard`
|
||||
appends the flags to the pointer console.
|
||||
- M12: `seed`, `setCli` to a refusal-only `cli.mjs`, `withBoard`, `activate --plan`. Expects the
|
||||
drop line, no `--board` in the Console line, and no state change.
|
||||
- M13: `setCli` to a parser, real `activate` without the flag, then the unit gets `--board` and
|
||||
`rollback --plan`. Expects the rollback drop line with the retired-board reason and no `--board`
|
||||
in the `Console:` install line. Activation runs without the flag because `webui-service.sh
|
||||
install` refuses it.
|
||||
- `packages/control-board/tests/serve.test.mjs` (B4, B5): `replyBoard(f, agentSend, isPidAlive)`
|
||||
passes `isPidAlive` through. New refusal row: a stale registration (pid 4242, `isPidAlive` false)
|
||||
answers 409 with `/stale/`. B5: the non-numeric `--port` test is deleted, since `serve` no
|
||||
longer takes a port.
|
||||
- `packages/webui/tests/send.test.mjs` (B4): `setup` builds an in-process board object
|
||||
(`index`, `markSeen`, `reply`, each recording into `boardHits`); the `upstream` server is gone.
|
||||
New case: a board write with a body that is not JSON answers 400 under `--send`, and the board
|
||||
runs nothing.
|
||||
- `packages/control-board/tests/retired.test.mjs` (new, B3): M1 `cli.mjs` starts no server;
|
||||
M2 no board source imports `node:http`, calls `createServer`/`.listen(`, or exports a server;
|
||||
M9 live READMEs, AGENTS.md and `scripts/*.sh` do not tell anyone to run `control-board serve`
|
||||
or open 127.0.0.1:7331, except a line that says it is retired. Append-only logs and plans are
|
||||
not scanned. Grep check on the current tree: no hits.
|
||||
- `packages/control-board/tests/board.test.mjs` (B3): new test, M15. After a write, `index()` must
|
||||
start a fresh scan. The event log must read `write, bump, scan, scan`, and the answer must show
|
||||
the mark.
|
||||
- The reply join killer (B3, M16) lives in `packages/control-board/tests/serve.test.mjs`, not
|
||||
`board.test.mjs`. `reply()` refuses an unregistered row before any send, so the killer needs a
|
||||
registered fixture (`replyFixture`), which `serve.test.mjs` already builds.
|
||||
- `packages/webui/tests/board-inprocess.test.mjs` (new, B3): M6 and M7 (preview: each board route
|
||||
answers 401 without the proxy header and 403 with a foreign Host, and the board never runs);
|
||||
M5 (`/api/seen` without `--send` writes no `seen.json`); M10 (positive control: with `--send` on
|
||||
the public name, `/api/seen` writes the mark and `/api/reply` runs exec once); M3 (a board read
|
||||
makes no request to a URL other than the Console's own); M14 (a real board over a 1 s tmux shim:
|
||||
the loop never stalls and `/healthz` answers while `/api/board` is pending).
|
||||
- `packages/webui/tests/pinned-copy.test.mjs` (B3, M11): the existing "serve runs from a copy"
|
||||
test also asserts `/api/board` answers 200 with a `sessions` array from the copy.
|
||||
|
||||
## Dropped coverage (B4), with reasons
|
||||
|
||||
- Stale-pid reply case. Not dropped: moved. It runs against `createBoard` with the new
|
||||
`isPidAlive` passthrough (`serve.test.mjs`, the 409 `/stale/` row).
|
||||
- Bad-JSON request case. Moved to `packages/webui/tests/send.test.mjs`. The HTTP writer is gone;
|
||||
body parsing now happens in the Console's `boardSend`.
|
||||
- `page.html` tests. Dropped: the page is deleted, and the Console serves the board page.
|
||||
- Tester board-proxy tests. Dropped: there is no proxy. The tester socket is built with the
|
||||
in-process board (N1 below).
|
||||
|
||||
## Corrections to r2 (N1)
|
||||
|
||||
The r2 note said the tester socket gets `board: null`. That was wrong. Only
|
||||
`packages/webui/tests/proof.test.mjs` passes `board: null`. `cli.mjs` passes the in-process board
|
||||
to `startTesterSocket`.
|
||||
|
||||
## Mutant map (M1-M15, design rev 5)
|
||||
|
||||
Each mutant runs on a scratch copy with the new tests. Not run yet.
|
||||
|
||||
| # | Mutant | Killer |
|
||||
|---|---|---|
|
||||
| M1 | `cli.mjs serve` starts a server | `retired.test.mjs` M1; also `board.test.mjs` "cli serve refuses" |
|
||||
| M2 | serve source restores or exports `createServer`/`startServer` | `retired.test.mjs` M2 |
|
||||
| M3 | Console proxy fetch to a board URL returns | `board-inprocess.test.mjs` "makes no request to a board URL" |
|
||||
| M4 | `/api/reply` skips the `send` check | `webui serve.test.mjs` "writes refused without --send" (existing; zero captures) |
|
||||
| M5 | `/api/seen` skips the `send` check | `board-inprocess.test.mjs` "writes no mark"; `serve.test.mjs` as M4 |
|
||||
| M6 | board block runs before the preview and header gate | `board-inprocess.test.mjs` preview 401 |
|
||||
| M7 | board route accepts a foreign Host | `board-inprocess.test.mjs` preview 403 |
|
||||
| M8 | `webui-service.sh` re-accepts `--board` | `service.test.mjs` render refusal list (status 1) |
|
||||
| M9 | live doc or script tells people to run `control-board serve` | `retired.test.mjs` M9 |
|
||||
| M10 | `/api/reply` and `/api/seen` branches move below the read-only refusal | `board-inprocess.test.mjs` positive control (200, exec once) |
|
||||
| M11 | `COPY_PATHS` drops `packages/control-board/src` or `tools/tmux` | `pinned-copy.test.mjs` "serve runs from a copy": `/api/board` 200 |
|
||||
| M12 | `console_for_target` keeps `--board` for a target that does not parse it | `stack-release.test.mjs` M12 |
|
||||
| M13 | a recorded `--board` is kept for a target that parses it (activate or rollback) | `stack-release.test.mjs` M13 (activate and rollback): plan drop line, then a real run: drop line in the output (rollback: steps.log), no `--board` in the unit or pointer |
|
||||
| M14 | a board read blocks the loop: `tmuxInspectAsync` in `scan.mjs` swapped for `tmuxInspect` (spawnSync) | `board-inprocess.test.mjs` M14: real `createBoard`, tmux shim on PATH sleeps 1 s; the timer gap while `/api/board` is pending and `/healthz` elapsed time |
|
||||
| M15 | `index()` joins a scan that started before the last write | `board.test.mjs` M15 (event log) |
|
||||
| M16 | `reply()` drops the counter bump: `writes++` at `board.mjs:61` (the event at line 62 is a second mutant, caught by the event log) | `serve.test.mjs` M16: event log `["scan","scan","send","bump","scan"]`, then the next `index()` must log `scan` |
|
||||
| M17 | `index()` never clears a settled scan: the `promise.then(clear, clear)` line in `board.mjs` `index()` is dropped (Webby B4, r3) | `board.test.mjs` B4: two `index()` calls with no write log `["scan","scan"]` and the second shows a session line appended between them; `board.test.mjs` B4 rejected scan: the next `index()` runs fresh. M15 and M16 still pass after the clear, so they do not kill M17 |
|
||||
|
||||
### M14: closed by the real-board test (Sage sage-2839, Webby N1)
|
||||
|
||||
The earlier gap came from a stub board: a `spawnSync` in `scan.mjs` never ran under the test. The
|
||||
M14 test now builds a real `createBoard` whose tmux check runs through a shim on PATH. The shim is
|
||||
`#!/bin/sh`, `sleep 1`, `exit 1`. The test measures two things, both by elapsed time:
|
||||
- a 10 ms timer's largest gap while `/api/board` is pending (a blocked loop stalls the timer);
|
||||
- `/healthz` elapsed time, fetched after `/api/board` is sent.
|
||||
The bound is 500 ms for each. The async path (`runAsync`) keeps both near 10 ms. The M14 mutant
|
||||
(`tmuxInspectAsync` calls `spawnSync`) stalls the loop for the shim's full second, so both checks
|
||||
fail. The real-board test is not run yet (gate). Known soft spots, to check on the first run: the
|
||||
shim must win the PATH lookup, and the `specs` entry is minimal (no registration), which may not
|
||||
match a real spec.
|
||||
|
||||
## Checks done without running anything
|
||||
|
||||
- Read whole: `packages/webui/tests/fixture.mjs`, `packages/webui/tests/send.test.mjs` (first
|
||||
70 lines), `packages/webui/tests/serve.test.mjs` (first 80 lines), `packages/control-board/
|
||||
tests/board.test.mjs`, `packages/control-board/src/board.mjs`, `packages/webui/tests/pinned-copy.test.mjs`
|
||||
(the serve-from-copy test).
|
||||
- Grep, current tree: `--board` and `boardURL` in `packages/*/tests` and `scripts/test-*.sh`
|
||||
show only intended refusals and the unrelated case2 suite (`case2-unit.test.mjs`, `case2-unit.sh`).
|
||||
- The M9 and M2 rules run as greps over the live docs and `packages/control-board/src`: no hits.
|
||||
- sha256 pins: no changed file pins a file sha256. The 64-hex literal at `service.test.mjs:23` is
|
||||
a fixture uid, not a hash.
|
||||
- `packages/webui/tests/library-cli.test.mjs:16`: a fake serve module exports `DEFAULT_BOARD`.
|
||||
Harmless; may be dropped later.
|
||||
|
||||
## Docs (N2)
|
||||
|
||||
- `packages/webui/README.md`: the start block is one command (`node packages/webui/src/cli.mjs
|
||||
serve`); `--board` is refused; the proxy-origin sentence is gone; the Host and write paragraph
|
||||
says seen and reply reach the in-process board under `--send` on the public name; rollback has
|
||||
no 7331 step.
|
||||
- `packages/control-board/README.md`: `serve` is retired and exits 2; the board runs in the
|
||||
Console; `scan` is kept; the serve-only flags are removed; the route list is marked as served by
|
||||
the Console; exit 0 is scan only.
|
||||
- `docs/plans/CURRENT.md`: one dated line appended at the end (append-only).
|
||||
|
||||
Open, ruled sage-2868: boardSend's `/api/seen` catch returns 400 for a scan failure after writeMark. Left as built: a scan failure is not a client error, so this is a real gap. Named follow-up in Sage's post-253 DEFERRED sweep (N2).
|
||||
|
||||
Still in the README, on purpose: `packages/webui/README.md` line 826 runs the control-board suite
|
||||
(`node --test packages/control-board/tests/`). The suite still exists, so the line stays.
|
||||
|
||||
## Gate, not run
|
||||
|
||||
- control-board, webui and cli suites; `scripts/test-*.sh`; the mutants M1-M15 on a scratch copy.
|
||||
All wait for Sage's CLEAR.
|
||||
- The per-target drop (sage-2834 ruling a, sage-2837) is implemented and documented; M13 has its
|
||||
expectation for activate and rollback. Pushed at 2a758a5ba (sage-2852 gap, 17:20Z).
|
||||
- Webby webby-0152/0153 and Sage sage-2839, after 2a758a5ba: B1 and N3 met at 2a758a5ba. B2 (real
|
||||
activate and rollback runs, unit and pointer checked), B3 and M16 (reply() join), N1 and M14 (real
|
||||
board over a tmux shim) are written and uncommitted; the test runs wait for the CLEAR.
|
||||
- N2 is open, not changed: `boardSend`'s `/api/seen` catch answers 400 with `err.message` for a scan
|
||||
that fails after the mark is written. Webby marked it non-blocking; Sage has not ruled on it. A
|
||||
500 for that case would need `ConfigError` to tell refusals from scan failures.
|
||||
- M14: Sage accepted the gap as recorded (sage-2834). The mutant run is the check.
|
||||
- Push: not done. Sage's gaps govern it.
|
||||
|
||||
## r4: rebase onto 45dca69f6 (sage-3575 step 1, sage-3586 ruling a)
|
||||
|
||||
Branch `bushroot/row198-r4-45dca69f6`: the eight r3 commits replayed on `origin/refactor` 45dca69f6.
|
||||
`bushroot/row198-r3-253` stays at 1fb66792e and is not touched. Seven commits replayed clean. Only the
|
||||
retire commit (0bde5c8d7, now 4288b7b16) conflicted, in three files and seven hunks.
|
||||
|
||||
Ruling applied: every upstream feature stays (federation view, decisionWrite, userOptions, the
|
||||
`/api/config` fields, granted-user gating). The board runs in-process. The URL proxy to a board origin is
|
||||
removed, because the proxy is the route to the 7331 writer that row 198 retires. This is the one place an
|
||||
upstream behaviour changes. Board routes now answer from the in-process board with the same response
|
||||
shapes. A granted user still gets `owner-only` 403 on board routes, because the granted-user gate runs before
|
||||
them.
|
||||
|
||||
### Conflict hunks
|
||||
|
||||
Each hunk: upstream side, our side, resolution, reason.
|
||||
|
||||
1. `packages/webui/src/serve.mjs`, `startServer` signature. Upstream: `board = DEFAULT_BOARD`, with
|
||||
`decisionWrite`, `userOptions`, `federation`. Ours: `board = null`, none of the three. Resolved: upstream
|
||||
signature with `board = null`, and our doc comment above it. Reason: keeps upstream's parameters; no board
|
||||
is now the default, not port 7331.
|
||||
2. `packages/webui/src/serve.mjs`, body after the tester checks. Upstream: decision check, `confirmations`,
|
||||
`const upstream = boardURL(board)`, `userOptions` check, `owner` with `upstream`, `hub`. Ours: nothing.
|
||||
Resolved: upstream minus `const upstream = boardURL(board);`; `owner` drops `upstream`. Reason:
|
||||
`boardURL` is the proxy's loopback validator; there is no proxy. The `let {...}` destructure and the
|
||||
granted-user `upstream = null` line lose the same name.
|
||||
3. `packages/webui/src/serve.mjs`, the board proxy block (`allowed`, `fetch(upstream + path ...)`, 502 on
|
||||
failure). Upstream: the proxy, after the read-only gate. Ours: nothing here; in-process routes sit at two
|
||||
points. Writes (`/api/seen`, `/api/reply`) answer 405 for a non-POST, 403 `{error:'read-only', sent:false}`
|
||||
without `--send` and a named human, 503 with no board, then `boardSend`. Reads (`/api/board`,
|
||||
`/api/conversations`, `/api/conversation`) answer 405 for a non-GET, 503 with no board, then
|
||||
`consoleRead(board.index())` or `board.conversation`. Resolved: ours, upstream block removed. Reason: this
|
||||
block is the 7331 route. Auth check order: the sign-in gate (401 for an unsigned request under `--sso`)
|
||||
and the granted-user owner-only gate both run before the write routes and the read routes.
|
||||
4. `packages/webui/src/serve.mjs`, `/api/config`. Upstream: `board: upstream`. Ours: `board: boardLabel`.
|
||||
Resolved: `board: granted ? null : boardLabel`, with every upstream field after it unchanged. Reason:
|
||||
upstream sent `null` to a granted user (its `upstream` was null); `boardLabel` is `'in the Console'` for the
|
||||
owner and `null` with no system config.
|
||||
5. `packages/webui/src/cli.mjs`, imports. Upstream: `DEFAULT_BOARD` in the `./serve.mjs` import, plus the bus
|
||||
and console imports. Ours: `createBoard`, the two scan discovery imports, and `busCaller` again. Resolved:
|
||||
upstream imports minus `DEFAULT_BOARD`, plus `createBoard` and `discoverRepoAgents`/`discoverFleetAgents`.
|
||||
Reason: `DEFAULT_BOARD` no longer exists; `busCaller` was already imported by upstream.
|
||||
6. `packages/webui/src/cli.mjs`, the `startServer` call. Upstream: `board: options.board`, with
|
||||
`federation: system?.federation ?? null`. Ours: `board`, without `federation`, `decisionWrite`,
|
||||
`userOptions`. Resolved: upstream call with `board`. Reason: `--board` is refused at `cli.mjs:39`, so
|
||||
`options.board` no longer exists; the new `board` is the in-process object.
|
||||
7. `packages/webui/src/public/app.js`, `/api/config` handler. Upstream: `board = config.board`, the federation
|
||||
render, the sso line. Ours: `board = config.board || 'the Console board'`. Resolved: upstream handler with
|
||||
our fallback label. Reason: `config.board` is `null` for a granted user and with no system config, which
|
||||
would print `Board: null`.
|
||||
|
||||
### Upstream tests changed to match (same commit, 4288b7b16)
|
||||
|
||||
Upstream added tests that pass a URL board string or `f.boardURL`. Each now means "no board" (`board: null`,
|
||||
which answers the board routes 503) or the in-process fixture (`f.board`). `board: 'http://127.0.0.1:9'` and
|
||||
`board:'http://127.0.0.1:9'` became `board: null`; `f.boardURL` became `f.board` in decide-browser,
|
||||
thread-claims-browser, processing-browser and granted-user. No assertion compared the URL string. The fake
|
||||
`DEFAULT_BOARD` export strings in library-cli and thread-claims are left: they are fake module text, not board
|
||||
config.
|
||||
|
||||
### Not ported: fake HTTP board servers (known to fail; port before the gate)
|
||||
|
||||
These still pass a fake HTTP origin as `board`. The in-process board never calls it, so each test's
|
||||
board-route assumptions are false now. Each needs a read and a port to an in-process `board` object:
|
||||
|
||||
- `packages/webui/tests/browser-edge.test.mjs:19` (waits for an HTTP `/api/board` request, lines 15 and 31)
|
||||
- `packages/webui/tests/preview.test.mjs:62` (fake board answers `/api/board`; line 160 asserts 200)
|
||||
- `packages/webui/tests/tester-listener.test.mjs:29` (`hits` counter on the fake board)
|
||||
- `packages/webui/tests/shell.test.mjs:111` and `:427` (fake board with unavailable, empty, one, fail, refuse modes)
|
||||
|
||||
Also a risk, not checked: any test that reaches a board route with no `board` argument. The default moved
|
||||
from 7331 (a 502 on a dead origin) to `null` (503). A test asserting 502 there would now fail.
|
||||
|
||||
### Range-diff
|
||||
|
||||
`git range-diff 48e450ce8..bushroot/row198-r3-253 45dca69f6..HEAD`, markers only:
|
||||
|
||||
1: 0bde5c8d7 ! 1: 4288b7b16 retire the board writer on 7331 (resolved above)
|
||||
2: 69ac924bc = 2: aad3000fe build design item 8; killers for M1-M15; B4 drops; docs; BUILD.md
|
||||
3: 716b46adf = 3: 77c12b554 rollback always drops --board (sage-2834 ruling a)
|
||||
4: 0bb5f230f = 4: c9c8badc3 drop --board for every op, reason by target (sage-2837)
|
||||
5: d388f47ec = 5: f2fd6145a real-run M13 pairs, reply join killer M16, real-board M14 (sage-2839)
|
||||
6: 02d5bd9b4 = 6: f913d0e76 index() clears a settled scan; B4 tests and mutant M17 (webby-0157)
|
||||
7: 25565fa97 = 7: 41b5aae3c BUILD.md places the reply join killer (B3, M16)
|
||||
8: 1fb66792e = 8: 3c57ebb22 BUILD.md records N2 as a named follow-up (sage-2868)
|
||||
|
||||
Seven commits are unchanged by patch-id. Only the retire commit is `!`.
|
||||
|
||||
### Files to rerun (auto-merged or changed on rebase)
|
||||
|
||||
The drift check found nine paths touched by both r3 and upstream. Six auto-merged and need a test rerun:
|
||||
|
||||
- `packages/cli/tests/stack-release.test.mjs`
|
||||
- `packages/webui/README.md`
|
||||
- `packages/webui/tests/browser.test.mjs`
|
||||
- `packages/webui/tests/chat-browser.test.mjs`
|
||||
- `packages/webui/tests/chat.test.mjs`
|
||||
- `scripts/stack-release.sh`
|
||||
|
||||
The three conflicted src files are in the hunk list above. The 21 upstream-stub test files listed above also
|
||||
need a rerun, because the drift check did not cover them: they are upstream-only changes.
|
||||
|
||||
### Gate status
|
||||
|
||||
UNVERIFIED. No node, no test run, and no mutant has run on r4. The gate waits for Sage's CLEAR, after 226,
|
||||
220/216 and 285 in gate order. The four fake-board tests above are known red. The rest are unknown until the
|
||||
run.
|
||||
|
||||
## r4: the four fake-board tests ported to the in-process board (sage-3597, sage-3603)
|
||||
|
||||
Scope: the four tests that read the retired fake HTTP board server, plus one new granted-user test. Numstat is
|
||||
against 14fe7513c, the r4 tip before this entry. Nothing in this section has been run. The gate is still
|
||||
UNVERIFIED, and the tests here were not executed (slot rule). Sage sends the list below to Mos (mos-1272).
|
||||
|
||||
| File | +/- | What changed |
|
||||
|---|---|---|
|
||||
| `packages/webui/tests/browser-edge.test.mjs` | 14/13 | The fake server is gone. `index()` holds while `holdBoard` is set and resolves to the page's board on release. `reply()` holds in place of the `held` response and returns `{status, body}`. The 5 s "made no /api/board request" check still runs: it now watches `index()` being called. Every assertion is kept. |
|
||||
| `packages/webui/tests/preview.test.mjs` | 12/16 | `setup()` builds an in-process board that records `GET /api/board`, `POST /api/seen` and `POST /api/reply` in `boardHits`. The 401 and read-only tests keep their `boardHits` checks. The dead-board case is a board whose `index()` throws, so the answer is 502 `invalid-response`. The upstream origin is no longer in the body (see the deleted assertion below). |
|
||||
| `packages/webui/tests/tester-listener.test.mjs` | 4/4 | The first test's `hits` counter moves into an in-process `index()`. Every bus and board read still counts, and both 403 checks are unchanged. The `createServer` import is removed. |
|
||||
| `packages/webui/tests/shell.test.mjs` | 44/63 | `board()` is an in-process board. Modes: `empty`, `one`, `registered`, `fail` (throws, so 502 `invalid-response`), `unavailable` (throws `ReadError('read-failed')`, so 503). `hold()` gates `index()`. The conversation catalogue and page are sync. Changed expectations (listed below). Refusal-only steps removed (listed below). |
|
||||
| `packages/webui/tests/granted-user.test.mjs` | 32/2 | `setup()` takes `board` and `send` options, defaulting to the old `null` and `false`. New test `R6 G4b`: a granted session gets 403 `owner-only` on all five board paths with no board read, and the owner session gets 200 on each, with the read order checked. |
|
||||
|
||||
### Changed expectations (not loosened; each is a product change, for Mos)
|
||||
|
||||
These expectations now name the in-process contract. The message text came from the fake server before. Each one is
|
||||
an old assertion with a new value.
|
||||
|
||||
- `shell.test.mjs` stale-data check: `/board scan failed.*Showing last known data/` is now `/invalid-response.*Showing last known data/`. A scan throw is mapped by `consoleRead` to `invalid-response` (502). Before, the fake returned 500 with the text.
|
||||
- `shell.test.mjs` unavailable check: `/board unavailable.*No board data loaded/` is now `/read-failed.*No board data loaded/`. `consoleRead` sends the code as `error`, and the page shows `err.message`, which is that code. The page shows `read-failed` now, where it showed `board unavailable` before.
|
||||
- `preview.test.mjs` dead board: the status stays 502, and `body.error` is now `invalid-response`.
|
||||
|
||||
### Deleted assertions (proxy-only, no in-process counterpart)
|
||||
|
||||
- `preview.test.mjs` `assert.equal(board.body.board, dead)`. The in-process board has no origin, so the body
|
||||
can't name one. The status and the bus checks around it are kept.
|
||||
- `shell.test.mjs` the refusal block: the fake's HTTP 403 `board-refused`. `consoleRead` has no 403 code: `STATUS`
|
||||
in `reads.mjs` maps only `not-configured`, `no-bus-host`, `queue-refused`, `read-failed`, `not-found` and
|
||||
`invalid-request`, and any other code is 502. So the in-process board cannot refuse, and `app.js`'s
|
||||
`err.status === 403` branch ("The board refused the read", `#board-refused`, the fail-closed drop of kept
|
||||
rows, the inspector and the counts) has no trigger in these tests. The block that was deleted asserted every
|
||||
one of these. The conversation refusal is deleted too: a 403 from `/api/conversation` closed the open
|
||||
conversation, and the new step closes it with `#conv-back`.
|
||||
This is a coverage loss, and it needs a decision. Either `reads.mjs` maps a board refusal to 403 (a product
|
||||
change, which Mos rules on), or the refusal branch is retired. The granted-user 403 `owner-only` answer goes
|
||||
through the same `err.status === 403` branch, so the page path is still reached by granted users. It is not
|
||||
covered by a shell check.
|
||||
|
||||
### Status change, dead board (Webby's question, webby-0229)
|
||||
|
||||
A board failure used to answer by the origin's state: a dead origin was 502 with the origin named, a board 500
|
||||
was 500, and a 503 passed through. Now:
|
||||
|
||||
- no board configured (`board` null): 503 `not-configured`;
|
||||
- a `ReadError` with a code in `STATUS`: that code's status;
|
||||
- any other throw (a scan failure): 502 `invalid-response` on `/api/board`. The 500 applies only on the
|
||||
conversation routes (`conversation read failed`).
|
||||
|
||||
No test under `packages/webui/tests` asserts 502 on a board path other than the preview dead-board case above.
|
||||
The grep: `usage`, `limits-route`, `routes`, `chat`, `library-status`, `thread-claims`, `send`, `bus`,
|
||||
`project-file`, `tester-listener`, `send-browser`, `instances`, `tester-cli` and `agents`. Those 502s belong to bus,
|
||||
chat, or project-file routes, which this change does not touch.
|
||||
|
||||
### Granted-user gate (sage-3603): the test and its mutant
|
||||
|
||||
The board is a closure that the granted rebind doesn't touch, so `userRoute` is the only gate in front of it
|
||||
for a granted session. Upstream's null-origin backstop is gone. Test `R6 G4b` pins that: the granted session gets
|
||||
403 `owner-only` on `GET /api/board`, `GET /api/conversations`, `GET /api/conversation`, `POST /api/seen` and
|
||||
`POST /api/reply`, and `reads` is still empty after those five requests. The owner session gets 200 on each, and
|
||||
`reads` shows the five calls in order.
|
||||
|
||||
Mutant M18 (not run): in `packages/webui/src/serve.mjs`, change the granted gate
|
||||
`if (!userRoute(path, req.method)) return json(res, 403, ...)` so it passes when `path` is one of the five board
|
||||
paths. Expected: `R6 G4b` fails at the granted 403 assertion, because the granted request reaches the board and
|
||||
`reads` is not empty. Run it with the rest of the granted-user suite once Sage gives CLEAR. The kill is not yet shown.
|
||||
|
||||
### Gate status
|
||||
|
||||
UNVERIFIED. These four files and the granted-user test are edited but not run. The rule "commit only after suites
|
||||
are green" is not yet met, and this commit says so. The coverage loss above needs Sage's ruling before the gate.
|
||||
|
||||
## r4 round 2: mos-1513 (B1 fix), N1 test, route helper (19:23Z 10-23)
|
||||
|
||||
Not run. Node and browser suites wait for Sage's CLEAR.
|
||||
|
||||
### B1: CDP interception instead of a named listener on the old files
|
||||
|
||||
Per mos-1513, the browser tests keep the real in-process board. Each one calls `routeWrites(b, base, board)` from
|
||||
`packages/webui/tests/board-route.mjs` before it navigates. Fetch intercepts the page's `/api/config` (answered
|
||||
with the real body and `send: true`), `/api/reply` and `/api/seen`. Each write is forwarded over node http to a
|
||||
named send listener on the same board, with the preview Host, the `https://` Origin and the signed user header.
|
||||
So the server's own gate, parse and status mapping answer, and the page gets that answer back. Nothing re-implements
|
||||
`boardSend`.
|
||||
|
||||
Shared helper, named for webby-0243: `board-route.mjs` exports `ROUTE`, `namedListener(board)`, `forward(port, path,
|
||||
body)` and `routeWrites(b, base, board)`. `namedListener` and `forward` are the same code `routeWrites` uses.
|
||||
|
||||
### Numstat (git diff --numstat, packages/webui/tests, against 4d8123b0a)
|
||||
|
||||
3 1 board-held.test.mjs
|
||||
7 2 browser-edge.test.mjs
|
||||
6 2 browser.test.mjs
|
||||
6 3 discord.test.mjs
|
||||
4 2 history-return-flow.test.mjs
|
||||
4 2 return-flow.test.mjs
|
||||
|
||||
New files: `board-route.mjs` (helper), `board-route-map.test.mjs` (node, see below), `board-refused.test.mjs` (N1).
|
||||
|
||||
### Line map: every removed line in the six old tests
|
||||
|
||||
No assertion line is changed. Every removed line is one of these, and each is listed by old line:
|
||||
|
||||
- board-held.test.mjs: `done` teardown (adds `stopRoute()` before `b.close()`); the `routeWrites` line is added in
|
||||
`setup`. No assertion.
|
||||
- browser-edge.test.mjs: the `await b.viewport(320); await b.navigate(...)` line is rewritten as the same two
|
||||
statements with the `routeWrites` line added before it; the `finally` adds `stopRoute?.()`. The `board` object
|
||||
is unchanged. `held` and `releaseBoard` are the same. No assertion.
|
||||
- browser.test.mjs: the `finally` adds `stopRoute?.()`; `routeWrites` is added before the `viewport`/`navigate`
|
||||
line, which is byte-identical. No assertion.
|
||||
- discord.test.mjs: the `fetch(base + '/api/reply', ...)` request line becomes `namedListener` + `forward`, the
|
||||
request setup, not the assertion; `assert.equal(reply.status, 409)` is byte-identical. The `finally` closes the
|
||||
named listener.
|
||||
- history-return-flow.test.mjs: `let b` gains `stopRoute`; `finally` adds `stopRoute()`. No assertion.
|
||||
- return-flow.test.mjs: `let web, b` gains `stopRoute`; `routeWrites` added after `browser()`; `finally` adds
|
||||
`stopRoute()`. No assertion.
|
||||
|
||||
### N1 (sage-3618, mos-1506 b): the refusal block, old to new
|
||||
|
||||
Old line numbers are in `shell.test.mjs` at 14fe7513c, the block 178-245. New line numbers are in
|
||||
`board-refused.test.mjs`. Every old assertion is restored with its text unchanged:
|
||||
|
||||
181 -> 74 183 -> 76 184 -> 77 185 -> 78 187 -> 80
|
||||
188 -> 81 189 -> 82 190 -> 83 191 -> 84 192 -> 85
|
||||
193 -> 86 194 -> 87 195 -> 88 196 -> 89 197 -> 90
|
||||
203 -> 97 204 -> 98 205 -> 99 206 -> 100 207 -> 101
|
||||
208 -> 102 209 -> 103 210 -> 104 211 -> 105 212 -> 106
|
||||
215 -> 109 216 -> 110 217 -> 111 218 -> 112 220 -> 114
|
||||
221 -> 115 227 -> 122 229 -> 124 232 -> 127 236 -> 131
|
||||
237 -> 132 238 -> 133 239 -> 134 240 -> 135 241 -> 136
|
||||
243 -> 138
|
||||
|
||||
Changes in the port: the page-level 403 stub (`Fetch` on `*/api/board*` and `*/api/conversation*`) is the one
|
||||
place a page-level stub is used, per N1. The board is the in-process one with `refuse()` set. The fake-board
|
||||
data in `board()` is the old fake board's data, moved.
|
||||
|
||||
Pinned N1 mutant (not run): in `packages/webui/public/app.js` (the `error(err)` branch that sets
|
||||
`refused = err.status === 403`), drop the `#board-refused` show and the `refused` banner. Expected:
|
||||
`board-refused.test.mjs` fails at line 80 (`banner ref`) and line 83 (`#board-refused` hidden). Run after CLEAR.
|
||||
|
||||
### Node coverage for the real route
|
||||
|
||||
`board-route-map.test.mjs` (new) starts the named listener on a fixture board and pins the mapping the browser
|
||||
tests now rely on: seen is 200; a delivered reply is 200 with `delivered: true`; an exit-4 reply is 200 with
|
||||
`delivered: false` and `exitCode: 4` (this is `replyWith` in control-board/src/serve.mjs, which maps a nonzero exit
|
||||
to 200, not a status code). Existing coverage cited, not duplicated: `board-inprocess.test.mjs:84-97` (seen 200 and
|
||||
reply delivered through a named listener); `send.test.mjs:114-119` (loopback Host writes 403 read-only, nothing
|
||||
reaches the board); `preview.test.mjs:185-199` (every write 403 read-only from the public name and loopback; 401
|
||||
unsigned on `/api/reply`).
|
||||
|
||||
The discord 409 is now reached through the named listener, so `discord.test.mjs` pins the connector refusal on the
|
||||
real route.
|
||||
|
||||
### Not done in this round
|
||||
|
||||
- The plain-listener browser check for the reply form and Seen line with send off (the `canSend` gate in app.js).
|
||||
The node and browser coverage for that gate is not written yet.
|
||||
- `send-browser.test.mjs:67,243` is unchanged. Webby to confirm next round.
|
||||
- Mutant M18 and the N1 mutant are not run.
|
||||
|
||||
### Gate status
|
||||
|
||||
UNVERIFIED. Nothing in this round is run. Waiting for Sage's CLEAR before any node or browser run.
|
||||
|
||||
### Correction to the r4 round 2 entry above (19:24Z 10-23)
|
||||
|
||||
- The N1 mutant's file is `packages/webui/src/public/app.js`, not `packages/webui/public/app.js`.
|
||||
- `board-refused.test.mjs` does not use the in-process board. Its `board()` is an inline stub object with a `refuse()`
|
||||
flag; the 403 comes from a page-level Fetch stub on `*/api/board*` and `*/api/conversation*`, as the N1 ruling
|
||||
allows. The "in-process board with refuse() set" sentence above is wrong.
|
||||
|
||||
## r4 round 2 fixes: mos-1522 (19:40Z 10-23)
|
||||
|
||||
Not run. Commit held locally until the suites pass (mos-1522).
|
||||
|
||||
- B1 (discord.test.mjs:43, :50): app.js now checks `r.connector` first, then `!canSend`, then the registered-seat text, in both the inspector reply line (app.js:100) and `convForm` (app.js:200, conv-no-reply). Discord still asserts "Board replies disabled for Discord connectors" with no routeWrites added. Assert lines are unchanged.
|
||||
- B2: `conv-no-reply` with send off reads "Replies are off: this Console is read-only.", asserted in send-off-browser.test.mjs.
|
||||
- routeWrites flips only `send` to true, over the real `/api/config` answer. Nothing else in the config body is changed.
|
||||
- Mutants (not run): (a) drop `canSend &&` from `canReply` (app.js:51); (b) drop the `seenButton` guard (app.js:71). Expected: send-off-browser fails on `.reply-form` absent (a) and on `[data-seen]` absent (b).
|
||||
|
||||
## r4 round 2 follow-up: Webby CHANGES on ab6417475 (N1, N2, B1 board-refused) (10-23 19:46Z)
|
||||
|
||||
Not run. Gate waits for Sage's CLEAR. Commit A of the follow-up; the old-test setup is commit B, below.
|
||||
|
||||
- N1: `routeWrites` builds the config URL with `new URL('/api/config', base)`, so a trailing-slash base no longer requests `//api/config` (which parses as host `api`). The config body now comes from one new export, `sendConfig(base)` (board-route.mjs), which `routeWrites` and `sendOnConfig` both use.
|
||||
- New export `sendOnConfig(b, base)` (board-route.mjs): Fetch answers the page's `/api/config` with send on and leaves every write alone. It is config only (mos-1524); its callers are in commit B.
|
||||
- board-refused.test.mjs: line 13 imports `sendConfig`; the Fetch pattern list adds `*/api/config` (line 59 onward); the handler answers `/api/config` with `sendConfig(base)` and returns before the refusal branch. The 403 refusal stub is unchanged. The assert that was line 122 (`#conv-form` hidden is false) is now line 128, unchanged; it sees send on.
|
||||
- N2, the "Not done" list above is out of date. Its line "the canSend check is not written" is wrong: the check is in app.js (ab6417475, mos-1522 fixes in b0c79751c). Written as of this follow-up:
|
||||
- app.js:24 `canSend` (set from `/api/config` at app.js:482); app.js:51 `canReply` gated on `canSend`; app.js:71 `seenButton` guard.
|
||||
- app.js:100 inspector reply line and app.js:200 `convForm` (conv-no-reply): connector first, then the read-only line, then the registered-seat line.
|
||||
- New user-visible strings: "Replies are off: this Console is read-only." and "Seen is off: this Console is read-only.".
|
||||
- New test files: board-route.mjs (helper), board-route-map.test.mjs (node), board-refused.test.mjs, send-off-browser.test.mjs.
|
||||
- Mutants planned, not run: (a) drop `canSend &&` from `canReply` (app.js:51); (b) drop the `seenButton` guard (app.js:71). Both must fail in send-off-browser.test.mjs.
|
||||
|
||||
## r4 round 2 follow-up, commit B: old-test setup for the canSend gate (Webby B1, mos-1524) (10-23 19:46Z)
|
||||
|
||||
Not run. Setup only: no assert line changed. Approved on the mos-1522 terms (mos-1524: config-only stub, not routeWrites). Gate waits for Sage's CLEAR.
|
||||
|
||||
Numstat for this commit (`git diff --numstat`):
|
||||
|
||||
| file | added | removed | change |
|
||||
| --- | --- | --- | --- |
|
||||
| packages/webui/tests/relaunch.test.mjs | 2 | 1 | line 10 imports `sendOnConfig`; line 32 `await sendOnConfig(b, webURL)` after `browser()` |
|
||||
| packages/webui/tests/shell.test.mjs | 2 | 0 | line 17 imports `sendOnConfig`; line 124 `await sendOnConfig(b, base)`, first test only |
|
||||
| packages/webui/tests/sso-browser.test.mjs | 1 | 1 | line 33 page stub: `/api/config` gets `send: true` on every page; `sso: true` still only with `sso` in the URL |
|
||||
|
||||
- relaunch.test.mjs:49 (`#reply` present, "reply eligibility unchanged") is unchanged and now sees send on. No teardown is needed: `sendOnConfig` starts no server; `b.close()` in the finally ends the Fetch domain.
|
||||
- shell.test.mjs:217 (`#conv-form` hidden is false) is unchanged and now sees send on. The five-states test (from about line 410) has no reply, Seen or conv-form assertion and is not changed.
|
||||
- sso-browser.test.mjs:91-92 (`[data-seen]` wait and click) is unchanged. Its `/api/seen` write is still answered 401 `sign-in required` by the page stub before it reaches the server, so the sign-in path is what runs. The sign-in lines and the `__signIn` list are unchanged.
|
||||
- board-refused.test.mjs:128 (`#conv-form` hidden is false) is in commit A, with its config stub; it needs no change here.
|
||||
- Totals for this commit: 5 added, 2 removed, 3 files. Assert lines changed: 0.
|
||||
@@ -1,170 +0,0 @@
|
||||
# Row 224 — C2-05b broker lookups and typed tools (#1725)
|
||||
|
||||
Owner: Della; reviewer: Dewey. Assignment sage-2795; scope extension sage-2804.
|
||||
Base: `4f3fbc25a` (Researcher C2-05a). Branch: `della/row224`.
|
||||
Brief: C2-05b in `docs/plans/2026-10-20_case2-rows.md` at `aca16404c`
|
||||
and `51cc0445c`; design section 3, 5.3 M3 and 7.2; C2-05a BUILD rulings.
|
||||
|
||||
## Execution boundary and progress
|
||||
|
||||
Code/docs and local Git only while row 253 holds the gate. No Node (including
|
||||
syntax checks), HTTPS Git, queue claim, test or push until Sage's CLEAR.
|
||||
Tests are written first; their RED/GREEN runs are deferred, not claimed.
|
||||
Queue next is deferred too: its executable uses Node. Sage assigned this row
|
||||
explicitly. After 253 and 216 land, rebase under a new branch name.
|
||||
|
||||
- Pre-flight: query.mjs consumes `{repo,commit,mapping}`; broker cannot import
|
||||
Git/filesystem library code because it is in the sandbox import closure.
|
||||
Runtime injects the host lookup adapter; existing human file/status/sync stay.
|
||||
- Pre-flight: resolved has no project list and a library is never a checkout.
|
||||
Bundle needs an explicit library flag from launcher business configuration.
|
||||
- M3 reading: exact creative base is five lookups plus send_message; retain
|
||||
explicitly granted broker web_search/web_fetch for research/companion under
|
||||
landed C2-08 and sage-2804. No generic spec, task, evidence or decision tools. Residual (mos-1308): those
|
||||
reads lack broker action checks; their typed-tool absence is the only fence
|
||||
for creative roles. M1/M2 deny bash; broker-side refusal is outside this row.
|
||||
Mos confirmed this interpretation in mos-1308; design wording fix belongs to Gosalyn. Other definitions retain their current tools.
|
||||
- Multi-library businesses use an optional project ID; omit it only with one
|
||||
configured library. Universe is required for all five tools, including get.
|
||||
- Missing library uses the brief's `library-not-configured`; argument errors
|
||||
reuse `invalid-request` and C2-05a query codes. No new action or authority.
|
||||
|
||||
## Manifest (draft code, unrun)
|
||||
|
||||
- bus/src/broker.mjs: five lookup verbs, strict args, holder/business rechecks.
|
||||
- bus/src/runtime.mjs: host lookup adapter, stale-before-read sync, served pin.
|
||||
- harness/src/tools.mjs: five schemas; creative exact-set filtering.
|
||||
- harness/src/bundle.mjs: library flag forwarded to typedTools.
|
||||
- cli/src/launcher.mjs: one flag derived from trusted business projects.
|
||||
- bus/tests/library-lookups.test.mjs: real query/broker/socket/runtime cases.
|
||||
- harness/tests/library-tools.test.mjs: schemas, role snapshots, bundle parity.
|
||||
- cli/tests/thread-launch.test.mjs: flag reaches a real launched bundle with no library checkout.
|
||||
- agents/della/work/queue-224/BUILD.md: manifest, rulings and deferred verification ledger.
|
||||
|
||||
## Verification ledger
|
||||
|
||||
No test or syntax-check run yet. Acceptance requires full bus/cli/harness suites
|
||||
and every scripts/test-*.sh on Sage's gate; focused files and affected CLI/
|
||||
seat import-closure tests precede that only in an authorized CLEAR.
|
||||
|
||||
## Coordination
|
||||
|
||||
Huey272 reports no runtime/bundle/launcher overlap. Rocko219 changes launcher
|
||||
THREAD_DEFINITIONS checks only, separate from our buildBundle flag argument.
|
||||
The launcher argument is required: bundle cannot derive library presence from
|
||||
checkout IDs (a library has no agent clone). No role, authority or config schema
|
||||
changes; library project entries are already validated and protected by M9.
|
||||
|
||||
## Mount and pin impact (inspection only)
|
||||
|
||||
No new imports in broker.mjs or tools.mjs; no RUNNER_FILES or sandbox mount
|
||||
addition. runtime.mjs is host-only and already imports library index.mjs.
|
||||
Bundle forwards only a boolean, never a root/mapping/slot/credential. The
|
||||
existing import-closure tests are bus/tests/project-runtime.test.mjs and
|
||||
seat/tests/sandbox.test.mjs. Grep found no literal sha256 pin for runtime.mjs
|
||||
or bundle.mjs; bundle tests derive current source digests through SOURCES.
|
||||
Library sources remain in the existing whole-directory release copy.
|
||||
|
||||
## Runs to schedule only after CLEAR
|
||||
|
||||
Set TMPDIR=/mnt/storage/scratch/tmp. Focused new bus/harness files first,
|
||||
then existing library.test.mjs, library-sync.test.mjs, project-runtime.test.mjs,
|
||||
harness creative/tools/spec-tools/bundle files and affected CLI launch tests.
|
||||
Follow with the required whole bus/cli/harness suites and Sage's full shell gate.
|
||||
No timing claim yet: use the repository/Sage per-file limits and record every
|
||||
command, outcome, elapsed time and timeout before acceptance. No focused skip
|
||||
or pattern run substitutes for whole-file gate evidence.
|
||||
|
||||
## Read-through findings and residuals
|
||||
|
||||
- Library get requires universe although the older design table omitted it;
|
||||
section 3 isolation rule and C2-05a's accepted API are binding.
|
||||
- Query type enum matches mapping.TYPES (five values); manuscript is a distinct
|
||||
lookup and not a find type. Unknown host-path/commit/business/accept fields
|
||||
are refused; explicit project is restricted to this business's library list.
|
||||
- Async reads recheck the live holder and current project revision. A stale
|
||||
read attempts sync, then takes the new served snapshot; a returned fetch
|
||||
failure/divergence keeps serving the old pin. Busy sync uses the current pin.
|
||||
Unreadable state, no served commit or unsafe configuration refuse. Query
|
||||
and sync never hold a SQLite transaction over await.
|
||||
- Processing halt refuses new agent lookup work and rechecks before returning
|
||||
async results. Existing human library file/status/sync behavior is unchanged.
|
||||
- Timer sync and reads still use C2-03a's 60-second contract. No timer tuning or
|
||||
resource-limit schema is introduced by this row. Q222 creative reply settings
|
||||
are in C2-06; no reply guard or writing capability is implemented here.
|
||||
- The generic tool socket's existing 60-second client timeout can precede the
|
||||
server's 120-second Git timeout. This row does not change the generic timeout;
|
||||
a timed-out lookup is read-only and retryable, never a write/outcome claim.
|
||||
- All tests are authored but unrun. No RED, GREEN, syntax, runtime performance,
|
||||
sandbox, broker/harness suite, shell gate, claim or push evidence exists yet.
|
||||
|
||||
## Candidate review checklist (read-through only)
|
||||
|
||||
- [x] Five verbs mapped to real C2-05a methods, all require universe.
|
||||
- [x] Default sole library or explicit owned project; no caller repo/commit.
|
||||
- [x] Human-only file/status/sync gates kept; lookup holder rechecked.
|
||||
- [x] Async socket dispatch includes lookup verbs through LIBRARY_VERBS.
|
||||
- [x] Stale/unknown fetch syncs first; results use current immutable served pin.
|
||||
- [x] Six/eight creative names per mos-1308; other roles retain their tools.
|
||||
- [x] Launcher -> bundle -> typedTools passes presence without a library clone.
|
||||
- [x] No imports added to sandbox source closure; no mount change.
|
||||
- [ ] Execute new tests, RED characterization, full files/suites after CLEAR.
|
||||
- [ ] Rebase after rows 253/216 land, under a new branch name.
|
||||
- [ ] Claim/push only in Sage-authorized gap; Dewey review and Sage gate.
|
||||
|
||||
Candidate preservation commits are explicitly unrun under sage-2795; they are
|
||||
not completion, green-suite or landing claims. No dependencies installed.
|
||||
|
||||
Correction: the first committed-range diff --check found a blank line at EOF
|
||||
in the new library-tools test, missed while it was untracked. Removed in a
|
||||
follow-up local commit; the complete range must be checked again. No tests ran.
|
||||
|
||||
## Shared-log receipts for Sage (sage-2815)
|
||||
|
||||
The shared logs are unchanged from the base in this row. Sage appends these
|
||||
receipts in the landing sweep; they are preserved here, not in the manifest.
|
||||
|
||||
For `BUILD-LOG.md`:
|
||||
|
||||
```text
|
||||
2026-10-22T16:36:30Z | Della row224 C2-05b #1725 preparation | Before: C2-05a queries exist at 4f3fbc25a but no broker lookup verbs or typed tools. Scope: broker/tools plus Sage-authorized runtime/bundle integration, tests and docs. Existing human-only library reads retained; five agent queries require a live holder and trusted configured source. Code/docs only under row253 gate; no Node/test/push/claim. Runtime ownership coordinated with Rocko219/Huey272.
|
||||
```
|
||||
|
||||
For `docs/SESSIONS.md`:
|
||||
|
||||
```text
|
||||
2026-10-22T16:36:30Z | Della (sage-2795 / sage-2804, Q220) | Row 224 C2-05b #1725, della/row224 base 4f3fbc25a | Read both pinned briefs and C2-05a source; preparing broker/typed tools and authorized host runtime/bundle seams. No queue command, Node, HTTPS Git, claim, test or push during Gander gate. Dewey reviewer; Rocko/Huey runtime coordination sent. Tests and integration verification deferred to CLEAR.
|
||||
```
|
||||
|
||||
Local-only draft pins 19c0a7adf/7cf39c390 are superseded by the
|
||||
log-free candidate. Saved as local branch della/row224-draft-with-logs before
|
||||
rebuilding the unpublished candidate commit; no remote history changed.
|
||||
|
||||
## Dewey dewey-0852 and Sage sage-2819 — focused corrections
|
||||
|
||||
B1: inline freshness uses max(lastFetch, lastError.at), not lastFetch alone.
|
||||
An error within 60 seconds counts as a recent attempt, so later lookups serve
|
||||
the old served pin without another fetch. Keep inline sync: the brief requires
|
||||
it and businesses without timed libraries have no sync timer. The extended
|
||||
failed-fetch test makes reads at +1, +59,999 and +60,000 ms (one attempt total),
|
||||
then +60,001 ms (second attempt). All reads must return the old commit.
|
||||
|
||||
Required mutant M-B1: remove lastError.at from recentAttempt. The repeated
|
||||
reads must fail the attempted-count assertion. This mutant has NOT been run.
|
||||
|
||||
Residual (Sage will defer): the first inline fetch in each outage window can
|
||||
still exceed the tool timeout (60 seconds, or the runner broker client’s
|
||||
30 seconds). Bounding that fetch is out of scope for 224. Subsequent reads
|
||||
are paced by persisted lastError.at. No claim of first-read outage latency.
|
||||
|
||||
N1: whole bus, cli and harness suites are required, plus the shell gate.
|
||||
If the new thread-launch integration test skips due to sandboxProblem(),
|
||||
name the skip; a skip is not launcher integration proof.
|
||||
N2: cli/tests/thread-launch.test.mjs now depends on library/tests/helpers.mjs
|
||||
mappingEntry() and library/tests/fixtures/mapping.json. Changes to that fixture
|
||||
must rerun the CLI launch file.
|
||||
N3: harness/tests/library-tools.test.mjs compares the type enum to mapping.mjs
|
||||
TYPES, so adding a mapped type cannot silently leave the typed tool behind.
|
||||
|
||||
These edits are local code/docs only. No Node, RED/GREEN run, mutant, claim,
|
||||
HTTPS Git or push. New local pin goes to Dewey/Mos/Sage for focused review.
|
||||
@@ -1,196 +0,0 @@
|
||||
# Row284 C2-05c — supporting scope and served-tree blob (#1789)
|
||||
|
||||
Owner Della; reviewer Dewey; gate Sage. Build start sage-3658/3660 (10-23).
|
||||
Branch della/row284. Original prerequisite base Honker28346bad7ea3;
|
||||
merge fc05bcbc3 carries22423816cf28 and2164f3fbc25a intact. Only conflict was
|
||||
CLI thread-launch's added footer tests: retained both full test bodies and
|
||||
assertions. No prerequisite code copied. Every prerequisite needs its own
|
||||
review/mutants/landing under mos1351; rebase after283 lands.
|
||||
|
||||
## Execution status
|
||||
|
||||
UNTESTED. No Node, node--check, test or mutant run before Sage CLEAR. First
|
||||
own commit is the authorized old-test migration; the implementation follows.
|
||||
Sage authorizes an immediate untested push. No queue write or shared-log edit.
|
||||
224 stays first;284 is critical to Bubba288. T4 for224 is not a CLEAR yet.
|
||||
|
||||
## Binding readings
|
||||
|
||||
Brief C2-05c at94c33a43c:1737-1789, Q226, sage2918 and mos1330 item3.
|
||||
get defaults canon; explicit playground/supporting opt-in; mismatch null.
|
||||
Sort full outgoing/incoming lists supporting, playground, canon before paging,
|
||||
keep cross-scope links and universe filter. Manuscript stays canon/playground.
|
||||
No all/array scope, write tool, guard, mapping or creative-name-count change.
|
||||
|
||||
## mos-1330 old-test migration (separate commit)
|
||||
|
||||
All paths below are packages/library/tests/query.test.mjs. Baseline file at
|
||||
fc05bcbc3 matches216's file. Source assertions retained; negative calls receive
|
||||
explicit scope too, so universe isolation cannot pass on default-scope denial.
|
||||
|
||||
Call/assertion 348 -> 348:
|
||||
|
||||
```js
|
||||
// OLD
|
||||
const rin = await get(s, { universe: 'idea', path: 'Drafts/New World/Characters/Rin.md' });
|
||||
// NEW
|
||||
const rin = await get(s, { universe: 'idea', path: 'Drafts/New World/Characters/Rin.md', scope: 'playground' });
|
||||
```
|
||||
|
||||
Call/assertion 350 -> 350:
|
||||
|
||||
```js
|
||||
// OLD
|
||||
assert.equal(await get(s, { universe: 'harbor', path: 'Drafts/New World/Characters/Rin.md' }), null);
|
||||
// NEW
|
||||
assert.equal(await get(s, { universe: 'harbor', path: 'Drafts/New World/Characters/Rin.md', scope: 'playground' }), null);
|
||||
```
|
||||
|
||||
Call/assertion 366 -> 366:
|
||||
|
||||
```js
|
||||
// OLD
|
||||
assert.equal(await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path }), null);
|
||||
// NEW
|
||||
assert.equal(await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path, scope: 'playground' }), null);
|
||||
```
|
||||
|
||||
Call/assertion 367 -> 367:
|
||||
|
||||
```js
|
||||
// OLD
|
||||
const own = await get(src(ref), { universe: 'harbor', path });
|
||||
// NEW
|
||||
const own = await get(src(ref), { universe: 'harbor', path, scope: 'playground' });
|
||||
```
|
||||
|
||||
Assertion 349 retained, OLD == NEW:
|
||||
|
||||
```js
|
||||
assert.equal(rin.scope, 'playground');
|
||||
```
|
||||
|
||||
Assertion 368 retained, OLD == NEW:
|
||||
|
||||
```js
|
||||
assert.equal(own.scope, 'playground');
|
||||
```
|
||||
|
||||
The null assertions at350 and366 are the complete OLD/NEW expressions above:
|
||||
expectation remains null. Added one separate no-scope playground-get -> null
|
||||
behavior test. It is unrun and expected to expose old get behavior until the
|
||||
new scope implementation lands. This migration is authorized by mos1330 under
|
||||
mos1272; no check removed or weakened. Dewey checks this list in manifest review.
|
||||
|
||||
## 283 surface and blob choice
|
||||
|
||||
Inspected actual46bad7ea3: optional editor.user is present. reader's parsed
|
||||
file records and index entries still discard tree oid. In owned query.mjs use
|
||||
the existing bounded listTree(repo, servedCommit) to obtain exact path oid,
|
||||
not HEAD and not a user-supplied revision expression. No reader/index edit.
|
||||
Tradeoff: one additional bounded tree listing per get; latency unmeasured.
|
||||
Recheck after rebasing to283's actual landing.
|
||||
|
||||
## Intended owned paths
|
||||
|
||||
query.mjs; broker.mjs lookup branch; tools.mjs lookup schemas/descriptions;
|
||||
library query.test.mjs/query-fixture.mjs; bus library-lookups.test.mjs;
|
||||
harness library-tools.test.mjs; this BUILD.md. Other files are prerequisites.
|
||||
|
||||
## Gate
|
||||
|
||||
Dewey manifest approval, whole library/bus/harness suites and every
|
||||
scripts/test-*.sh on Sage gate. Before review grep all package tests for enum
|
||||
pins and canon/playground pairs. Keep shared logs out; send receipts to Sage.
|
||||
No test or mutant evidence claimed. Manifest/digest follows complete code.
|
||||
|
||||
## Implementation head (UNTESTED, sage-3658)
|
||||
|
||||
- query scope accepts supporting for find/search/get. get defaults canon,
|
||||
mismatch null. Manuscript passes a separate two-value scope allowlist.
|
||||
- get returns blob from listTree(source.repo,index.commit) after scope/universe
|
||||
filtering. The same index.commit supplies the text; source HEAD is irrelevant.
|
||||
- Full outgoing and incoming link lists are stably sorted by supporting,
|
||||
playground,canon before combining/paging. All visible cross-scope links stay;
|
||||
universe/hybrid visibility still applies. Scope metadata isn't added to links.
|
||||
- Broker only adds optional scope to library.get's strict fields. No new verbs.
|
||||
- Typed scope expands find/search/get only; manuscript retains its own two-value
|
||||
enum. Names/M3 counts remain the row224 six/eight, no write tools added.
|
||||
- New tests: book/universe/draft supporting find/search/get; defaults/mismatch;
|
||||
absent supporting prefix; source HEAD advances but each scope's blob equals
|
||||
served:path; 300 outgoing plus300 incoming links ordered across bounded pages,
|
||||
retaining cross-scope links and dropping other-universe links; hybrid source
|
||||
stays canon-only; no all/array scope; manuscript rejects supporting; broker
|
||||
scope/blob forwarding; typed enums and unchanged names. All unrun.
|
||||
|
||||
## Additional old assertion pin (brief Gate)
|
||||
|
||||
harness/tests/library-tools.test.mjs:49 at23816cf28 (line49, unchanged in this head):
|
||||
OLD find scope enum ['canon','playground']; NEW ['canon','playground','supporting'].
|
||||
This is the exact scope-enum pin the brief directs updating. Existing six/eight
|
||||
name snapshots and TYPES equality assertion are unchanged. The new manuscript
|
||||
pin intentionally remains ['canon','playground']. No other two-scope test pin
|
||||
was found by rg over packages/*/tests at this build's prerequisite merge.
|
||||
|
||||
## Proposed mutation evidence (reviewer confirms final set)
|
||||
|
||||
All unrun. Require assertion failures with whole-file runs and pristine restore:
|
||||
M1 remove supporting from query SCOPES -> supporting lookup cases.
|
||||
M2 drop get's requested-scope comparison -> default/mismatch null cases.
|
||||
M3 use source HEAD for blob lookup -> served-tree/advanced-HEAD equality case.
|
||||
M4 remove link rank sort -> multi-page outgoing/incoming expected order.
|
||||
M5 restrict links to requested scope -> cross-scope link counts/order case.
|
||||
M6 drop sees() from visible/link filtering -> second-universe/hybrid cases.
|
||||
M7 allow supporting in manuscript validator -> manuscript refusal case.
|
||||
M8 omit get scope from broker fields -> real broker supporting get case.
|
||||
M9 widen typed manuscript enum -> typed-scope separation case.
|
||||
|
||||
## Inspection and pending gate
|
||||
|
||||
No production imports change in broker/tools, so no new sandbox closure file
|
||||
or mount. query.mjs is host library code and adds existing reader.listTree;
|
||||
no new package/export/copy-path file. rg found the one old find scope pin plus
|
||||
the intentional new manuscript two-scope pin. Whole library/bus/harness and
|
||||
shell gates remain required. CLI integration/merge context is prerequisite224/
|
||||
219, not this row's source delta; any changed gate expectation goes to Sage.
|
||||
Node checks, tests, mutants and elapsed-time evidence remain absent pending
|
||||
CLEAR. The first test-migration commit and this implementation are distinct.
|
||||
|
||||
Sage sweep receipt: Della row284 builds on28346bad7ea3 plus intact224 merge;
|
||||
mos1330 old-test migration separate, supporting scope/blob implementation and
|
||||
fixtures authored, completely UNTESTED. No shared-log/queue write or live action.
|
||||
|
||||
## Current assertion anchors after implementation imports
|
||||
|
||||
The separate migration commit0ed4fbab9 retains its complete OLD/NEW text
|
||||
above. Adding only the fs/path imports in the implementation shifts anchors:
|
||||
|
||||
query.test.mjs:130: `const blendCh2 = await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path: 'Book One/Chapters/Chapter 02.md' });`
|
||||
query.test.mjs:350: `const rin = await get(s, { universe: 'idea', path: 'Drafts/New World/Characters/Rin.md', scope: 'playground' });`
|
||||
query.test.mjs:351: `assert.equal(rin.scope, 'playground');`
|
||||
query.test.mjs:352: `assert.equal(await get(s, { universe: 'harbor', path: 'Drafts/New World/Characters/Rin.md', scope: 'playground' }), null);`
|
||||
query.test.mjs:368: `assert.equal(await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path, scope: 'playground' }), null);`
|
||||
query.test.mjs:369: `const own = await get(src(ref), { universe: 'harbor', path, scope: 'playground' });`
|
||||
query.test.mjs:370: `assert.equal(own.scope, 'playground');`
|
||||
|
||||
No result/null assertion changed; scope arguments carry the explicit opt-in.
|
||||
|
||||
## Dewey dewey-0941 static review — round2
|
||||
|
||||
Static scope fc05bcbc3..47db04eba CLEAN; not a final verdict or test evidence.
|
||||
M1-M9 accepted as the final set. Optional M10 not added to this gate.
|
||||
N2 taken: keep the existing canon-path supporting-scope mismatch assertion,
|
||||
and ADD get(Book One/Support/Notes/Helper.md,scope:supporting) -> null under
|
||||
the default mapping with no supporting prefix. The supporting-positive test
|
||||
already returns this same path under withSupporting, so the added probe
|
||||
pins the missing-prefix case directly. No assertion removed or weakened;
|
||||
mos1330's separate migration commit and its retained assertions unchanged.
|
||||
|
||||
N1 proposed DEFERRED/follow-up for Sage: retain each tree oid in reader/index
|
||||
entries so get can return blob without cat-file/whole ls-tree -r -l per page.
|
||||
Current get's result is pinned correctly, but per-page cost grows with the
|
||||
library. No performance measurement yet. This optimization needs reader/index
|
||||
ownership and separate acceptance/performance evidence; do not silently expand
|
||||
284's manifest or change its served-snapshot contract for it.
|
||||
|
||||
No Node, tests, mutants or syntax checks run in this round; hold still applies.
|
||||
@@ -1,8 +0,0 @@
|
||||
# Fethry
|
||||
|
||||
Builder seat, Codex gpt-6.1-sol, started by Mos under Q245 (mos-1506). Owns row 225 (C2-10b, handed over from Morgana).
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread (thread 14f80e3c) and works in its own worktree. It has no
|
||||
credentials of its own; it pushes only its own `fethry/*` branches through the repository credential helper (mos-0981),
|
||||
never `refactor`, `main` or another seat's branch. Claude seats orchestrate and review (Q245). Registered by Sage on
|
||||
2026-10-23 so the queue can name the seat as row owner (store.mjs isSeatDir).
|
||||
@@ -1,331 +0,0 @@
|
||||
# Row 226 build notes, C2-13b install script and library precheck (#1727)
|
||||
|
||||
Brief: docs/plans/2026-10-20_case2-rows.md "C2-13b install script and library precheck" (:1073-1134 at
|
||||
d37210288). Design: agents/gosalyn/work/case2/DESIGN.md r11 sections 4 and 10, on gosalyn/case2-design
|
||||
75fb3e10e. Row 195's needs: agents/darkwing/work/queue-195/DESIGN.md "What row 195 needs from row 226".
|
||||
|
||||
This is design round d2. Nothing is built yet. d1 was f46e66883, and Webby's review of it is webby-0085
|
||||
(CHANGES, B1 and N1-N6) and webby-0086 (the ordering problem). Sage ruled on both in sage-2189. The
|
||||
changes from d1 are listed at the end.
|
||||
|
||||
## Base
|
||||
|
||||
Branch gosalyn/c2-13b from refactor d37210288. Rows 215, 221, 222 and 230 are done.
|
||||
|
||||
## Facts the design rests on
|
||||
|
||||
- No CLI writes a business file. `mosaic business validate <id>` checks one and exits 0 ok, 2 invalid
|
||||
(packages/business/src/cli.mjs). A business file needs `human`, `arbiters`, `projects`, `tracker`
|
||||
with a sync bot and a Vikunja credential ref, and `roles`, each role with its tracker bot when its
|
||||
definition uses Vikunja (business.mjs:23, :84-100, :131-149).
|
||||
- The library is a project, `projects.<id>.kind === "library"`, checked by `checkMapping`
|
||||
(packages/library/src/mapping.mjs). There is no top-level `library` key.
|
||||
- `library.sync` and `library.status` are bus verbs, human only (broker.mjs:92, :2452-2476). The only
|
||||
client is `packages/bus/src/human-cli.mjs` over the bus socket.
|
||||
- The bus makes each library mirror at start, `ensureMirror({ business, project, dataRoot })`
|
||||
(packages/bus/src/runtime.mjs:143-145). Before the bus first starts there is no mirror.
|
||||
- `sync()` fetches outside `withLibraryLock`. Only a per-process `running` set keeps two syncs apart
|
||||
(packages/library/src/sync.mjs:97-98), so a second process calling `sync()` would race the bus's
|
||||
timer.
|
||||
- `libraryStatus(context)` reads the recorded state under the flock and writes nothing
|
||||
(mirror.mjs:232-239). A missing or unsafe mirror fails `library-git` (`ownedRoot`, mirror.mjs:43-49).
|
||||
A mirror that has never synced returns the empty state, `lastFetch: null`, `lastError: null`,
|
||||
`problems: []` (mirror.mjs:27, :205-210). `lastError` is `{ code, at }` with `code` matching
|
||||
`^[a-z-]{1,64}$` (mirror.mjs:225).
|
||||
- The bus builds the library context as `{ business, project, dataRoot }` and the slot as
|
||||
`<slotDir>/secrets/<business.id>/library-gitea` (runtime.mjs:146-151, :162-164).
|
||||
- `case2-persona-import.mjs install --staging DIR --config-dir DIR --business ID` writes
|
||||
`<configDir>/personas/<business>/<instance>.md` at 0600 in 0700 dirs, never overwriting, and prints
|
||||
`instance bean: persona.name Bean, persona.sha256 <hex>` per persona.
|
||||
- A Discord binding is `<dataRoot>/discord/<name>.json` (packages/discord/src/binding.mjs:70-83).
|
||||
- `case2-unit.sh --copy ID` needs `<home>/.local/share/mosaic-stack/copies/<ID>`, which
|
||||
`stack-copy.sh copy COMMIT --copies DIR` makes. The copy runs `npm ci` from package-lock.json
|
||||
(stack-copy.sh:70), which installs the pinned pi.
|
||||
|
||||
## Design
|
||||
|
||||
### D1. The business file is an input
|
||||
|
||||
Unchanged from d1. `case2-install.sh` takes `--business-file FILE`, a JSON business document written for
|
||||
the host from Jason's go. The script doesn't build one from flags, because bots, botIds and credential
|
||||
refs are host values and the brief says every host value is an input. It checks the input, adds what
|
||||
only it knows, writes it, and then runs `mosaic business validate`, so business.mjs stays the only
|
||||
schema.
|
||||
|
||||
What the script checks before it writes anything, beyond `validate`:
|
||||
- `id` equals `--business`.
|
||||
- `library`: at least one library project, `bus.tester` not true, roles `bean` and `chet` with
|
||||
definition `companion`, and no `persona.*` var in the input, since D3 adds them.
|
||||
- `factory`: no library project, and `bus.tester` true. These are the precheck's rules at
|
||||
case2-precheck.sh:127-135, so the install refuses what the precheck would refuse.
|
||||
|
||||
### D2. Install steps, in order
|
||||
|
||||
1. Refuse to run as root.
|
||||
2. `scripts/bootstrap.sh`. It's idempotent and never rewrites an existing config.
|
||||
3. `scripts/stack-copy.sh copy <--commit> --copies <home>/.local/share/mosaic-stack/copies`. The copy
|
||||
`case2-unit.sh --copy` names has to exist before root renders the units (row 195 need 3).
|
||||
4. `library` only: the persona import's `install` step from `--personas DIR` (D3).
|
||||
5. Write `<configDir>/businesses/<id>.json` at 0600 with O_EXCL (D4).
|
||||
6. `library` only: make `<configDir>/secrets/<business>/` at 0700, empty. The token is Jason's, design 10
|
||||
step 4.
|
||||
7. `mosaic business validate <id>`. If it refuses and step 5 created the file in this run, remove that
|
||||
file, then refuse. A file that already matched on a re-run (D4) is never removed (N4).
|
||||
8. Print the copy id and the next steps: root's 2c, Jason's 2d, 3 and 4, then the `--library-status`
|
||||
check (D6b).
|
||||
|
||||
d1's step 3, `release.sh package` and `activate`, is gone (Q4a, webby-0085 B1, sage-2189). Package is a
|
||||
`docker compose build`, and activate runs a pi Compose health check that mounts `PI_AUTH_FILE`
|
||||
(compose.yaml:43). Neither fits 2a, which has no Docker and no pi credential, and the hub doesn't run
|
||||
containers. The pinned harness comes from the copy's `npm ci` (Q3). The `CASE2_RELEASE` seam goes with
|
||||
it.
|
||||
|
||||
It starts no unit, writes no unit file, doesn't write `/etc/mosaic-stack/dev.args` and doesn't run
|
||||
`adopt`. These are my answers to row 195's needs 4 and 5 (gosalyn-0087), unchanged. Row 195's W8 now
|
||||
passes `--business-file` and has no release step.
|
||||
|
||||
### D3. Personas come from the staging dir
|
||||
|
||||
Unchanged from d1. `library` requires `--personas DIR`, the staging dir from `case2-persona-import.mjs
|
||||
stage`. The install runs `case2-persona-import.mjs install --staging DIR --config-dir <configDir>
|
||||
--business <id>` and reads its two `instance` lines into `roles.bean.vars` and `roles.chet.vars`. Only
|
||||
those two lines go into the business file. They hold a name and a digest, never text.
|
||||
|
||||
This moves the persona import's stage part of design 10 step 6 ahead of 2b: the read of her Hermes
|
||||
profiles and Jason's check of the printed pairing. Webby agreed in webby-0085 (Q1).
|
||||
|
||||
### D4. Re-runs
|
||||
|
||||
Unchanged from d1, plus N4. Each step is safe to run again. bootstrap and the copy are idempotent. An
|
||||
existing business file that matches the one the script would write passes, and the script remembers it
|
||||
didn't create it. One that differs refuses `business-file-differs`, and the script doesn't touch it.
|
||||
The persona import already refuses to overwrite. On a re-run the install compares each existing
|
||||
persona file's sha256 with the staging manifest, and refuses on a mismatch.
|
||||
|
||||
### D5. Inputs and seams
|
||||
|
||||
```
|
||||
case2-install.sh --business ID --business-file FILE --commit SHA [--profile factory|library]
|
||||
[--personas DIR] [--home DIR]
|
||||
```
|
||||
|
||||
`--home` defaults to `$HOME`, which is `/srv/mosaic` under `sudo -u mosaic -H`. `MOSAIC_CONFIG` is
|
||||
honoured as bootstrap.sh honours it. One test seam, in case2-unit.sh's `CASE2_*` pattern:
|
||||
`CASE2_STACK_COPY` is the copy script, and the test uses a stub that makes the copy dir.
|
||||
|
||||
Exit codes: 0 ok, 1 refused, 4 usage, as case2-unit.sh uses.
|
||||
|
||||
### D6. Precheck library checks, split in two (Q4b, sage-2189)
|
||||
|
||||
d1 ran `sync()` from the precheck. Webby's N1 and webby-0086 showed two faults. The precheck runs before
|
||||
the units start, so there is no mirror yet (runtime.mjs:143-145). And a precheck `sync()` after the bus
|
||||
is up would race the bus's timer, since `sync()` fetches outside the lock (sync.mjs:97-98). Sage split
|
||||
the check in sage-2189, and the brief's "runs library.sync" becomes "reads the bus's recorded sync
|
||||
state".
|
||||
|
||||
#### D6a. Before the units start, `--profile library`
|
||||
|
||||
These run where the precheck runs today, after 2c's render and before the start. They sync nothing and
|
||||
read no mirror. Each is a `refuse` line in the script's style.
|
||||
- The business file is owned by the uid, with no group or other bit (`business-file-mode`).
|
||||
`businesses/.history/<id>/` is 0700 and its `changes.jsonl` is 0600, when present (`history-mode`).
|
||||
- The persona dirs are 0700. Each creative role's persona file is regular, owned and 0600
|
||||
(`persona-mode`), and its sha256 equals the role's `persona.sha256` (`persona-mismatch`). The output
|
||||
prints names and digests, never text.
|
||||
- No `<dataRoot>/discord/*.json` exists (`discord-binding`).
|
||||
|
||||
#### D6b. `case2-precheck.sh --business ID --library-status`, after the bus is up and after step 4
|
||||
|
||||
A new mode, run as `<user>` once the bus is up and Jason has placed the token. It changes nothing. A
|
||||
node block builds the context the bus builds, `{ business, project, dataRoot }` for each library
|
||||
project, with `dataRoot` from the config (N3, runtime.mjs:146-151, :162-164). For each one it:
|
||||
- refuses `library-mirror-missing` when the mirror path doesn't exist. It checks this itself, so the
|
||||
message says the bus hasn't made it, not `library-git`;
|
||||
- calls `libraryStatus(context)` only, and refuses with the thrown code when that fails, for example
|
||||
`library-git` for an unsafe mirror or `library-sync-busy` when the flock wait runs out;
|
||||
- refuses `library-never-synced` when `lastFetch` is null;
|
||||
- refuses `library-last-error <code>` when `lastError` is not null. The code is the fixed
|
||||
`^[a-z-]{1,64}$` string, so it's safe to print. This also covers Webby's N2;
|
||||
- prints each recorded R-problem as `problem R<n> <path>`, with the path through `JSON.stringify`, then
|
||||
one `problems <count>` line (N5). Her file names are the only part that prints, never text.
|
||||
R-problems don't refuse (Q2, agreed by Webby).
|
||||
|
||||
The bus syncs each library with a `user` every 60 s, so the first run after step 4 may need a minute,
|
||||
or Jason's own `library.sync`. The run sheet says so.
|
||||
|
||||
### D7. host-run-sheet.md fix
|
||||
|
||||
`docs/guides/host-run-sheet.md:240` says "I2's install writes it" about `dev.args`. It becomes: root
|
||||
writes it at the unit render, from the values the go names, and the install never writes it, because
|
||||
`<user>` can't write `/etc` and root reads nothing `<user>` wrote. Sage asked in sage-1832 for this fix
|
||||
to go in this build commit, with its path in the manifest. Section 9's `library-gitea` line (:275) says
|
||||
"C2-13b's `library.sync` (not landed)". It becomes the `--library-status` check (D6b). The install
|
||||
section also gets a line that the copy's `npm ci` needs the npm registry from the host (Q3, webby-0085).
|
||||
|
||||
### D8. Design 10 text
|
||||
|
||||
Sage ruled that design 10 gets the same change when 226 lands, with the line in this row's manifest.
|
||||
In agents/gosalyn/work/case2/DESIGN.md:
|
||||
- step 2b drops `release.sh package` and `activate`;
|
||||
- a new step after step 4 runs `case2-precheck.sh --business <business> --library-status`;
|
||||
- the precheck's library text says it reads the bus's recorded sync state, not that it runs
|
||||
`library.sync`.
|
||||
|
||||
### D9. Tests, `packages/cli/tests/case2-install.test.mjs`
|
||||
|
||||
These follow case2-unit.test.mjs: scratch HOME, MOSAIC_CONFIG, stub bin dir and seams, a local bare repo
|
||||
cloned `--bare --no-local` as sync.test.mjs does, and the fixture's key never in output.
|
||||
- Install `library`:
|
||||
- writes the business file at 0600 with bean's and chet's persona vars;
|
||||
- writes the persona files at 0600 in 0700 dirs and the empty slot dir at 0700;
|
||||
- logs the copy call and no release call;
|
||||
- starts nothing;
|
||||
- passes on a re-run;
|
||||
- refuses `business-file-differs` and leaves the file unchanged.
|
||||
- Install `factory`:
|
||||
- writes no library mapping, no persona file and no slot dir, which is the brief's case;
|
||||
- refuses a library project and `bus.tester` off.
|
||||
- Install refuses: run as root, a business id mismatch, missing `--personas` under library, and a
|
||||
`validate` refusal, after which no business file is left.
|
||||
- N4: a re-run where the matching business file is already there and `validate` refuses leaves that
|
||||
file in place, byte for byte.
|
||||
- D6a refusals, one case each: a persona file at 0644, a digest mismatch, a business file at 0644, a
|
||||
`changes.jsonl` at 0644, and a Discord binding. A clean library host passes D6a with no mirror.
|
||||
- D6b, with the mirror made by `ensureMirror` and the state written by a real `sync()` against the local
|
||||
bare repo, as the bus would:
|
||||
- no mirror refuses `library-mirror-missing`;
|
||||
- a fresh mirror refuses `library-never-synced`;
|
||||
- a sync against a remote that doesn't resolve refuses `library-last-error` with its code;
|
||||
- a synced bare repo with one R2 path prints its `problem` line and `problems 1`, and passes.
|
||||
|
||||
Every new refusal gets a mutant that removes it. The count goes in the slot request.
|
||||
|
||||
### D10. Files
|
||||
|
||||
Owned by the brief: `scripts/case2-install.sh` (new), `scripts/case2-precheck.sh` (library checks and
|
||||
`--library-status` only) and `packages/cli/tests/case2-install.test.mjs` (new). Also
|
||||
`docs/guides/host-run-sheet.md` (D7, sage-1832), agents/gosalyn/work/case2/DESIGN.md (D8, sage-2189)
|
||||
and this BUILD.md. Before the slot request I'll grep for outside readers of case2-precheck.sh, since
|
||||
case2-unit.test.mjs runs it.
|
||||
|
||||
## Questions
|
||||
|
||||
All three d1 questions are settled:
|
||||
- Q1, persona stage before 2b: Webby agreed (webby-0085).
|
||||
- Q2, R-problems don't refuse: Webby agreed.
|
||||
- Q3, the pinned harness is the copy's `npm ci`: Webby agreed, and asked for the registry note in D7.
|
||||
- Q4a and Q4b: Sage, sage-2189.
|
||||
|
||||
One choice for Webby to check: D6b's `problem` line carries the path through `JSON.stringify`. N5 asked
|
||||
for a prefix and a count. I kept the path because Jason needs it to tell her which file to fix, and the
|
||||
names are hers, not text.
|
||||
|
||||
## Changes from d1
|
||||
|
||||
- B1: release package and activate dropped, with the `CASE2_RELEASE` seam and its test (D2, D5).
|
||||
- N1 and webby-0086: the sync is gone from the precheck. D6 splits into D6a before the start and D6b,
|
||||
`--library-status`, after the bus is up and step 4 (sage-2189).
|
||||
- N2: covered by D6b's `lastError` refusal.
|
||||
- N3: D6b builds the context and slot as the bus does.
|
||||
- N4: step 7 removes only a file this run created, with a test.
|
||||
- N5: `problem` prefix and a `problems` count.
|
||||
- N6: the run sheet lines are :240 and :275.
|
||||
- New D8, the design 10 text change, and its path in D10.
|
||||
|
||||
## Build
|
||||
|
||||
Webby approved d2 in webby-0087 (REVIEW-design-d2.md, webby/reviews 2f488b4ea). Nothing above this
|
||||
section changed after approval. This section records the build.
|
||||
|
||||
### D8 lands on case2-design
|
||||
|
||||
Sage ruled in sage-2195, after Webby's N1, that D8 goes on gosalyn/case2-design, because
|
||||
agents/gosalyn/work/case2/DESIGN.md isn't on refactor. The change is commit 624212842 there, pushed:
|
||||
step 2b drops `release.sh package` and `activate`, new step 4a runs `--library-status`, and the C2-13b
|
||||
row text says the precheck reads the bus's recorded sync state. DESIGN.md is not in this row's manifest.
|
||||
|
||||
The brief's lines docs/plans/2026-10-20_case2-rows.md:1093-1096 still name release package, activate and
|
||||
a library.sync fetch. Sage owns the brief and will amend those lines to match D8 in a doc-only commit
|
||||
after the re-pin window (sage-2205). They are not in this row's manifest.
|
||||
|
||||
### Webby's d2 notes
|
||||
|
||||
- N2: the `problem` line keeps the path. After `JSON.stringify` the precheck also escapes DEL, the C1
|
||||
controls U+0080-009F, U+200E and U+200F, U+2028 and U+2029, and the bidi controls U+202A-202E and
|
||||
U+2066-2069 as `\uXXXX`. Webby named C1 and bidi. I added the rest because `JSON.stringify` leaves
|
||||
them raw too, and each one can change how a terminal shows the line. The test path holds U+202E and
|
||||
U+0085, and the test checks that neither prints raw.
|
||||
- N3: `lastError` is checked before `lastFetch`. A first sync with no token records
|
||||
`library-credential-missing` and leaves `lastFetch` null, so it refuses `library-last-error`, not
|
||||
`library-never-synced`. The fresh-mirror test has `lastError` null and refuses `library-never-synced`.
|
||||
|
||||
### Choices made in the build
|
||||
|
||||
- The config dir. The persona import refuses a config dir with group or other bits, and bootstrap makes
|
||||
one with the default mode. So the install makes the config dir at 0700 when it's absent, before
|
||||
bootstrap, and refuses `config-dir-mode` when it exists with a group or other bit. It checks
|
||||
`secrets/` and `secrets/<id>/` the same way under library (`secrets-mode`, `slot-mode`).
|
||||
- `--home` must equal `$HOME` (`home-mismatch`). Every path comes from `$HOME` and `MOSAIC_CONFIG`, as
|
||||
bootstrap's does, so a different `--home` would only name a second home the script doesn't use.
|
||||
- A plan step runs before anything is written. It reads the input and the staging manifest, computes
|
||||
the persona vars from the manifest, and compares the result with what's on disk. It refuses every
|
||||
input rule, `business-file-differs` and `persona-differs` there, so a refused run writes nothing.
|
||||
After the import the script checks that the import's two `instance` lines carry the manifest's
|
||||
digests.
|
||||
- A re-run with both persona files present and matching skips the import, which would refuse to
|
||||
overwrite them. One matching file and one missing refuses `persona-differs`.
|
||||
- A diverged library prints one line under `--library-status`, naming the tip `library.sync --accept`
|
||||
would take. It doesn't refuse. The design doesn't list it as a refusal, and the bus still serves the
|
||||
old tip.
|
||||
- The precheck's library checks print `ok` lines for each check they pass, as the rest of the script
|
||||
does. A business file that's missing prints no extra line, since the existing business-file check
|
||||
already refuses it.
|
||||
- The business id must match the persona import's rule, `^[a-z0-9][a-z0-9-]{0,63}$`. That's narrower
|
||||
than the precheck's, which also takes `.` and `_`, because the persona dir uses the id.
|
||||
|
||||
### Outside readers
|
||||
|
||||
`git grep` over packages/*/tests/*, packages/*/src/* and scripts/test-*.sh finds case2-precheck.sh read
|
||||
by case2-unit.test.mjs, which runs it, and case1-accept.test.mjs, which names a copy's path but runs a
|
||||
fake. No test reads host-run-sheet.md. No test pins a hash of either script. case2-unit.test.mjs's
|
||||
precheck host has a 0600 business file and no persona vars, so the new D6a checks pass there.
|
||||
|
||||
### Mutants
|
||||
|
||||
24, in /mnt/storage/scratch/gosalyn-r226/mutate.py. Each replaces one exact string once. Line numbers
|
||||
are the candidate's. run.sh stops with BASE RED before any mutant unless the base run is rc=0 and
|
||||
fail 0.
|
||||
|
||||
| Mutant | Line | Check removed |
|
||||
|---|---|---|
|
||||
| I1 | install :73 | run-as-root |
|
||||
| I2 | install :74 | home-mismatch |
|
||||
| I3 | install :81 | config-dir-mode |
|
||||
| I4 | install :101 | business-id-mismatch |
|
||||
| I5 | install :107 | profile-library-business |
|
||||
| I6 | install :108 | bus-tester-off |
|
||||
| I7 | install :111 | bus-tester-on |
|
||||
| I8 | install :115 | persona-var-input |
|
||||
| I9 | install :158 | business-file-differs |
|
||||
| I10 | install :152 | persona-differs |
|
||||
| I11 | install :218 | N4, removes the file even when this run didn't write it |
|
||||
| I12 | install :218 | validate's removal of the file this run wrote |
|
||||
| P1 | precheck :229 | business-file-mode |
|
||||
| P2 | precheck :235 | history-mode for changes.jsonl |
|
||||
| P3 | precheck :246 | persona-mode for a persona file |
|
||||
| P4 | precheck :251 | persona-mismatch |
|
||||
| P5 | precheck :241 | persona-mode for a persona dir |
|
||||
| P6 | precheck :258 | discord-binding |
|
||||
| P7 | precheck :104 | library-mirror-missing |
|
||||
| P8 | precheck :107 | library-last-error |
|
||||
| P9 | precheck :107 | N3, the order swapped |
|
||||
| P10 | precheck :108 | library-never-synced |
|
||||
| P11 | precheck :92 | N2, the escape after stringify |
|
||||
| P12 | precheck :65 | --library-status refuses --profile |
|
||||
|
||||
`history-mode` for the history dir itself has no mutant and no test. The brief names only the
|
||||
`changes.jsonl` case.
|
||||
|
||||
### Tests
|
||||
|
||||
Not run yet. They wait for a slot.
|
||||
@@ -1,276 +0,0 @@
|
||||
# Row 283 (#1788) C2-01d build notes (Honker)
|
||||
|
||||
Append-only. Design: DESIGN.md d2 16e809457, APPROVE webby-0172. Gate owner Sage, reviewer Webby.
|
||||
|
||||
## 2026-10-23 build on 45dca69f6 (sage-3577)
|
||||
|
||||
Branch honker/row283-build from origin/refactor 45dca69f6e4bce52834587f66f2ff33ba839ab8a. The old branch
|
||||
honker/row283 (16e809457) held only the design, so its two commits were cherry-picked as 398314cf4
|
||||
(d1) and 4f66dccde (d2). The DESIGN.md blob equals 16e809457's.
|
||||
|
||||
Commits:
|
||||
- 331653b38: code, README and tests T1-T8.
|
||||
- 28918824c: the survey.test:73 re-pin, alone (mos-1330 item 4).
|
||||
- this file.
|
||||
|
||||
Nothing has run. No node, test or mutant ran during the build: Darkwing held the slot. Every count
|
||||
below is a plan until Sage's CLEAR.
|
||||
|
||||
### Drift check (sage-3577)
|
||||
|
||||
`git diff --stat 94c33a43c 45dca69f6` (design base to build base) over the paths the tests read or
|
||||
hash-pin:
|
||||
- No change in packages/library, packages/business/src/business.mjs or
|
||||
packages/business/tests/business.test.mjs.
|
||||
- No test hash-pins a library file. bus library.test and library-sync.test import index.mjs, and
|
||||
stack-copy.test only checks that index.mjs exists.
|
||||
- webui library-cli.test changed one line and doesn't read scope. The `classify` in webui reads.mjs is
|
||||
the business one, not the library's.
|
||||
|
||||
I redo the drift check on BASE..refactor before the in-review move.
|
||||
|
||||
### N3 (webby-0169): deepEquals of the mapping output outside packages/library
|
||||
|
||||
`git grep -n playground -- 'packages/*/tests/*' ':!packages/library/*'` and the files naming
|
||||
`universes` give two files:
|
||||
- business.test.mjs:20 compares `validateBusinessDocument(...).projects.books` with
|
||||
`checkMapping(mappingEntry())`. That is output against output, so `supporting: []` shows up on both
|
||||
sides. :27 compares only `.playground`.
|
||||
- library-projects-protected.test.mjs compares file bytes (:31, :32) and change paths (:58), never the
|
||||
mapping output.
|
||||
|
||||
No re-pin is needed outside library. Inside library, only the draft deepEqual at mapping.test:48 gains
|
||||
`supporting: []`, as the design says.
|
||||
|
||||
### Q1 departure (sage-2923) and the :73 re-pin (mos-1330 item 4)
|
||||
|
||||
survey.mjs:65 counts `supporting: 0` and formatSurvey prints it. T8 is the only added test
|
||||
(survey.test "C2-01d: with --mapping the survey counts supporting files").
|
||||
|
||||
mos-1330 item 4, packages/library/tests/survey.test.mjs:73, commit 28918824c:
|
||||
- old: `'scopes canon 5, playground 1, unmapped 3; indexed 6',`
|
||||
- new: `'scopes canon 5, playground 1, supporting 0, unmapped 3; indexed 6',`
|
||||
|
||||
N2: the pin gains only `supporting 0`. No other text on the line or in that test moved.
|
||||
|
||||
### Q2 tradeoff (webby-0169)
|
||||
|
||||
A supporting prefix that holds a playground prefix refuses `library-mapping-invalid`. That goes past
|
||||
the brief, but it is the fail-closed reading, and it uses an existing code: classify checks supporting
|
||||
first, so that playground prefix could never match. The cost is that she can't nest a playground under
|
||||
a supporting folder, though she can nest the other way (a playground holding a supporting folder).
|
||||
|
||||
### Handoffs
|
||||
|
||||
- Q4 (webby-0169, sage-2930): the M12 delete rule ("A delete also needs the path to exist at the tip as
|
||||
a 100644 blob", case2 DESIGN.md :308) is not in supportingPath. It binds row 288 (C2-02) via Gitea
|
||||
#1793 comment 30738, with tests for a missing path and a non-100644 entry.
|
||||
- A save with no `editor` refusing is C2-02's (DESIGN.md section 1).
|
||||
|
||||
### Choices the design left open
|
||||
|
||||
- The book loop is now `againstBooks(owners, list, code)` in mapping.mjs, called once per list. The
|
||||
playground's messages and codes are byte-equal to before.
|
||||
- A non-string path refuses `library-write-story-path` ("the path must be a string") as part of
|
||||
check 1. A non-array `tree` is a TypeError (DESIGN.md section 3).
|
||||
- Check 8 builds the tree's names as every path and every folder above it, keyed by
|
||||
`normalize('NFC').toLowerCase()`. A name on the way refuses if its key holds any name other than
|
||||
itself, so a tree that already holds case twins refuses a write to either (R7).
|
||||
|
||||
### Mutants (exact strings)
|
||||
|
||||
Each applies one replacement to the build tip. Run one at a time, and restore with `git checkout --`
|
||||
on the one file before the next.
|
||||
|
||||
| Mutant | File | Replace | With | Killed by |
|
||||
|---|---|---|---|---|
|
||||
| M1 | src/scope.mjs | `const prefix = entry.supporting.find(p => inside(p, path));` | `const prefix = entry.supporting.find(p => inside(p, path) && !entry.playground.some(q => inside(q, path)));` | scope.test "supporting comes before playground", supporting.test "passes at book level" (both-lists path) |
|
||||
| M2 | src/mapping.mjs | `if ((inside \|\| holding) && owner !== id)` | `if ((inside \|\| holding) && owner !== id && list !== 'supporting')` | mapping.test "each supporting prefix rule", business.test C2-04 |
|
||||
| M3 | src/supporting.mjs | `for (const p of own) if (LINKS.has(modes.get(p)))` | `for (const p of own) if (false && LINKS.has(modes.get(p)))` | supporting.test "symlink or gitlink" |
|
||||
| M4 | src/supporting.mjs | `const fold = name => name.normalize('NFC').toLowerCase();` | `const fold = name => name.normalize('NFC');` | supporting.test "case or Unicode form" |
|
||||
| M4b | src/supporting.mjs | `const fold = name => name.normalize('NFC').toLowerCase();` | `const fold = name => name.toLowerCase();` | supporting.test "case or Unicode form" (NFD case) |
|
||||
| M5 | src/supporting.mjs | `Buffer.byteLength(path, 'utf8') > MAX_BYTES` | `path.length > MAX_BYTES` | supporting.test "1025 bytes" |
|
||||
| M6 | src/mapping.mjs | `const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f<>]/;` | `const IDENT_BAD = /[<>]/;` | mapping.test "editor is optional" |
|
||||
| M6b | src/mapping.mjs | `const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f<>]/;` | `const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f]/;` | mapping.test "editor is optional" |
|
||||
| M7 | src/supporting.mjs | `if (place.scope !== 'supporting' \|\| place.universe !== universe)` | `if (place.scope !== 'supporting')` | supporting.test "outside the universe's supporting folders" |
|
||||
| M8 | src/mapping.mjs | `if (sid === pid && s !== p && holds(s, p))` | `if (false && sid === pid && s !== p && holds(s, p))` | mapping.test "each supporting prefix rule" (Workshop case) |
|
||||
|
||||
(The `\|` in the table is an escaped `|` in the source text.) Each mutant runs the one test file named,
|
||||
plus business.test for M2, under `timeout -k 10 600`. A mutant passes the gate only when its run is red
|
||||
on the named test and the restored file's sha256 equals the build tip's.
|
||||
|
||||
### Gate plan (on Sage's CLEAR)
|
||||
|
||||
1. The touched files whole, each under `timeout -k 10 600`: library mapping, scope, supporting and
|
||||
survey tests, and business.test.mjs. Counts go below.
|
||||
2. M1-M8 as above.
|
||||
3. The full gate, under mos-1275 timeouts: `node --test packages/library/tests/` and
|
||||
`node --test packages/business/tests/` each under `timeout -k 10 1800`, and every scripts/test-*.sh
|
||||
under `timeout -k 10 300`. pipefail and tee, with skips named (test-task Docker groups).
|
||||
4. Before in-review: grep packages/*/tests for a 64-hex pin on any changed file and for the changed
|
||||
message text, then the drift check on BASE..refactor.
|
||||
|
||||
## 2026-10-23 Webby early look (webby-0227, static, not a verdict)
|
||||
|
||||
- E1: business.test.mjs is an existing test file with a 4-line addition after the playground refusal.
|
||||
The in-review request lists it next to survey.test:73.
|
||||
- E3 fixed: supportingPath now runs the string check before `JSON.stringify`, so a BigInt (or any
|
||||
non-string) path refuses `library-write-story-path` instead of throwing a TypeError.
|
||||
supporting.test's string case now covers 7, null, undefined, 1n and an array.
|
||||
- E2 open: `editor` takes surrounding whitespace, and git trims it at commit, so the saved ident and
|
||||
the commit ident can differ. DESIGN.md section 1 says "non-empty after trim". Refusing
|
||||
`value.trim() !== value` would change that approved rule, so it went to Sage instead of into the
|
||||
build.
|
||||
|
||||
## 2026-10-23 E2 departure (sage-3601)
|
||||
|
||||
A departure from DESIGN.md section 1 (which stays unedited). The design says each `editor` value is
|
||||
"non-empty after trim". Sage ruled that surrounding whitespace refuses too, because git trims an
|
||||
ident at commit, so a padded value would save one name and commit another. mapping.mjs now adds
|
||||
`if (value.trim() !== value) bad(`editor.${key} must not start or end with whitespace`)` after the
|
||||
IDENT_BAD check. `trim()` covers Unicode whitespace, so U+00A0 and U+3000 refuse as well. Inner
|
||||
whitespace is kept ("Her Middle Name" passes).
|
||||
|
||||
T3 gains a case per key for leading, trailing and both-ends spaces, U+00A0 and U+3000, plus one inner
|
||||
whitespace pass.
|
||||
|
||||
M6 grows a third part:
|
||||
|
||||
| Mutant | File | Replace | With | Killed by |
|
||||
|---|---|---|---|---|
|
||||
| M6c | src/mapping.mjs | `if (value.trim() !== value) bad(` | `if (false && value.trim() !== value) bad(` | mapping.test "editor is optional" (padded cases) |
|
||||
|
||||
Fixture grep: `git grep -nE "\beditor\b|\bauthor\b" -- 'packages/library/tests/*' 'packages/business/tests/*'`,
|
||||
leaving out mapping.test.mjs, finds nothing. packages/business/tests has no fixtures directory. The
|
||||
library fixture mapping.json has no `editor`. The positive control (`vaultPrefix` in mapping.json)
|
||||
matches once. No fixture maps an editor or author with surrounding whitespace.
|
||||
|
||||
## 2026-10-23 E4 (webby-0230, sage-3606), same departure as E2
|
||||
|
||||
Claimed: `queue move 283 in-progress`, op honker-283-start, rev 3642, 19:10:49Z (briefed→in-progress).
|
||||
|
||||
Git's ident.c `crud()` strips any byte <= 0x20 and `, : ; < > " \ '` from both ends of an ident. I
|
||||
checked that with git 2.55.0: `Jane,` `"Jane"` `'Jane'` `Jane;` `Jane:` `Jane\` and `,Jane` all
|
||||
commit as `Jane`, while `Jane.` and `Ja,ne` are kept. mapping.mjs now takes the set from crud()
|
||||
rather than from that list: `IDENT_CRUD_END = /^[\u0000-\u0020,:;<>"\\']|[\u0000-\u0020,:;<>"\\']$/`.
|
||||
The E2 check became `if (value.trim() !== value || IDENT_CRUD_END.test(value))`, which refuses
|
||||
library-mapping-invalid with "editor.<key> must not start or end with whitespace or any of , : ; " ' \".
|
||||
`trim()` still catches Unicode whitespace (U+00A0, U+3000), which crud() leaves alone.
|
||||
|
||||
Inner characters: IDENT_BAD (`/[\u0000-\u001f\u007f-\u009f<>]/`) already refuses `<`, `>`, newline,
|
||||
tab and every other control anywhere in the value. T3 now pins that at the start, the end and the
|
||||
middle. Inner `, : ; " ' \` are kept, since git keeps them, and T3 pins that too.
|
||||
|
||||
Tests:
|
||||
- T3 has one case per character in [space, U+00A0, U+3000, `,`, `:`, `;`, `"`, `'`, `\`], at the start
|
||||
and at the end, for each key. `<`, `>`, newline and tab are pinned at the start, end and middle.
|
||||
Four accepted names have inner crud characters.
|
||||
- New round-trip test, "an accepted editor round-trips through a real git commit unchanged". It
|
||||
commits in a scratch repo with editor `Doe, Jane Q.` / `[email protected].` as author and
|
||||
committer, with GIT_AUTHOR_* and GIT_COMMITTER_* stripped from the env, and asserts `git log`
|
||||
shows exactly the saved values. I preflighted that commit with git 2.55.0 by hand: both values came
|
||||
back unchanged.
|
||||
|
||||
M6c now replaces the whole end check (this supersedes the earlier M6c row):
|
||||
|
||||
| Mutant | File | Replace | With | Killed by |
|
||||
|---|---|---|---|---|
|
||||
| M6c | src/mapping.mjs | `if (value.trim() !== value \|\| IDENT_CRUD_END.test(value))` | `if (false)` | mapping.test "editor is optional" (end cases) |
|
||||
| M6d | src/mapping.mjs | `if (value.trim() !== value \|\| IDENT_CRUD_END.test(value))` | `if (value.trim() !== value)` | mapping.test "editor is optional" (`,` `:` `;` `"` `'` `\` cases) |
|
||||
|
||||
## 2026-10-23 E5 (webby-0236, not blocking), fixed
|
||||
|
||||
The round-trip test stripped only GIT_AUTHOR_* and GIT_COMMITTER_*. With GIT_DIR, GIT_WORK_TREE or
|
||||
GIT_INDEX_FILE set (inside a hook, say), GIT_DIR beats `-C root` and the commit would land in the
|
||||
outer repo, and the test read global and system config. It now deletes GIT_DIR, GIT_WORK_TREE,
|
||||
GIT_COMMON_DIR and GIT_INDEX_FILE (as queue/tests/helpers.mjs gitEnv does) and sets
|
||||
GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1 (as ledger.test.mjs does). The `-c user.*`
|
||||
ident still applies. Test only; no mutant changes.
|
||||
|
||||
## 2026-10-23 sage-2925: optional editor.user (sage-3649)
|
||||
|
||||
sage-2925 (recorded in ludwig/row286-r2 agents/ludwig/work/queue-286/BUILD.md, Q4) ruled that row 283
|
||||
adds an optional `editor.user`, her Gitea login for the `library-human` slot, which row 288 (C2-02)
|
||||
passes to `humanCredential(context, {user})`. It reached this row after the DESIGN.md d2 approval
|
||||
(ludwig-0068, bubba-0009, sage-3649), so it departs from DESIGN.md section 1 ("exactly the keys
|
||||
`name` and `email`"). DESIGN.md stays unedited.
|
||||
|
||||
- mapping.mjs: `only(entry.editor, ['name', 'email', 'user'], 'editor')`. When present, `user` must be
|
||||
a string matching USER (`/^[A-Za-z0-9_.-]{1,100}$/`, the entry `user` check), else
|
||||
library-mapping-invalid "editor.user must be a plain account name". The output carries `user` only
|
||||
when given. An absent `editor.user` refusing library-human-missing is 288's (bubba-0009).
|
||||
- README: the `editor` line names `user` and the end-character rule from E2/E4.
|
||||
- Tests: T3 (mapping.test "editor is optional") gains one accepted case (`her.login-1`, kept in the
|
||||
output) and refused cases for '', an inner space, '@', 101 characters, 7 and null. They go in
|
||||
mapping.test with the other editor cases, not in the new supporting.test, which tests write paths.
|
||||
- No test outside mapping.test reads `editor` or pins mapping.mjs or README (git grep).
|
||||
|
||||
| Mutant | File | Replace | With | Killed by |
|
||||
|---|---|---|---|---|
|
||||
| M6e | src/mapping.mjs | `if (Object.hasOwn(entry.editor, 'user') && (typeof entry.editor.user !== 'string' \|\| !USER.test(entry.editor.user)))` | `if (false)` | mapping.test "editor is optional" (editor.user refusals) |
|
||||
| M6f | src/mapping.mjs | `...(Object.hasOwn(entry.editor, 'user') ? { user: entry.editor.user } : {})` | `...({})` | mapping.test "editor is optional" (`her.login-1` kept) |
|
||||
|
||||
## 2026-10-23 T2 run (sage-3695 CLEAR, lane A, library and business only)
|
||||
|
||||
Node v26.8.1. refactor was e2b873390 (226 landed); `git diff --stat 45dca69f6 e2b873390 -- packages/library
|
||||
packages/business` is empty. TMPDIR was a mktemp -d under /mnt/storage/scratch/tmp, with pipefail.
|
||||
Singles and mutants ran under `timeout -k 10 600`, suites under `timeout -k 10 1800`.
|
||||
|
||||
First-run red (mos-1272, placed): the first base run on 46bad7ea3 at 20:01:09Z was red in
|
||||
supporting.test "malformed paths refuse by class, in order". 5 of 6 passed, with `TypeError: Do not know
|
||||
how to serialize a BigInt`. The cause was the test helper, not the product: `refuses()` labelled each
|
||||
assertion with `JSON.stringify(path)`, and the 1n case added for E3 can't be stringified. In c18ad3e12
|
||||
the label uses `util.inspect(path)`, a 2-line test-only change. The product's 1n refusal was never in
|
||||
question: supportingPath type-checks before it stringifies (E3). The whole run below is on c18ad3e12.
|
||||
|
||||
Base, the touched files whole (20:01:43Z), all rc=0:
|
||||
- library mapping.test 16/16, scope.test 6/6, supporting.test 6/6, survey.test 7/7;
|
||||
- business business.test 21/21;
|
||||
- 0 skipped, 0 cancelled, 0 todo.
|
||||
|
||||
Mutants (20:01:44Z). Each was applied by exact string with count 1, then restored with `git checkout --`.
|
||||
Every restored sha256 equals HEAD's blob, and the tree was clean after.
|
||||
|
||||
| Mutant | Run | Result | Failing test |
|
||||
|---|---|---|---|
|
||||
| M1 | scope.test | rc=1, 5/6 | supporting comes before playground; a folder in both lists is supporting… |
|
||||
| M1 | supporting.test | rc=1, 5/6 | a path passes at book level, universe level, in a draft and in a folder in both lists |
|
||||
| M2 | mapping.test | rc=1, 15/16 | each supporting prefix rule refuses with its code |
|
||||
| M2 | business.test | rc=1, 20/21 | C2-04: library mappings load without roots, remain frozen, and retain mapping refusals |
|
||||
| M3 | supporting.test | rc=1, 5/6 | a symlink or gitlink on the way, or at the path itself, refuses |
|
||||
| M4 | supporting.test | rc=1, 5/6 | a name that differs from a tree name only in case or Unicode form refuses… |
|
||||
| M4b | supporting.test | rc=1, 5/6 | the same case/Unicode test |
|
||||
| M5 | supporting.test | rc=1, 5/6 | a path of exactly 1024 bytes passes, and 1025 bytes… refuses |
|
||||
| M6 | mapping.test | rc=1, 15/16 | editor is optional, kept when given, and refuses anything but a plain name and email |
|
||||
| M6b | mapping.test | rc=1, 15/16 | the same editor test |
|
||||
| M6c | mapping.test | rc=1, 15/16 | the same editor test |
|
||||
| M6d | mapping.test | rc=1, 15/16 | the same editor test |
|
||||
| M6e | mapping.test | rc=1, 15/16 | the same editor test |
|
||||
| M6f | mapping.test | rc=1, 15/16 | the same editor test |
|
||||
| M7 | supporting.test | rc=1, 5/6 | a path outside the universe's supporting folders refuses |
|
||||
| M8 | mapping.test | rc=1, 15/16 | each supporting prefix rule refuses with its code |
|
||||
|
||||
14 of 14 mutants killed, each on its named test only.
|
||||
|
||||
Suites (20:01:47Z):
|
||||
- `packages/library`: `node --test tests/*.test.mjs` rc=0, 98/98, 0 skipped.
|
||||
- `packages/business`: `node --test tests/` rc=0, 231/231, 0 skipped.
|
||||
|
||||
Not run here: every scripts/test-*.sh and the other packages. Those are Sage's 283 gate, after Webby's
|
||||
APPROVE.
|
||||
|
||||
## 2026-10-23 G1: mos-1528 OK for the mapping.test draft deepEqual (sage-3713)
|
||||
|
||||
This entry completes the N3 line above (BUILD.md:42, "only the draft deepEqual at mapping.test:48 gains
|
||||
`supporting: []`"). BUILD.md is append-only, so :42 stays as written. Mos OK'd the change of that old
|
||||
assertion in mos-1528. It is now at packages/library/tests/mapping.test.mjs:52, and commit 331653b38
|
||||
changed it. Everything else in that commit's hunk is new test lines, not changed old ones.
|
||||
|
||||
- Old, mapping.test.mjs:48 at 45dca69f6:
|
||||
`assert.deepEqual(m.universes.idea, { kind: 'draft', name: 'New World', books: [], playground: ['Drafts/New World/'] });`
|
||||
- New, mapping.test.mjs:52 at b2a1f4092:
|
||||
`assert.deepEqual(m.universes.idea, { kind: 'draft', name: 'New World', books: [], playground: ['Drafts/New World/'], supporting: [] });`
|
||||
|
||||
The line gains only `, supporting: []`; nothing else in it moved. The business.test and scope.test hunks
|
||||
add tests only (sage-3713: no OK needed). The other old-test change in this row is survey.test:73
|
||||
(mos-1330 item 4, 28918824c). Doc only.
|
||||
@@ -1,191 +0,0 @@
|
||||
# Row 283 (#1788) C2-01d design, draft 2 (Honker)
|
||||
|
||||
Brief: docs/plans/2026-10-20_case2-rows.md "C2-01d" at refactor 94c33a43c. Sources: the design at
|
||||
agents/gosalyn/work/case2/DESIGN.md b68d79663, the ruling after r11 (:207), the M12 paragraph (:300),
|
||||
1.1 (:755) and 1.2 (:817). Base 94c33a43c. Reviewer Webby, gate owner Sage. Nothing is built yet. The
|
||||
build waits for 282 to land (sage-2906).
|
||||
|
||||
## 1. Mapping (packages/library/src/mapping.mjs)
|
||||
|
||||
`supporting`:
|
||||
- Every universe kind takes an optional `supporting` list: plain `['name','books','playground','supporting']`,
|
||||
hybrid adds it to its seven keys, and a draft gets `['kind','name','playground','supporting']`.
|
||||
- Each value is checked as a playground prefix is: a string ending in `/` whose body passes `folder()`.
|
||||
- The output carries `supporting` on every universe, `[]` when absent, the way a draft carries
|
||||
`books: []`. classify then needs no `?? []`. The business writer writes the raw document
|
||||
(organization.mjs:195), not this output, so no business file changes shape.
|
||||
- The prefix rules, across every entry (ruling after r11):
|
||||
- A supporting prefix holding another supporting prefix, or one listed twice, refuses
|
||||
`library-mapping-invalid`.
|
||||
- A prefix equal to one of its own entry's book folders, or holding one, refuses `library-mapping-invalid`.
|
||||
- A prefix inside or holding another entry's book folder refuses `library-supporting-other-universe`.
|
||||
- Level comes from where the prefix sits, as for the playground: inside one of its own entry's books
|
||||
is book level, and outside every book is universe level. A draft's prefixes are all universe level.
|
||||
- Between the two lists (my choice, Q2):
|
||||
- A supporting prefix may equal a playground prefix of the same entry. That folder is writable.
|
||||
- A playground prefix may hold a supporting prefix of the same entry. The inner folder is supporting
|
||||
and the rest stays playground.
|
||||
- A supporting prefix that holds a playground prefix refuses `library-mapping-invalid`. classify
|
||||
checks supporting first, so that playground prefix could never match.
|
||||
- A supporting prefix equal to, holding or inside a playground prefix of another entry refuses
|
||||
`library-mapping-invalid`. Otherwise one path would have two universes.
|
||||
- The book loop at mapping.mjs:128-140 becomes a helper that both lists call with their own code and
|
||||
label. The playground's messages and codes stay byte-equal, so no existing test text moves.
|
||||
|
||||
`editor`:
|
||||
- The entry takes an optional `editor`. When present, it is an object with exactly the keys `name` and
|
||||
`email` (via `only()`). Each value is a string that is non-empty after trim and has no control
|
||||
character (`[\u0000-\u001f\u007f-\u009f]`), `<` or `>` (webby-0169 R1: git drops them from an ident
|
||||
and commits under a different name, so C2-02 would never see a failure). Anything else refuses
|
||||
`library-mapping-invalid`.
|
||||
- The output carries `editor: { name, email }` only when present, as `user` and `vaultPrefix` do. A save
|
||||
with no `editor` refusing is C2-02's job.
|
||||
|
||||
## 2. Scope (scope.mjs)
|
||||
|
||||
- `classify` gets a supporting loop before the playground loop (design 1.2). It returns `place(...)`
|
||||
with `{ scope: 'supporting', universe, book }`. The book is the book folder holding the prefix, or
|
||||
null at universe level and in a draft. The type and folder come from the first folder under the
|
||||
prefix, as for the playground.
|
||||
- The `.md` check stays first, so a `.canvas` or `.base` under a supporting prefix stays unmapped.
|
||||
- The comment above `classify` names the three prefix scopes and the order.
|
||||
|
||||
## 3. supportingPath (new supporting.mjs)
|
||||
|
||||
`supportingPath(mapping, universe, path, tree)` returns `path` or throws
|
||||
`LibraryError('library-write-story-path', ...)`. It is pure and reads no git.
|
||||
|
||||
- `tree` is the array `listTree` returns (reader.mjs:26, `{ mode, type, oid, size, path }`), so the
|
||||
C2-02 broker passes its listing as it is (Q3). A non-array `tree` throws a TypeError, because that is
|
||||
a caller bug, not a path class.
|
||||
- The checks run in this order, each with its own message naming the class:
|
||||
1. The path must be a string of at most 1,024 UTF-8 bytes (`Buffer.byteLength`).
|
||||
2. It must have no control character, using the same control class as `editor` (not `<` or `>`).
|
||||
3. It must have no backslash.
|
||||
4. It must not be absolute (a leading `/`).
|
||||
5. It must have no empty, `.` or `..` segment. That also catches `//` and a trailing `/`.
|
||||
6. `classify(mapping, path)` must give scope `supporting` and that same `universe`. This one check
|
||||
covers an unknown universe, an unmapped path, a `.canvas`, `.base` or attachment, a canon or
|
||||
story path, a playground path that isn't also supporting, and another universe's prefix. The scope
|
||||
rule then has one source, and M1 below hits both.
|
||||
7. Every proper prefix of the path (`a`, `a/b`, ...) and the path itself must not be in `tree` with
|
||||
mode `120000` (symlink) or `160000` (gitlink).
|
||||
8. No prefix of the path, and not the path itself, may equal a tree path or a tree directory
|
||||
(derived from the entries' paths) after `normalize('NFC').toLowerCase()` on both sides, unless
|
||||
it equals that tree path exactly. NFC as well as case, because APFS treats NFC and NFD forms of
|
||||
a name as one file (webby-0169 N1).
|
||||
Directories are checked too, because the folders under a supporting prefix are hers to name. A
|
||||
path in a tree that holds both `a.md` and `A.md` refuses, which matches R7.
|
||||
- The refusal messages quote the path with JSON, except for the control-character class, whose message
|
||||
leaves the path out.
|
||||
- The delete rule ("A delete also needs the path to exist at the tip as a 100644 blob", M12) isn't in
|
||||
the brief's supportingPath list. It stays with C2-02's delete (Q4).
|
||||
The C2-02 brief doesn't name it (only design :308), so BUILD.md carries it to C2-02 under Handoffs
|
||||
(webby-0169 Q4).
|
||||
- index.mjs exports `supportingPath`.
|
||||
|
||||
## 4. README (packages/library/README.md)
|
||||
|
||||
- Mapping: `supporting` and its rules, the cross-list rules, and `editor`.
|
||||
- Refusals: a `library-supporting-other-universe` row, and the new cases in the
|
||||
`library-mapping-invalid` row.
|
||||
- Scope: supporting first, then playground, then canon.
|
||||
- A new section, "Agent write paths", for supportingPath and its classes.
|
||||
|
||||
## 5. Callers of classify outside files owned (Q1 for Sage)
|
||||
|
||||
- survey.mjs:65 builds `{ canon: 0, playground: 0, unmapped: 0 }` and increments
|
||||
`scopes[classify(...).scope]`. A supporting file adds `supporting: NaN`, and formatSurvey (:92) never
|
||||
prints it. I propose a departure: add `supporting: 0` and print it, so the line becomes
|
||||
`scopes canon 5, playground 1, supporting 0, unmapped 3; indexed 6`. That re-pins survey.test:73 and
|
||||
adds a count test. Without it, the survey reports garbage once she marks a folder. The departure
|
||||
covers two files, packages/library/src/survey.mjs and packages/library/tests/survey.test.mjs.
|
||||
- No change in the rest, each for a reason:
|
||||
- reader.mjs:183 skips only unmapped, so supporting files are indexed like playground files.
|
||||
- R5 (:207) flags only canon to playground, as design 1.3 R5 says. A canon link to a supporting file
|
||||
raises nothing.
|
||||
- render.mjs:23 gives supporting a null badge. The supporting badge belongs to C2-10b (design :1415).
|
||||
- mirror.mjs:246 refuses only unmapped. Reading supporting files by scope is C2-05c's.
|
||||
- No bus or webui source filters on scope (git grep at 94c33a43c).
|
||||
- M9 already protects the whole `projects` key from agent edits (library-projects-protected.test). No
|
||||
test is added there.
|
||||
|
||||
## 6. Tests
|
||||
|
||||
- mapping.test:
|
||||
- T1: `supporting` at book level, at universe level, in a hybrid and in a draft. The output carries
|
||||
it, with `[]` when absent. The draft `deepEqual` at :48 gains `supporting: []`.
|
||||
- T2: one test per prefix rule.
|
||||
- These refuse `library-mapping-invalid`: no trailing `/`, a bad folder, one holding another, one
|
||||
listed twice, one equal to its own book, one holding its own book, one holding a playground
|
||||
prefix, and one overlapping another entry's playground.
|
||||
- These refuse `library-supporting-other-universe`: one inside another universe's book, and one
|
||||
holding another universe's book.
|
||||
- An equal same-entry playground passes.
|
||||
- T3: `editor` passes, and absent passes. It refuses `library-mapping-invalid` for a non-object, an
|
||||
array, an extra key, a missing key, an empty or blank value, a non-string, and a control character,
|
||||
`<` or `>` in either value.
|
||||
- scope.test, T4:
|
||||
- Supporting at book level, universe level and in a draft each give their universe and book.
|
||||
- A folder in both lists is `supporting`, and a playground-only folder is `playground`.
|
||||
- A supporting `.canvas` or `.base` is unmapped.
|
||||
- supporting.test (new):
|
||||
- T5 passes at book level, universe level, in a draft, in a both-lists folder, and at exactly 1,024 bytes.
|
||||
- T6 has one refusal per class against a fixture tree with a symlink, a gitlink and a case variant.
|
||||
The classes are:
|
||||
- unmapped, `.canvas`, `.base`, an attachment, canon, playground-only, another universe's
|
||||
supporting prefix, and an unknown universe;
|
||||
- absolute, `..`, `.`, `//`, a trailing `/`, a backslash, a C0 control and DEL;
|
||||
- a symlink folder, a gitlink folder, and a symlink at the path itself;
|
||||
- a case-variant file, a case-variant folder, and an NFD-form variant of an NFC tree name;
|
||||
- 1,025 bytes made with a multi-byte character, so bytes and characters differ.
|
||||
- Each asserts the code `library-write-story-path`.
|
||||
- business.test: T7 adds one assertion that `validateBusinessDocument` refuses
|
||||
`library-supporting-other-universe`.
|
||||
- survey.test (only if Q1 is approved): T8 counts one supporting file, and :73 is re-pinned.
|
||||
|
||||
## 7. Mutants (exact strings go in BUILD.md)
|
||||
|
||||
| Mutant | Change | Killed by |
|
||||
|---|---|---|
|
||||
| M1 | classify checks playground before supporting (the brief's mutant) | T4 both-lists, T5 both-lists |
|
||||
| M2 | the other-universe fail for supporting is dropped | T2 |
|
||||
| M3 | the symlink and gitlink check is dropped | T6 |
|
||||
| M4 | the case check is dropped | T6 |
|
||||
| M4b | check 8 drops `normalize('NFC')` | T6 NFD variant |
|
||||
| M5 | the byte length uses `path.length` | T6 1,025 bytes |
|
||||
| M6 | the `editor` control-character check is dropped | T3 |
|
||||
| M6b | the `editor` `<` and `>` check is dropped | T3 |
|
||||
| M7 | supportingPath skips the universe match | T6 other universe |
|
||||
| M8 | the supporting-holds-playground check is dropped | T2 |
|
||||
|
||||
## 8. Gate
|
||||
|
||||
- Webby approves the manifest.
|
||||
- The library and business suites and every scripts/test-*.sh are green on Sage's gate.
|
||||
- Timeouts (mos-1275): each package `timeout -k 10 1800`, each script `timeout -k 10 300`, each single
|
||||
file and mutant `timeout -k 10 600`. These come from my row 256 gate at 10:34Z.
|
||||
- Before in-review, grep packages/*/tests for 64-hex pins on the changed files and for the changed
|
||||
message text, then drift-check BASE..refactor.
|
||||
|
||||
## 9. Questions
|
||||
|
||||
- Q1 (Sage): approve the survey.mjs and survey.test.mjs departure in section 5?
|
||||
- Q2 (Webby): the cross-list rules in section 1?
|
||||
- Q3 (Webby): `tree` as the `listTree` array?
|
||||
- Q4 (Webby): the delete's 100644 check stays with C2-02?
|
||||
- Q5 (Webby): `editor` refuses only blank values and control characters, as the brief says. `<` and `>`
|
||||
would break a git ident. I'd leave them to C2-02's commit step unless you want them refused here.
|
||||
|
||||
## 10. Draft 2 (webby-0169)
|
||||
|
||||
- R1 folded: `editor` refuses `<` and `>` too, with T3 cases and mutant M6b.
|
||||
- Q2 accepted. BUILD.md records the tradeoff: refusing a supporting prefix that holds a playground
|
||||
prefix goes past the brief, but it is the fail-closed reading and uses an existing code. The cost
|
||||
is that she can't nest a playground under a supporting folder, though she can nest the other way.
|
||||
- Q3 accepted. Q4: carried to C2-02 under Handoffs in BUILD.md, and Sage was asked to add it to C2-02's brief.
|
||||
- N1 folded into check 8 (NFC and case), with an NFD case in T6 and mutant M4b.
|
||||
- N2: the survey.test:73 pin gains only `supporting`, and the count test is the only addition
|
||||
(mos-1330, its own commit).
|
||||
- N3: before the build, re-grep tests outside packages/library for a `deepEqual` of the mapping output
|
||||
on the build base.
|
||||
@@ -1,8 +0,0 @@
|
||||
# Lena
|
||||
|
||||
Builder seat, Codex gpt-6.1-sol, started by Mos under Q245 (mos-1506). Owns row 296 (FED R7, handed over from Quackerjack).
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread (thread db27faca) and works in its own worktree. It has no
|
||||
credentials of its own; it pushes only its own `lena/*` branches through the repository credential helper (mos-0981),
|
||||
never `refactor`, `main` or another seat's branch. Claude seats orchestrate and review (Q245). Registered by Sage on
|
||||
2026-10-23 so the queue can name the seat as row owner (store.mjs isSeatDir).
|
||||
@@ -1,277 +0,0 @@
|
||||
# Row 286: C2-03b push and per-seat credentials for the library mirror (#1791)
|
||||
|
||||
Owner Ludwig. Reviewer Dewey. Gate owner Sage (sage-2909).
|
||||
Brief: `docs/plans/2026-10-20_case2-rows.md`, "## C2-03b", at 94c33a43c. Design 7.2 and the ruling
|
||||
after r11 "agents never write story" in `agents/gosalyn/work/case2/DESIGN.md` at b68d79663.
|
||||
Base: `refactor` at 94c33a43c. The brief cites aca16404c, and lines have moved since:
|
||||
`GRANT_EVIDENCE` is `broker.mjs:253` now, not `:246`.
|
||||
|
||||
Status: code written, local and unrun (18:48Z on). No node until Sage's CLEAR, after row 258's run.
|
||||
|
||||
## What the code does today (94c33a43c)
|
||||
|
||||
- `packages/library/src/sync.mjs:32` `syncCommand(kind, url, {mirror, branch, user, slot})`. It refuses
|
||||
any kind but `fetch`. The env passes `MOSAIC_LIBRARY_HOST`, `_USER` and `_SLOT` to `bin/askpass`.
|
||||
- `bin/askpass` checks `-f` and `! -L` on the slot, then reads it. It doesn't check the uid or the mode.
|
||||
- `sync.mjs:51` `slotPresent` checks lstat: a regular file, our uid, no group or other bits.
|
||||
- The slot path is built in `packages/bus/src/runtime.mjs:211-217` (`syncOptions`) as
|
||||
`<configDir>/secrets/<business>/library-gitea`. The broker only calls `this.#library.sync(context,
|
||||
{accept})` (`broker.mjs:4630`), and the timer calls `libraryModule.sync` directly (`runtime.mjs:533`).
|
||||
- The sync state file has a strict key list in `packages/library/src/mirror.mjs:222`.
|
||||
- `chief-of-staff` is a definition (`STAFF_TO`, `broker.mjs:240`), not a fixed instance name.
|
||||
- A seat's Gitea user is in `gitea_grants.user` and `gitea_users`. Its token file is
|
||||
`<storeRoot>/<business>/<agent>/gitea` (`gitea-grant.mjs:379`). `giteaKeys.held(business, agent)`
|
||||
answers the held case.
|
||||
|
||||
## Plan
|
||||
|
||||
1. `syncCommand(kind, url, {mirror, branch, user, file, commit})`. `kind` is `fetch` or `push`. Same
|
||||
`SAFE` prefix, `-c` keys and env for both. A push is
|
||||
`push --porcelain --no-verify --end-of-options <url> <commit>:refs/heads/<branch>`, with no `+`.
|
||||
`commit` must be a full sha. The env var stays `MOSAIC_LIBRARY_SLOT` but now names whichever file
|
||||
answers: a seat file, the chief's file, slot `library-gitea` or slot `library-human`.
|
||||
2. `push(context, {commit, credential, retried, command})` in `sync.mjs`. git's porcelain line for
|
||||
the ref decides:
|
||||
- `!` with `[rejected]` and `(fetch first)` or `(non-fast-forward)` means the tip moved. Fetch once
|
||||
with the same credential and return `{pushed: false, raced: true, tip}`. With `retried: true`, the
|
||||
same rejection refuses `stale-base`. C2-02 owns the re-apply.
|
||||
- Any other failure refuses `library-push-failed`. git's stderr isn't kept.
|
||||
- A remote that fails C2-04's URL rules refuses `library-remote-invalid` before any git call.
|
||||
3. `bin/askpass` gets `readSecretFile`'s checks, the same way: no symlink, the bus's uid, mode 0600.
|
||||
In sh: `test -f && test ! -L && test -O`, plus a mode check through `stat -c %a`. Only the path
|
||||
reaches that argv. The user check stays an exact match on the one configured user. The check and the
|
||||
read happen at different times (TOCTOU). The bus owns the directories, so I take that as the
|
||||
accepted limit (Q1).
|
||||
4. Credential choice for the fetch, as a pure function `fetchCredential(store, business, keys)` in the
|
||||
bus. The broker exposes it, and `runtime.mjs` passes its answer to `sync` for the verb and the timer:
|
||||
- The chief is the one instance whose definition is `chief-of-staff`.
|
||||
- Evidence: `SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND
|
||||
json_extract(body,'$.method')='gitea' AND json_extract(body,'$.agent')=?`, of any action. It
|
||||
names no project and never reads `gitea_grants`.
|
||||
- No evidence: slot `library-gitea`, `{kind: 'slot'}`.
|
||||
- Evidence, a live `gitea_grants` row for the chief and not held: `{kind: 'chief', user, file}`,
|
||||
with the user from that row. The fallback test never reads the table. This path does, because it
|
||||
needs the user.
|
||||
- Evidence and anything else (revoked, fired or held): `library-fetch-no-grant`, before any file is
|
||||
opened.
|
||||
- No chief instance (fired): Q2.
|
||||
5. The sync state gets `credential: {kind, user} | null`. `library.status` shows it.
|
||||
`library-fetch-no-grant` shows as `lastError.code`. This changes `mirror.mjs`'s key check (Q3).
|
||||
6. Slot `library-human` is `<configDir>/secrets/<business>/library-human`. `humanCredential(...)`
|
||||
refuses `library-human-missing` before any git call when the slot is missing or fails the file checks
|
||||
(lstat: regular file, our uid, 0600). Her Gitea user: Q4.
|
||||
|
||||
## Files owned extension (sage-2922)
|
||||
|
||||
Added to the brief's list: `packages/bus/src/runtime.mjs` (`syncOptions` at :211-217 and the timer's
|
||||
call at :533) and `packages/library/src/mirror.mjs` (the state key list at :222). sage-2922 wrote
|
||||
`packages/library/src/runtime.mjs`, but the file is `packages/bus/src/runtime.mjs`. In mirror.mjs
|
||||
the row 222 contract holds: keys are added and none renamed. Before in-review I grep `packages/*/tests`
|
||||
for hash pins and path reads on both files and check them against every in-review manifest.
|
||||
|
||||
## Scope questions (for Dewey and Sage before code)
|
||||
|
||||
- Q1. The askpass TOCTOU above. Is it accepted as is, or should the helper be a node script that does
|
||||
O_NOFOLLOW plus fstat? If node, it would read the token in process and print it to git's pipe.
|
||||
- Q2. Fired chief. The instance and its `gitea_grants` row are gone, so I can't name the agent for the
|
||||
evidence check. My recommendation fails safe: with no chief instance, the fetch refuses
|
||||
`library-fetch-no-grant` when any gitea grant event exists in the business, and uses the slot only
|
||||
when none exists.
|
||||
- Q3. Files owned names `sync.mjs`, `askpass`, `broker.mjs` and the tests. The choice also needs
|
||||
`packages/bus/src/runtime.mjs` (`syncOptions` and the timer's call) and
|
||||
`packages/library/src/mirror.mjs` (the state key list for `credential`). I'd add both to the
|
||||
manifest, with only those hunks changed.
|
||||
- Q3: approved, sage-2922. See above.
|
||||
- Q4: sage-2922 allowed `user` only if design 7.2 or the ruling after r11 makes it her account. Neither
|
||||
does. `packages/library/README.md:31` at 94c33a43c says "`user` is the bot account name from design
|
||||
step 3, required for sync", so it is the fetch identity and can't be reused for her save. Back to Sage.
|
||||
My recommendation: `humanCredential(context, {user})` takes her login as a parameter, so this row adds no
|
||||
mapping field. The row that adds the save, C2-02 or C2-11a with `editor`, names the field. That
|
||||
needs a mapping schema change, which is Sage's call.
|
||||
Ruled sage-2925: accepted. `humanCredential(context, {user})` refuses `library-human-missing` before
|
||||
any git call when the slot is missing or bad or `user` is missing. This row adds no mapping field.
|
||||
Row 283 (C2-01d) adds an optional `editor.user`, and row 288 (C2-02) passes it in.
|
||||
- Q4 (original). Her Gitea user for `library-human`. The entry's `user` (`mapping.mjs:52`) is today the sync
|
||||
user, the bot's. The design names `editor {name, email}` (C2-11a) but no Gitea login. My
|
||||
recommendation: the entry's `user` becomes her Gitea login, used only with slot `library-human`. The
|
||||
fallback fetch keeps that same user too, because the bot slot was set up for it. Otherwise this row
|
||||
takes the user as a parameter and C2-02 or C2-12b names the field.
|
||||
|
||||
## Tests (brief list, mapped)
|
||||
|
||||
T1 push lands a fast-forward. T2 a moved tip reports the race. T3 a second race refuses `stale-base`.
|
||||
T4 a dead remote refuses `library-push-failed`. T5 askpass answers with the named seat's user and file,
|
||||
and refuses another seat's user, a symlink, 0644 and another uid (with a fallback note if the test can't
|
||||
chown). T6 the fetch uses the chief's file with a live grant, and the slot only with no chief event.
|
||||
T7 revoked (through `grants.revoke`), fired (through `instance.fire`) and held each refuse
|
||||
`library-fetch-no-grant` with a valid slot present, and each asserts the `gitea_grants` row is gone
|
||||
first. T8 a chief grant on another project ends the fallback. T9 T7's three cases with the slot at 0000.
|
||||
T10 a missing `library-human` refuses `library-human-missing` and runs no git. T11 a token search of
|
||||
argv, the mirror config, the run log and the test output. Mutant: a `+` added to the push refspec fails.
|
||||
|
||||
## Gate
|
||||
|
||||
Dewey approves the manifest. The library and bus suites, plus every `scripts/test-*.sh`, pass on Sage's
|
||||
gate. The mutant dies. A token search of the run's output finds nothing.
|
||||
|
||||
## Early look (dewey-0860), folded
|
||||
|
||||
Q1, askpass: sh, with no gap between the check and the read. The helper keeps `test -f` and then:
|
||||
- opens the slot with `exec 3<"$slot"`;
|
||||
- stats the open file with `stat -L -c '%d:%i %u %a %s %F' /dev/fd/3`, and the path itself (an lstat)
|
||||
with `stat -c '%d:%i' -- "$slot"`;
|
||||
- refuses unless the two dev:inode values match, the type is regular, the uid is `id -u`, the mode is
|
||||
600 and the size is at most 16384;
|
||||
- reads the token from fd 3.
|
||||
A symlink or a swap at either moment changes the inode, and the token comes from the file that was
|
||||
checked. This matches `readSecretFile` (`gitea-grant.mjs:167-181`).
|
||||
Residual, the same on both paths: a FIFO swapped in after `test -f` blocks the open. Neither side uses
|
||||
O_NONBLOCK.
|
||||
Swap test T5b: the direct helper test puts a `stat` shim on its own PATH. On its first call the shim
|
||||
renames another file over the slot, then runs the real stat. The helper must exit 1 and print nothing.
|
||||
Mutant M2 drops the inode compare and must fail T5b.
|
||||
|
||||
Q2, the chief: found through `roles` by definition, not through `launch.instances`. A fire only delists
|
||||
the name (`packages/business/src/instance-settings.mjs:355`), and `roles[name]` stays. Only a delete
|
||||
removes it (`deletedDocument`, :70).
|
||||
- Gitea grant evidence for any `roles` entry whose definition is `chief-of-staff` ends the fallback.
|
||||
That includes a fired chief beside a new listed one.
|
||||
- A live credential comes only from the listed chief's live, unheld `gitea_grants` row.
|
||||
- With no `roles` entry of that definition (deleted, or never a chief), any gitea grant event in the
|
||||
business refuses `library-fetch-no-grant`.
|
||||
- Cost, for Sage: row 236 lets seat gitea grants exist before C2-21. So a business with no chief
|
||||
entry and one seat grant loses the slot fallback. That fails safe, but it narrows "an install that
|
||||
runs before C2-21 still syncs".
|
||||
- T7's fired case also asserts that `roles[chief]` still exists after `instance.fire`.
|
||||
|
||||
N1: the `credential` key is optional in `mirror.mjs`'s exact `keys(...)` check (:221), so a pre-286
|
||||
state file still reads. The old fixtures have no `credential`: `library-sync.test.mjs:18`,
|
||||
`library.test.mjs:106` and `mirror.test.mjs:13`. New test T12 reads a pre-286 state file. Row 224
|
||||
(Della, 23816cf28) also changes `packages/bus/src/runtime.mjs`. Whichever lands second rebases and reruns
|
||||
bus whole.
|
||||
|
||||
N2: `[remote rejected]`, a hook refusal, is `library-push-failed`, not a race. Only `[rejected]` with
|
||||
`(fetch first)` or `(non-fast-forward)` counts as a race. The evidence query depends on
|
||||
`body.method` and `body.agent`. gitea `grant.applied` carries both at `broker.mjs:2479` and
|
||||
`grant.revoked` at :1862 and :2797. The spend event (`operation: 'grant.gitea'`, :1093) carries
|
||||
neither, so it doesn't match.
|
||||
|
||||
N3: `fetchCredential` reads the store, so it is the bus's choice function, not a pure one.
|
||||
|
||||
## Rulings sage-2928
|
||||
|
||||
- Q1: Dewey's fix, in sh. On dragon-lin `/bin/sh` is bash, and GNU coreutils stat 9.11 gives
|
||||
`stat -L -c '%d:%i %u %a %s %F' /dev/fd/3` on an open fd. Checked at 18:47Z with a scratch file, not
|
||||
the repo. The helper runs as git's child in the bus process, not under bwrap. T5b stages the swap with
|
||||
the PATH stat shim. If a run shows the swap can't be staged reliably, BUILD.md names M2, which drops the
|
||||
fd compare, and it gets killed.
|
||||
- Q2: chief by `roles[*].definition === 'chief-of-staff'`. The blanket refusal applies only with no such
|
||||
entry. Departure from the brief's "an install that runs before C2-21 still syncs", accepted in
|
||||
sage-2928: it refuses rather than widens, and every install enrolls the chief at initial configuration
|
||||
(F40, Q226). The refusal's message names the fix: "add the chief of staff, or grant the library
|
||||
through C2-21".
|
||||
- N1: as above.
|
||||
|
||||
## Build notes (code written; nothing run yet; dewey-0864 folded)
|
||||
|
||||
Files changed:
|
||||
- `packages/library/src/sync.mjs`: `syncCommand` takes `push` with a full-sha `commit`; `push()`;
|
||||
`humanCredential()`; `sync()` takes `credential`; `HUMAN_SLOT`, `CREDENTIAL_KINDS`. The `slot` opt
|
||||
and `MOSAIC_LIBRARY_SLOT` keep their names and now name whichever file answers.
|
||||
- `packages/library/src/index.mjs`: the new exports.
|
||||
- `packages/library/bin/askpass`: Dewey's fd 3 check.
|
||||
- `packages/library/src/mirror.mjs`: optional `credential` in the state key check. Nothing renamed.
|
||||
- `packages/bus/src/broker.mjs`: `LIBRARY_FETCH_FIX` and `libraryFetchCredential(business)`.
|
||||
- `packages/bus/src/runtime.mjs`: `syncOptions` passes the broker's choice to the verb and the timer.
|
||||
The timer's held line adds `fix` for `library-fetch-no-grant`.
|
||||
- `packages/library/README.md`: "Push and credentials (C2-03b)".
|
||||
- Tests: `packages/library/tests/sync.test.mjs` (appended), `packages/bus/tests/library-credential.test.mjs` (new).
|
||||
|
||||
Choices made without a ruling. Each is small and reversible:
|
||||
- `humanCredential(context, { user, configDir })`: sage-2925 named `{user}`. The slot path needs the
|
||||
config dir, and the library package doesn't know it, so the caller passes it. That's the same
|
||||
source runtime's `slotDir` uses.
|
||||
- `BusError` carries only a code, so the sage-2928 fix text can't ride on the verb's refusal. It
|
||||
goes in the timer's log line (`fix`) and in the README. The verb answers `library-fetch-no-grant`.
|
||||
- T7 and T9 run the real verbs (dewey-0864, ruled by sage-2947: no SQL staging; replaces the
|
||||
staged state in cefebf65b). Until row 285 lands, the test passes its own definitions; once it lands
|
||||
the real chief definition carries gitea. Whichever of 285 and 286 lands second rebases and reruns
|
||||
library and bus whole (sage-2947). The chief's grant goes through decision.raise and the human's yes against fake-gitea-api.mjs. The test's own
|
||||
definitions give chief-of-staff a gitea credential, as gitea-grant.test.mjs gives coder one. The
|
||||
real roles/chief-of-staff.json has `credentials: []`, so a live chief can't get one before C2-21.
|
||||
- Revoked: `grants.revoke`, then the row is gone and the fetch refuses.
|
||||
- Fired: `instance.fire` can't fire the launch.by chief. Only the launch.by role calls it, and it
|
||||
refuses its own name (`organization-instance-referenced`, broker.mjs `#chiefLifecycle`). So the
|
||||
test's launch.by is `boss` (definition pm, the chief's authority), and boss fires the one listed
|
||||
chief-of-staff instance. Step G deletes the row (row 281), `roles.chief` stays, the fetch refuses.
|
||||
Only launch.by may call instance.fire (`role-not-chief`, broker.mjs:4308 at 94c33a43c) and it
|
||||
refuses its own name (:4335), so this is the only fired chief the verb allows. Accepted by
|
||||
sage-2951 as brief owner and read the same way in dewey-0869.
|
||||
- T9's revoked case runs `grants.revoke` on the runtime's own data root and store, between starts.
|
||||
T9's runtime gets a scratch roles/ whose chief-of-staff carries `{service: gitea}`, as row 285
|
||||
ships it (case2-rows.md:1823-1824), so its definitions match the grant's (dewey-0869). After a
|
||||
rebase onto 285 the copy needs no edit.
|
||||
- Delisted with the row still there, a `fire.stopped` with no `fire.done`, held, two chiefs and
|
||||
another project stay unit checks on the choice function with SQL fixtures.
|
||||
- "Another uid" in T5: the suite can't chown, so a PATH `stat` shim reports uid+1 for the open file.
|
||||
The real `id -u` compare is covered only through that shim (dewey-0864).
|
||||
- Two listed chiefs, or none listed, refuse `library-fetch-no-grant` (T8).
|
||||
- The race fetch inside `push()` takes sync's `running` guard (dewey-0864 R1, in this row per
|
||||
sage-2947). While a sync of the mirror runs it refuses `library-sync-busy`, fetches nothing, and
|
||||
the caller writes again. Test: "race fetch: while a sync of the mirror runs". Its sync fetch is a
|
||||
git alias that touches a marker, then waits for a release file. The push starts after the marker
|
||||
exists and the release comes after the refusal, so nothing depends on timing (dewey-0869).
|
||||
- The slot at 0000: T9 covers the revoked case at runtime. Fired and held refuse inside the choice
|
||||
function, which takes no path and opens no file. `sync` with any `{ refused }` runs no git (library
|
||||
test, valid slot present).
|
||||
- First-run reds in the new tests are mine to fix in setup and report afterwards (mos-1272).
|
||||
- The chief's evidence is any gitea grant event naming the chief, of any action (grant.applied,
|
||||
grant.revoked and the rest), per the plan. The spend event names no agent, so it doesn't count.
|
||||
|
||||
Test map:
|
||||
- library sync.test.mjs: argv (push), T1, T2+T3, T4 (dead remote, hook, wrong token), refused or
|
||||
unsafe credential runs no git, T10, fetch credential and state with N1/T12, T5+T5b.
|
||||
- library sync.test.mjs also: race fetch busy (R1).
|
||||
- bus library-credential.test.mjs: T6, T7 (real revoke and fire), T7 choice checks, T8, departure,
|
||||
T9 runtime (real grant, chief file and user via timer and verb; real revoke, slot 0000: no git,
|
||||
log `fix`, state `credential: null`).
|
||||
- webby-0151 R3: T9 reads `library.status` for `credential` and `lastError`.
|
||||
- T11 token search: T1 scans the data root and every file but the seat's; T9 scans the log lines.
|
||||
The gate's output search uses the fixture tokens `fixture-token-*-made-up-00{42..49}`.
|
||||
|
||||
Mutants (each must die on a semantic assertion):
|
||||
- M1: `+` before the push refspec in `syncCommand`. Expected to fail: the argv test, and T2 (the
|
||||
forced push lands, so `raced` is never reported and her commit is gone).
|
||||
- M2: drop `test "$1" = "$path"` from askpass. Expected to fail: T5b (the helper prints the checked
|
||||
file's token).
|
||||
|
||||
## r2 rebase (sage-3579)
|
||||
|
||||
Sage ruled that 286 now goes before 258, because 222 is its only after row. Branch ludwig/row286-r2
|
||||
rebases b9506b352 (base 94c33a43c) onto origin/refactor 45dca69f6. The rebase had no conflicts.
|
||||
|
||||
- Range-diff 94c33a43c..b9506b352 against 45dca69f6..r2: all 11 commits are `=`, so no patch
|
||||
changed.
|
||||
- Rows 254 (331597709) and 268 (c27207f6e) were already in the old base 94c33a43c. Between the two
|
||||
bases nothing changed under packages/library.
|
||||
- Paths the two test files import or read, diffed 94c33a43c..45dca69f6: bus broker.mjs, runtime.mjs
|
||||
and store.mjs changed. None of the others changed: library/*, bus credentials, gitea-grant,
|
||||
mapping, mirror, reader, survey, sync, the fake-gitea-api, helpers and host-fixtures modules,
|
||||
business helpers (rolesCopy, systemFor), project git, seat checkout and fake-gitea, and roles/.
|
||||
- broker.mjs: the constructor gains `accessStamp` and `compactions`, both defaulting to null, and
|
||||
my tests pass neither. No base hunk falls in the fire job, the revoke job or the instance.fire
|
||||
verb. Old :4308/:4335 are now :4852/:4879, with the same text. The new console-cap check refuses
|
||||
a non-console session only on CONSOLE_VERBS, and the library verbs are not among them. The
|
||||
request path's access re-read applies only to `s.user` sessions, and my tests open none.
|
||||
- runtime.mjs: consoleOwners (default []), the access timer, compactions and the session-clear
|
||||
hooks. The Credentials and giteaStart wiring that T9 uses is unchanged.
|
||||
- store.mjs: schema 3l to 3n (row 206 thread_shares, row 271 session_ops and message_cancels).
|
||||
Each test opens a fresh Store, and none pins a schema version or step count.
|
||||
- Hash pins: no test or script line pairs a 64-hex value with a path I change. The positive
|
||||
control finds 64-hex pins in, for example, account-migration.test.mjs.
|
||||
- Row 285 has not landed. roles/chief-of-staff.json at 45dca69f6 still names no gitea credential,
|
||||
so T9 keeps its scratch roles copy (sage-2947).
|
||||
- sage-3589 freezes stack-release.sh, webui-service.sh, bus-service.sh, stack-copy.sh and common.sh.
|
||||
This row changes none of them.
|
||||
- Still to run, on CLEAR: library and bus whole, every scripts/test-*.sh, M1 and M2, and the token
|
||||
search, all on r2.
|
||||
@@ -1,184 +0,0 @@
|
||||
# Row 216 build notes (C2-05a, lookup index and queries, #1717)
|
||||
|
||||
Phase A only: code, tests and this file. No test, suite or mutant has been run. `node --check` passed on every changed `.mjs`.
|
||||
|
||||
- Branch: `researcher/row216`
|
||||
- Base: `d372102882d6858fa77af4aee453f866c886ad5c` (origin/refactor)
|
||||
- Design: `agents/gosalyn/work/case2/DESIGN.md` on origin/gosalyn/case2-design, section 3 (table at lines 625-631, bullets 633-641), 1.2-1.4, B12, D2
|
||||
- Brief: `docs/plans/2026-10-20_case2-rows.md`, C2-05a at lines 320-357
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Status |
|
||||
|---|---|
|
||||
| `packages/library/src/index-build.mjs` | new |
|
||||
| `packages/library/src/query.mjs` | new |
|
||||
| `packages/library/src/index.mjs` | edit: 2 export lines added |
|
||||
| `packages/library/tests/query-fixture.mjs` | new, test helper |
|
||||
| `packages/library/tests/query.test.mjs` | new |
|
||||
| `packages/library/tests/index-build.test.mjs` | new |
|
||||
| `agents/researcher/work/queue-216/BUILD.md` | new (this file) |
|
||||
|
||||
No existing test or source file changed beyond `index.mjs`.
|
||||
|
||||
## Design rows and where each is tested
|
||||
|
||||
| Design row | Code | Tests (query.test.mjs unless noted) |
|
||||
|---|---|---|
|
||||
| `library_find` | `find` | find is bound to its universe; every result carries its scope; long list pages; large items page under the cap |
|
||||
| `library_get` | `get` | hybrid gets a source file; get lists visible links both ways; get pages its lines |
|
||||
| `library_search` | `search` | search returns scoped line hits; item that cannot fit alone is refused |
|
||||
| `library_manuscript` | `manuscript` | one chapter at most 400 lines; refused without chapter; hybrid reads a source chapter |
|
||||
| `library_universe` | `universeInfo` | universe info reports a hybrid's sources, constraints, notes; unknown universe refused |
|
||||
| Hybrid semantics (1.4) | `sees` | a hybrid sees its sources canon minus exclusions, no playground |
|
||||
| Unmapped never appears | index build + `sees` | unmapped files never appear (query.test); index holds mapped files only (index-build.test) |
|
||||
| Per-commit cache (3) | `indexFor` | same commit, path, mapping return one index; different path, mapping or commit get their own; eight-entry bound; failed read not kept; unchecked mapping refused (index-build.test) |
|
||||
| Rebuild time (3, C2-05a gate) | `indexFor` | 3,000-file rebuild, time reported with `t.diagnostic`, no threshold asserted |
|
||||
| REPLY_CAP parity | `QUERY_REPLY_CAP` | equals `REPLY_CAP` from harness tools.mjs |
|
||||
|
||||
## Choices where the design table is silent or differs
|
||||
|
||||
These are departures or gaps. Each is built as stated, so Sage can rule before Phase B. A ruling that changes one of them is a small edit.
|
||||
|
||||
1. **Paging arguments (gap a).** The table has no offset. `find` and `search` take an optional `offset` (a position in the filtered list, default 0). A reply carries `next`: the position of the next item, or `null`.
|
||||
2. **`library_get` has no universe (gap b).** Built with a required `universe`. Without it the reply could not be isolated. A missing file and a file the universe cannot see both return `null`, so a caller cannot tell them apart. `get` also takes an optional `from` line.
|
||||
3. **`get` over the cap (gap c).** Lines page by `from` with `next`. `links_out` and `links_in` come back whole on every page. If the frontmatter and links alone exceed the cap, the call is refused with `query-item-too-large`. A hub note with many backlinks may be refused. This needs a ruling: link paging, or accept the refusal.
|
||||
4. **`manuscript` without a chapter (gap d).** Refused with `query-chapter-required`. The design names no list form. A chapter name that matches two visible files is refused with `query-chapter-ambiguous`.
|
||||
5. **`search` excerpt (gap e).** The excerpt is the whole line, not a truncation. A line that alone exceeds the cap is refused. Truncating it would cut an item.
|
||||
6. **`exclude` form (gap f).** `checkMapping` accepts only a folder path for `exclude` (`folder()`), so an exclusion is a path prefix. There is no type or source form to build. Confirm.
|
||||
7. **Pin constraints.** `pin` does not hide any file. It is reported by `universeInfo` only. Confirm.
|
||||
8. **Playground through `get`.** `scope` applies to lists. `get` by an explicit path returns a playground file of the caller's own universe. Confirm.
|
||||
9. **Links.** A link into a file the universe cannot see is dropped from `links_out` and `links_in`, in line with N1. Links from a visible file that point into another universe are kept in the index (the reader keeps them under R6), and the query layer drops them for universes that cannot see them.
|
||||
10. **`find` matching.** Case-insensitive substring over the front matter `name` and the file name. An empty `query` matches every file in scope.
|
||||
11. **Cache key.** `repo`, `commit` and a sha256 of the canonical mapping. Keying by commit alone would return the wrong scopes after a mapping change. The cache holds eight entries, least recently used out. Failed reads are not kept.
|
||||
12. **`universeInfo`.** Reads only the checked mapping and the served commit. It builds no index.
|
||||
13. **Reply commit.** Every reply carries the index commit, which is the served commit passed in.
|
||||
|
||||
## Provisional refusal codes (pending Sage approval)
|
||||
|
||||
The brief says new refusal codes need Sage's approval first. These are provisional and must not be treated as settled:
|
||||
|
||||
- `query-argument-invalid`
|
||||
- `query-universe-unknown`
|
||||
- `query-chapter-required`
|
||||
- `query-chapter-ambiguous`
|
||||
- `query-item-too-large`
|
||||
|
||||
Reused, existing codes: `library-mapping-invalid`, plus the reader's `library-git`, `library-commit` through `indexFor`.
|
||||
|
||||
## Grep results (grep only, not a test run)
|
||||
|
||||
- Test references to the changed paths: `packages/library/tests/*.mjs` import `../src/index.mjs` and `../src/mapping.mjs`. `packages/bus/tests/library.test.mjs` and `library-sync.test.mjs` import the library index as a namespace. Neither enumerates its exports, so the two new export lines break no assertion.
|
||||
- `packages/cli/tests/stack-copy.test.mjs:52` checks that `packages/library/src/index.mjs` exists, and it still does.
|
||||
- 64-hex pins: none in `packages/library/tests`, none on `index.mjs`, `query.mjs` or `index-build.mjs`.
|
||||
- `scripts/test-*.sh`: no reference to the new modules.
|
||||
|
||||
## Flag for Sage
|
||||
|
||||
- `packages/library/tests/helpers.mjs` `repo()` builds its fixtures under `tmpdir()`, which is `/tmp`. The launcher rules forbid that. The new tests do not use it; they use `tests/query-fixture.mjs`, which builds under `/mnt/storage/scratch/tmp`. The existing helper should be fixed in a separate row, not here.
|
||||
|
||||
## Phase B commands (not run)
|
||||
|
||||
Run only after Sage sends "slot clear". Set the scratch TMPDIR first, per the launcher rule.
|
||||
|
||||
```
|
||||
cd /mnt/storage/src/mosaic-stack/.claude/worktrees/agent-aa3cc98787bc5152c/packages/library
|
||||
export TMPDIR=/mnt/storage/scratch/tmp
|
||||
node --test tests/query.test.mjs tests/index-build.test.mjs
|
||||
node --test tests/*.test.mjs
|
||||
```
|
||||
|
||||
Also run `packages/bus/tests/library.test.mjs` and `library-sync.test.mjs`, since they import the index.
|
||||
|
||||
## Status
|
||||
|
||||
Not yet verified. Nothing has been tested and nothing is committed. The working tree holds the Phase A changes. AGENTS.md invariant 8 allows a commit only after the applicable suites are green, so the commit waits for Phase B. The dispatch asked for a local commit in Phase A; Sage to confirm that the commit waits for Phase B.
|
||||
|
||||
## Round 2 (2026-10-22, sage-2708, sage-2716): rulings applied, rebased onto aca16404c
|
||||
|
||||
Added after the text above; nothing above is edited. "Nothing is committed", "Provisional refusal codes",
|
||||
`query-chapter-required` and the tmpdir flag above were true when written and are superseded here.
|
||||
|
||||
- Branch `researcher/row216-r2`, one local commit on origin/refactor `aca16404c`. Row 268 (C2-01c) is in
|
||||
that base. Row 253 has not landed; the work rebases onto 253's tip before any gate. Not pushed, not run.
|
||||
- Rulings: `agents/sage/work/queue-216/RULINGS.md` at da9555ca2, rulings 1-13 and the tmpdir flag.
|
||||
- Drift check, d37210288..aca16404c against my paths: the library files that changed are `mapping.mjs`,
|
||||
`scope.mjs`, `README.md` and the mapping and scope tests. None is a file I add or edit. `query.mjs` reads
|
||||
the checked mapping through them, so the whole library package runs in phase B.
|
||||
|
||||
### What changed from phase A
|
||||
|
||||
| Ruling | Change in `query.mjs` | Test |
|
||||
|---|---|---|
|
||||
| 3 `get` over the cap | `links_out` and `links_in` page together with `links_from`; every reply carries `links_next`, `next` and the front matter. A page with lines left carries at least one line. Links take at most half the cap while lines remain. `query-item-too-large` is left for front matter alone or one line alone (or one link alone) | hub note with 900 backlinks pages and adds up; front matter alone and a line alone refused; bad `links_from` |
|
||||
| 4 `manuscript` no chapter | `chapter` optional: the first visible chapter in path order. Every reply carries `next_chapter`. `query-chapter-required` removed. Ambiguity still refuses | first chapter and `next_chapter`; ambiguous name refused |
|
||||
| 5 `search` long line | hit comes back `{path, line, excerpt: null, excerpt_too_long: true, scope}`, other hits still return | marked hit and unmarked hits in one reply |
|
||||
| 6 `exclude` | path-prefix reading only. The test names `EXCLUSION` and `EXCLUDED_PATHS` in `query-fixture.mjs`, so C2-15 (row 267) swaps two constants | exclusion hides from the hybrid and not from the source |
|
||||
| 8 hybrid `get` | unchanged code; test added | hybrid gets a source's playground file as `null`; the source gets it with `scope: "playground"` |
|
||||
| PRD 1.14 (row 268) | `universeInfo` reports `kind` from the mapping (`draft` included) | four tests below |
|
||||
|
||||
PRD 1.14 tests, one each: a lookup naming a `book` leaves `book: null` files out (find and search);
|
||||
`library_manuscript` never reads a `book: null` file; a draft universe has no canon, so its lookups return
|
||||
files only with `scope: "playground"`; `library_universe` on a draft returns kind, name and prefixes.
|
||||
The levels fixture is `WITH_LEVELS` in `query.test.mjs`: harbor gets `Notes/Harbor/` as a universe-level
|
||||
playground prefix, and `idea` is a draft over `Drafts/New World/`.
|
||||
|
||||
`query-fixture.mjs` builds under `tmpdir()`, as `helpers.mjs` does (ruling "tmpdir flag"). The earlier
|
||||
flag about `helpers.mjs` building under `/tmp` is closed by that ruling: the gate sets `TMPDIR`.
|
||||
|
||||
Refusal codes now: `query-argument-invalid`, `query-universe-unknown`, `query-chapter-ambiguous`,
|
||||
`query-item-too-large`. All four approved in the rulings.
|
||||
|
||||
### Grep results (grep only, not a test run)
|
||||
|
||||
- `git grep` over `packages/*/tests/*` and `scripts/*` for `query.mjs` and `index-build`: no hit outside
|
||||
`packages/library/tests/query*.test.mjs` and `index-build.test.mjs`.
|
||||
- 64-hex pins naming a library path: none. Positive control: the same pathspec finds the sha256 lines in
|
||||
`packages/cli/tests/case1-hostcheck.test.mjs` and the `packages/library/src/index.mjs` existence check at
|
||||
`packages/cli/tests/stack-copy.test.mjs:52`.
|
||||
- Tests that read `packages/library/src` as a directory: `packages/cli/tests/stack-release-fixture.mjs:309`
|
||||
copies `packages/library/src` whole into a release fixture. It carries no file list, so two new files break
|
||||
no assertion. `stack-release.test.mjs` is not in the gate unless Sage wants it.
|
||||
- Tests that import the library index as a namespace: `packages/bus/tests/library.test.mjs` and
|
||||
`library-sync.test.mjs`. Neither enumerates exports, so the two export lines add no break. Both run
|
||||
whole in phase B.
|
||||
|
||||
### Run limits (fixed before any run)
|
||||
|
||||
Every node file runs as its own `setsid -w timeout -k 10 LIMIT node --test FILE`; nothing runs unbounded.
|
||||
|
||||
- Node test files: LIMIT 900 s. The library test files have no recorded time. 900 s is above the longest
|
||||
recorded whole-package run I know of (cli, 591 s, row 281 BUILD.md "Runs" step 3), so it holds the 3x rule
|
||||
for any single file. The first run records each file's time, and a file past 300 s gets a limit re-derived
|
||||
from it before the next run. `query.test.mjs` builds a 900-file repo and a 3,000-file index rebuild is in
|
||||
`index-build.test.mjs`; both are the files most likely to need it.
|
||||
- No shell script is in this row's gate, so no script limit applies.
|
||||
|
||||
Any timeout goes to Sage, then Mos, unplaced (mos-1275).
|
||||
|
||||
### Phase B (not run; Sage's CLEAR first, then a rebase onto 253's tip)
|
||||
|
||||
```
|
||||
export TMPDIR=/mnt/storage/scratch/tmp
|
||||
cd <worktree>/packages/library
|
||||
for f in tests/*.test.mjs; do setsid -w timeout -k 10 900 node --test "$f"; done
|
||||
cd ../bus
|
||||
for f in tests/library.test.mjs tests/library-sync.test.mjs; do setsid -w timeout -k 10 900 node --test "$f"; done
|
||||
cd ../cli
|
||||
setsid -w timeout -k 10 900 node --test tests/stack-copy.test.mjs
|
||||
```
|
||||
|
||||
Mutants I propose (my list, not read off the brief; each is meant to be killed by the named test, and none
|
||||
has been thrown; the kill claims are unverified until CLEAR):
|
||||
|
||||
- M-a: `sees()` returns true for a file of another universe: killed by "find is bound to its universe".
|
||||
- M-b: the playground filter dropped: killed by "playground appears only when asked for".
|
||||
- M-c: the hybrid reads a source's playground: killed by "a hybrid gets a source playground file as null".
|
||||
- M-d: `links_next` always null: killed by the hub paging test.
|
||||
- M-e: the book filter lets `book: null` through: killed by the PRD 1.14 book test.
|
||||
|
||||
### Status
|
||||
|
||||
Built, not run. `node --check` has not been run on this round's files (no node before the CLEAR); the
|
||||
phase A pass above covered the earlier text only. The commit is local and waits for no gate under Sage's
|
||||
sage-2716 instruction, which allows git and docs work only; push and claim wait for Sage's go.
|
||||
@@ -1,229 +0,0 @@
|
||||
# Row 285 build notes: C2-08b creative and chief role changes
|
||||
|
||||
Seat: Scrooge. Reviewer: Webby. Gate owner: Sage. Issue #1790 (mos-1309 item 5).
|
||||
Brief: `docs/plans/2026-10-20_case2-rows.md` at 94c33a43c, row 285 section, gate under "### Gate".
|
||||
Base: 94c33a43c on branch `scrooge/row285`.
|
||||
|
||||
## Status
|
||||
|
||||
- The code, tests, fixtures and docs are written locally. No test has run. Sage's order puts row 252 first at the next CLEAR, and nothing runs during another seat's slot.
|
||||
- The Results section is empty until that run. Nothing below claims a pass.
|
||||
- Mos ruled the mos-1272 points in mos-1332 (18:55Z). mos-1333 (19:04Z) withdrew item 3, the business.mjs publish fence. See "Old tests" below.
|
||||
|
||||
## What changes
|
||||
|
||||
- The six creative roles (companion, ideas, research, proofreader, fact-checker, continuity):
|
||||
- withinRole is `["message.send", "library.write"]`.
|
||||
- credentials is `[{"service": "gitea", "scopes": ["read:user", "write:repository"]}]`.
|
||||
- Each contract's Authority and Protocol lines describe the two library write tools, which are limited to `.md` files in supporting folders the user marked. The agent never holds a token.
|
||||
- The chief of staff:
|
||||
- credentials is `[{"service": "gitea", "scopes": ["read:repository", "read:user"]}]`, with no library.write.
|
||||
- The contract says the broker holds that token and it can't publish or push.
|
||||
- `vocabulary.mjs` drops CREATIVE_KINDS and adds CREATIVE_ACTIONS (`message.send`, `library.write`) and CREATIVE_SERVICE (`gitea`).
|
||||
- `role.mjs` checkCreative refuses a creative role that holds any withinRole action outside CREATIVE_ACTIONS, any crossRole action, or any credential service other than gitea.
|
||||
- `broker.mjs` #giteaRole (:2138) also refuses the chief of staff with `gitea-not-declared` (sage-2921).
|
||||
- business.mjs does not change (mos-1333 item 1). The fence added in 5f81d0da0 is removed in a later commit, so the branch's business.mjs equals the base.
|
||||
|
||||
## Rulings
|
||||
|
||||
- **webby-0164:** PRD 1.15 rewrites REQ-LIB-6 so creative agents write `.md` files in supporting folders. The REQ-WAIT-3 routing line is the missing piece.
|
||||
- **mos-1328:**
|
||||
- library.write routing goes in PRD 1.16, which needs Jason's explicit yes (REQ-WAIT-2).
|
||||
- library.write stays out of ROUTED until 1.16 lands.
|
||||
- If Jason changes the bounds, row 285 gets a new brief.
|
||||
- **sage-2915:**
|
||||
- The scopes a role declares must equal what any token minted for that role holds, at the commit where 285 lands.
|
||||
- The creative scopes `["read:user","write:repository"]` are approved.
|
||||
- The ROUTED edit gets its own commit, citing the paragraph.
|
||||
- **sage-2921:**
|
||||
- Refuse the chief in #giteaRole, with a gitea-grant.test case next to X-G1. broker.mjs (#giteaRole only) and gitea-grant.test join the manifest.
|
||||
- Grep the tests for the gitea-not-declared text and for pins on broker.mjs before in-review.
|
||||
- The ROUTED commit cites 1.16 (row #1796).
|
||||
- **mos-1330:**
|
||||
- Item 1: the fixture change gets its own commit. Only fixture data changes, and named tests cover a missing gitea ref.
|
||||
- Item 2: option A is approved. The live chief gets one gitea ref. Every repo business writer gets the ref.
|
||||
- **sage-2937:** if Mos rules (b), business.mjs joins the files owned, scoped to the refusal near :166 and clear of :283-291 (row 287, Megavolt).
|
||||
- **webby-0174:** each changed expected value carries Mos's ruling key. The load fence needs a design line, its own test, a mutant and a grep of the tests for the N10 text.
|
||||
- **mos-1332:** the mos-1272 rulings, items 1-5, cited under "Old tests". The ROUTED commit and the landing still wait on Jason's yes on PRD 1.16 (Q32, Vikunja 546). The build, the gate and Webby's review go ahead.
|
||||
- **mos-1333:** withdraws mos-1332 item 3, since the fence contradicted the brief (:1825, :1829, :1839, and row 287's chief publish ["books"] at :1998). Items 1, 2, 4 and 5 stand.
|
||||
- The fence leaves business.mjs in a new commit, no force-push. business.mjs leaves the manifest and its fence mutants are dropped.
|
||||
- business.test "row 285: a creative instance with identity and publish loads" is back in its 8c529fb4d form.
|
||||
- N10 takes option (a). See "Old tests" item 3.
|
||||
- A new named test: a chief with its gitea ref, identity and publish ["stack"] loads.
|
||||
- Row 287 has nothing to reshape. Its binding note below still applies.
|
||||
- **sage-2946:** the bus suite runs whole (broker.mjs is in the manifest and bus tests read roles/chief-of-staff.json), plus the cli example readers. Item 2 is answered below under "Scopes field required".
|
||||
|
||||
## Scopes rule evidence (sage-2915 q2)
|
||||
|
||||
At 94c33a43c, exactly one path mints a seat token. That is the Gitea grant, `broker.mjs:2420`, where `api.tokenCreate(user, tokenName)` takes the default scopes. The default is SEAT_SCOPES `['read:user', 'write:repository']` (`gitea-grant.mjs:29`, `:263`).
|
||||
|
||||
The only other tokenCreate caller is the admin rotate (`gitea-grant.mjs:331`), which uses ADMIN_SCOPES.
|
||||
|
||||
The creative declaration therefore equals SEAT_SCOPES. The chief's read declaration would be narrower than a minted token. #giteaRole refuses the chief, so no grant path mints the chief a token at the landing commit.
|
||||
|
||||
### Scopes field required (sage-2946 item 2, Megavolt's question)
|
||||
|
||||
role.mjs refuses a gitea credential entry with no scopes. `{"service": "gitea"}` goes through checkGiteaScopes (role.mjs:100-111), which calls `requireDistinctList(scopes, "gitea scopes", ..., {nonEmpty: true})`. util.mjs:42 refuses a non-array with "gitea scopes must be an array". Only a `host` entry may omit scopes (role.mjs:134). So every gitea entry names its scopes, and for a minted token they must equal SEAT_SCOPES (sage-2915).
|
||||
|
||||
### Binding note for row 287 (C2-21)
|
||||
|
||||
- The library read grant row must mint the chief's declared scopes (`read:repository`, `read:user`) and not SEAT_SCOPES.
|
||||
- It needs a test showing the chief's token lacks `write:repository`.
|
||||
- If it reuses #giteaRole, it has to lift the chief refusal and pass the declared scopes in the same change.
|
||||
|
||||
## Live step (mos-1330 item 2, option A)
|
||||
|
||||
The live dragon-lin chief holds no credentials, and there are no creative instances. After row 285 lands, the chief entry in the live business file needs one gitea reference, or the file refuses at load with "must reference exactly the services its role definition needs (gitea; got none)".
|
||||
|
||||
Exact JSON, added to the chief instance (store form, the form the human's `instances.set` add writes):
|
||||
|
||||
```json
|
||||
"credentials": { "gitea": { "store": true, "rotateBy": "YYYY-MM-DD" } }
|
||||
```
|
||||
|
||||
The file form also works: `{ "gitea": { "file": "<configDir>/secrets/<business>/chief-of-staff-gitea.token", "rotateBy": "YYYY-MM-DD" } }`. That file must exist, mode 0600.
|
||||
|
||||
- The reference creates no account, mints no token and grants nothing by itself.
|
||||
- The window owner adds it after a backup of the business file, and before P3.
|
||||
- **Precondition for the store form:** the store root `<configDir>/secrets/agents` must exist and be private, since `bus/src/credentials.mjs:212` calls privateDir on it when the reference is read.
|
||||
|
||||
## Repo business writers (mos-1330 item 2)
|
||||
|
||||
A git grep of scripts, templates, src, tools and packages outside tests found two repo files that write a business file with a chief. Both get the gitea file reference:
|
||||
|
||||
- `packages/business/examples/mosaic-stack.example.json:114`, the chief entry. Two test files read it (corrected after webby-0178 B2):
|
||||
- `packages/business/tests/business.test.mjs:256`, "the shipped example refuses as written and validates once filled in". It replaces `/home/you` with its tmp root and checks the role list and the sync token path. The chief's new file ref goes through the same replacement.
|
||||
- `packages/cli/tests/stack-release-fixture.mjs:351` (EXAMPLE, through `biz()`). It fills botId and the dates but keeps the `/home/you` token paths, as it already does for the other roles' refs. Its readers are stack-release.test, stack-release-external.test and stack-upgrade.test.
|
||||
- All four test files run whole at the gate.
|
||||
- `packages/business/README.md:174`, the chief example line, plus the prose at :163 and :183 that said the chief holds no credentials.
|
||||
|
||||
No script, template or case installer writes a chief or creative instance. `scripts/case1-accept.sh` only talks to `chief`.
|
||||
|
||||
## Fixture changes (mos-1330 item 1, own commit)
|
||||
|
||||
Data only, with one named exception listed under "Old tests".
|
||||
|
||||
- `packages/business/tests/helpers.mjs:71-74`
|
||||
- Old: `// The chief of staff launches (REQ-LAUNCH-1, decision 139); it holds no` / `// credential and no tracker bot.` / `chief: { definition: "chief-of-staff" },`
|
||||
- New: `// The chief of staff launches (REQ-LAUNCH-1, decision 139); it has no` / `// tracker bot. Its one credential reference is gitea, for the library` / `// read grant (row 285, mos-1330).` / `chief: { definition: "chief-of-staff", credentials: { gitea: ref("chief-of-staff", "gitea") } },`
|
||||
- Creative and chief instances loaded through the loader each gain `credentials: { gitea: { store: true, rotateBy: FAR_DATE } }`, and the import adds FAR_DATE where needed:
|
||||
- `packages/business/tests/instance-settings.test.mjs:334`, `:335`, `:380` (import :12)
|
||||
- `packages/business/tests/lifecycle-settings.test.mjs:237` (import :11)
|
||||
- `packages/business/tests/launch-settings.test.mjs:184`, bean (FAR_DATE already imported)
|
||||
- `packages/business/tests/resolve.test.mjs:117`, chief2 (import :7)
|
||||
- `packages/bus/tests/creative.test.mjs:98`, bean in the written business file (import :10). The `startBroker` businesses object at :111 doesn't go through the loader and is unchanged.
|
||||
- `packages/cli/tests/launcher.test.mjs:769`, bean (import :1)
|
||||
- `packages/business/tests/business.test.mjs`: the persona test's bean fixtures get `credentials: CREATIVE_GITEA` (4 places).
|
||||
|
||||
New named tests (mos-1330 item 1):
|
||||
|
||||
- business.test "row 285: a chief or creative instance without credentials.gitea refuses at load". Each case refuses with the checkCredentialMap "(gitea; got none)" message, and a positive control loads.
|
||||
- business.test "row 285: a creative instance with identity and publish loads" (brief :1825, :1839, restored by mos-1333 item 2). The grant still refuses it at raise (#giteaRole).
|
||||
- agent-identity.test "row 285: a chief with its gitea reference, identity and publish loads" (mos-1333 item 4, brief :1829). It also checks the chief's credentials are exactly gitea.
|
||||
|
||||
## Old tests (mos-1272, ruled in mos-1332 and mos-1333)
|
||||
|
||||
Webby (webby-0174) passes each item only with Mos's ruling key next to it.
|
||||
|
||||
1. **`packages/business/tests/chief-of-staff.test.mjs`** (mos-1332 item 1, accepted as pushed). Expected values follow the role file:
|
||||
- APPROVED.credentials (:24). Old: `[]`. New: `[{service: "gitea", scopes: ["read:repository", "read:user"]}]`.
|
||||
- The resolved-record test (:90). Old: `{}`. New: `{gitea: {service: "gitea", file: <root>/secrets/chief-of-staff-gitea.token, rotateBy: FAR_DATE}}`.
|
||||
- :155. Old name: "the instance takes no credential references and no tracker", expecting "(none; got gitea)". New name: "the instance takes only the gitea credential reference and no tracker", adding a vikunja ref and expecting "(gitea; got gitea, vikunja)". The tracker refusal is unchanged.
|
||||
2. **`packages/business/tests/role.test.mjs`** (mos-1332 item 2, accepted). checkCreative checks the service and not the scopes (webby-0178 N1). The scopes are pinned by the load test, which deepEquals each of the six creative role files' credentials to `[{service: "gitea", scopes: ["read:user", "write:repository"]}]`, so a widened scope in any one file is red.
|
||||
- The six-creative-roles load test now pins withinRole `[message.send, library.write]` and credentials `[{gitea, [read:user, write:repository]}]` exactly. Old: `[message.send]` and `[]`.
|
||||
- The M1 block checked record.write and one crossRole case. It now loops over every routed action outside CREATIVE_ACTIONS, keeps the crossRole refusal, and adds the vikunja and host credential refusals. The refusal text changes from "may hold only withinRole message.send" to "may hold only withinRole message.send and library.write".
|
||||
3. **`packages/business/tests/agent-identity.test.mjs:49-50` (N10)** (mos-1333 item 3, option (a); replaces mos-1332 item 3). After 285 the chief's definition declares gitea, so the old second case no longer refuses. Only the fixture instance and the instance name in the regex change. The text after the name is unchanged.
|
||||
- Old: `refuses(() => check((d) => { d.roles.chief.identity = structuredClone(IDENTITY); d.roles.chief.publish = ["stack"]; }),` / `/roles\.chief\.publish needs identity and a gitea definition/);`
|
||||
- New: `refuses(() => check((d) => { d.roles.helper = { definition: "assistant", holder: "mos", identity: structuredClone(IDENTITY), publish: ["stack"] }; }),` / `/roles\.helper\.publish needs identity and a gitea definition/);`
|
||||
- The standalone helper test added in 5f81d0da0 is removed. The fence and its business.test change are reverted.
|
||||
- Grep of `packages/*/tests/*` for "needs identity and a gitea definition": only agent-identity.test :47, :48 and :52. No other reader.
|
||||
- Mutant M9 below.
|
||||
4. **`packages/business/tests/instance-settings.test.mjs:289`** (mos-1332 item 4, accepted). Old: `{ services: [], accepted: true }`. New: `{ services: ["gitea"], accepted: true }`. If the :298 listing changes at the run, the exact new value goes here and Webby hears about it. Anything that removes or weakens a check goes back to Mos.
|
||||
5. **Other fixture edits** (mos-1332 item 5): data only, no ruling needed.
|
||||
|
||||
## Commit plan
|
||||
|
||||
1. Role files, contracts, vocabulary, role.mjs, broker #giteaRole, gitea-grant.test and role.test, plus this file.
|
||||
2a. The mos-1332 commit (5f81d0da0): instance-settings.test:289 and this file. Its business.mjs fence and two fence tests are reverted by the mos-1333 commit.
|
||||
2b. The webby-0178 commit (d79cfe674): B1, B2 and notes.
|
||||
2c. The mos-1333 commit: the fence removed, business.test restored, N10 option (a), the named chief test and this file.
|
||||
2. The fixture commit (mos-1330): helpers.mjs, chief-of-staff.test, business.test (fixture refs and the two new named tests), the loader fixtures above, the example json and README. Mos ruled the old-test points in mos-1332.
|
||||
3. The 1.16 commit: `library.write` in ACTIONS and ROUTED in both `business/src/vocabulary.mjs` and `bus/src/broker.mjs`, citing the PRD 1.16 paragraph (row #1796), with a hard-waits test that it is routed. It's held until Jason's yes and 1.16 lands.
|
||||
|
||||
Until commit 3, every creative role file refuses at load (REQ-WAIT-2: a GATED_ONLY action in a role file). The branch is green only with all three commits.
|
||||
|
||||
## Mutant plan
|
||||
|
||||
Kill rules are written before the runs. Each mutant is one edit, then a restore, a sha256 compare and a clean `git status`.
|
||||
|
||||
| M | Mutant | Source | Expected kill |
|
||||
|---|---|---|---|
|
||||
| M1 | drop `role.definition === STAFF_TO` from #giteaRole | broker.mjs:2138 | gitea-grant.test "X-G1: the chief of staff refuses at raise" |
|
||||
| M2 | CREATIVE_ACTIONS gains `role.end` | vocabulary.mjs | role.test M1 routed-action loop |
|
||||
| M3 | drop the creative services check | role.mjs checkCreative | role.test M1 vikunja and host refusals |
|
||||
| M4 | drop the crossRole check | role.mjs checkCreative | role.test M1 crossRole refusals |
|
||||
| M5 | creative role file without library.write | roles/companion.json | role.test creative-load test |
|
||||
| M6 | chief scopes widened with write:repository | roles/chief-of-staff.json | chief-of-staff.test "the shipped file is exactly the approved list" |
|
||||
| M9 | drop `\|\| !services.includes("gitea")` from the N10 check | business.mjs:166 | agent-identity.test N10's helper case :51-52 (mos-1333 item 5) |
|
||||
|
||||
M7 and M8 targeted the withdrawn fence and are dropped (mos-1333 item 1). M9 mutates a file outside the manifest only to show the N10 test still guards it.
|
||||
|
||||
## Manifest
|
||||
|
||||
Source and roles:
|
||||
- packages/business/src/vocabulary.mjs
|
||||
- packages/business/src/role.mjs
|
||||
- packages/bus/src/broker.mjs (#giteaRole only, sage-2921)
|
||||
- roles/{companion,ideas,research,proofreader,fact-checker,continuity}.{json,md}
|
||||
- roles/chief-of-staff.{json,md}
|
||||
|
||||
Tests:
|
||||
- packages/bus/tests/gitea-grant.test.mjs (sage-2921)
|
||||
- packages/business/tests/role.test.mjs
|
||||
- packages/business/tests/business.test.mjs
|
||||
- packages/business/tests/helpers.mjs (fixture, mos-1330)
|
||||
- packages/business/tests/chief-of-staff.test.mjs (old test, mos-1272)
|
||||
- packages/business/tests/instance-settings.test.mjs
|
||||
- packages/business/tests/lifecycle-settings.test.mjs
|
||||
- packages/business/tests/launch-settings.test.mjs
|
||||
- packages/business/tests/resolve.test.mjs
|
||||
- packages/bus/tests/creative.test.mjs
|
||||
- packages/cli/tests/launcher.test.mjs
|
||||
- packages/business/tests/agent-identity.test.mjs (N10 :51-52 and one named test, mos-1333 items 3 and 4)
|
||||
|
||||
Docs:
|
||||
- packages/business/examples/mosaic-stack.example.json
|
||||
- packages/business/README.md
|
||||
- agents/scrooge/work/queue-285/BUILD.md (this file)
|
||||
|
||||
## Gate checks before in-review
|
||||
|
||||
- `git grep CREATIVE_KINDS -- packages roles scripts` is empty. Checked locally.
|
||||
- Grep the tests for `gitea-not-declared`, and for 64-hex pins on broker.mjs and each changed role file.
|
||||
- Run business.test, stack-release.test, stack-release-external.test and stack-upgrade.test whole, since they read the example json (webby-0178 B2).
|
||||
- Run the bus suite whole (sage-2946 item 1): broker.mjs is in the manifest, and these bus tests read roles/chief-of-staff.json by name: chief-of-staff, fire-revokes-grants, host-run, instances-verb, names, reload, routes-verb. Sage listed five. A git grep finds the seven.
|
||||
- Run each fixture-importing suite whole: the business suite, the bus suite, the cli suite, `scripts/test-config.sh` and every `scripts/test-*.sh`, with TMPDIR under /mnt/storage/scratch/tmp.
|
||||
- Sage confirms the result against mos-1309 item 5.
|
||||
|
||||
## r2 rebase (sage-3578, 10-23)
|
||||
|
||||
- Rebased 34ee4df7d onto origin/refactor 45dca69f6 as scrooge/row285-r2; the old branch is untouched and nothing was force-pushed.
|
||||
- Commit map: 0e369895b to 9a77fff7a, 8c529fb4d to 115889e4a, 5f81d0da0 to aabfdfc60, d79cfe674 to 66e3c3d61, 34ee4df7d to cf8ba6ef4.
|
||||
- `git range-diff 94c33a43c..34ee4df7d 45dca69f6..cf8ba6ef4` gives `=` on all five. `git patch-id --stable` is unchanged for the whole range (72cc8ef5) and for broker.mjs (caa0e756). merge-tree was clean.
|
||||
- Drift 94c33a43c..45dca69f6 (262 commits). On this row's paths only broker.mjs (row 306), bus creative.test and cli launcher.test moved, and they merged clean. roles/, packages/business/ and contracts/ have no upstream change. Row 291 (4874d1e83) and row 308 (30e6bb680) touch the PRD, the requirements json and the tasks and bus requirements tests, which this row does not change. Row 306 touches broker.mjs, access-revoke.test, revoke-window.test and harness runner.mjs.
|
||||
- New and changed upstream tests that read `roles/<name>.json`: bus access-revoke, launcher-released, revoke-window, session-clear, session-compactions, sessions, thread-shares, chief-of-staff and creative. Bus chief-of-staff, creative and tester-writes import business tests/helpers.mjs, as do many cli, webui, harness and plugins tests. No 64-hex pin names a role file or broker.mjs. CREATIVE_KINDS has no reader at 45dca69f6. project-runtime.test pins vocabulary.mjs import-free; this row adds no import.
|
||||
- Gate scope therefore grows: run the bus, business, cli, webui, harness and plugins suites whole, plus the scripts above.
|
||||
|
||||
## Commit 3: library.write routed
|
||||
|
||||
PRD 1.16 landed with row 291 (4874d1e83) and Jason's yes is mos-1354 Q32, so the hold on this commit is gone. `library.write` goes last in ACTIONS in both business/src/vocabulary.mjs and bus/src/broker.mjs, keeping bus hard-waits.test's order-sensitive ACTIONS equal VOCABULARY, and into ROUTED in both, so HARD_WAITS is unchanged. A new business hard-waits test (REQ-WAIT-3, REQ-LIB-6) checks it is in ACTIONS, routed and not GATED_ONLY.
|
||||
|
||||
Open for review, not in scope: the PRD says library.write is granted to the six creative roles only. role.mjs checkCreative bounds what a creative role holds, but nothing refuses library.write in a non-creative role's withinRole. The brief asks only that the chief refuses it under an allowlist validator, and the chief has none. Flagged for Webby and Sage.
|
||||
|
||||
N1 (sage-3593): role validator should refuse library.write on a non-creative role (PRD 1.16 creative-only). Not part of 285. Role files change only through reviewed commits (invariant 7), so a review would see such a grant today, but a refusal at load is the fail-closed form. Webby confirms it in the round, and Sage files it in DEFERRED as a follow-up row at landing.
|
||||
|
||||
Review scope (sage-3593): commits 1-5 are a rebase, so check the range-diff. Commit 3 (8f8b8cf15) is new code, so review it in full.
|
||||
|
||||
## Results
|
||||
|
||||
Not run.
|
||||
@@ -1,8 +0,0 @@
|
||||
# Violet
|
||||
|
||||
Builder seat, Codex gpt-6.1-sol, started by Mos under Q245 (mos-1506). Owns row 297 (the GitHub grant method, handed over from Bushroot).
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread (thread 6229eb89) and works in its own worktree. It has no
|
||||
credentials of its own; it pushes only its own `violet/*` branches through the repository credential helper (mos-0981),
|
||||
never `refactor`, `main` or another seat's branch. Claude seats orchestrate and review (Q245). Registered by Sage on
|
||||
2026-10-23 so the queue can name the seat as row owner (store.mjs isSeatDir).
|
||||
@@ -0,0 +1,154 @@
|
||||
# Row 297: GitHub grant method (#1803)
|
||||
|
||||
Owner Violet (thread `6229eb89-192c-4ca8-9a07-204a33458de3`), reviewer Dewey.
|
||||
Assignment: Mos mos-1498 / Q245, 2026-10-23. Branch `violet/row297`.
|
||||
Brief: `docs/plans/2026-10-23_mvp-readiness-rows.md`, GitHub grant method section.
|
||||
|
||||
## Status: Untested; Q-B3 contract review before implementation
|
||||
|
||||
First commit merges row 198 at `14fe7513c`; second merges its next head `4d8123b0a`.
|
||||
Both are merge commits; Bushroot's commits remain byte-identical. New 198 heads will
|
||||
be merged, never rebased/amended/force-pushed. Row 198 is unverified and its old-test
|
||||
approval is pending. Row 297 cannot land before row 198 passes its own gate.
|
||||
|
||||
Read AGENTS.md, agents/README.md, Bushroot's queue-198 BUILD.md and the supplied brief.
|
||||
Recovered #436b r5 at `1be535aca`, section 10 S1-S6, and inspected row 236's Gitea
|
||||
implementation, current credential schema, CLI and broker integration. Bushroot
|
||||
confirmed there are no prior Q-B3 answers or started row 297 work.
|
||||
|
||||
`DESIGN.md` answers Q-B3, proposes per-method S4, and names the repository identity
|
||||
and renewal consumer boundaries requiring Sage's ruling. No implementation starts
|
||||
before Dewey and Megavolt read the answer, as brief Step 1 requires. Shared Gitea
|
||||
behavior remains unchanged. No credential file or live token was read; research
|
||||
used public official GitHub documentation only.
|
||||
|
||||
## Verification and slot
|
||||
|
||||
- `scripts/mosaic queue next violet` in this worktree refused linked-worktree execution.
|
||||
- Retried in `/mnt/storage/src/mosaic-stack`: reported an unknown rendered view and
|
||||
`nothing` for Violet. No queue file changed. Sage owns registration and queue changes.
|
||||
- No node, test, webui build, gate or mutant run. Sage sage-3614 requires CLEAR first.
|
||||
- Merge commits are explicitly labeled Untested per Mos's assignment.
|
||||
|
||||
## Pre-existing tests changed by Violet
|
||||
|
||||
None. Inherited row 198 test changes belong to Bushroot and are recorded in that
|
||||
row's BUILD.md; Violet has neither changed nor claimed verification of those files.
|
||||
|
||||
## Push and landing
|
||||
|
||||
Only `violet/*` may be pushed. No push, PR, landing or queue movement yet. First push
|
||||
ETA communicated as 20:00 UTC, subject to Q-B3 review. Sage owns the gate and landing.
|
||||
|
||||
## First push: 6af63e1d3 (2026-10-23)
|
||||
|
||||
Pushed only `violet/row297`, normal new-branch push. Packet sent as violet-0004 to
|
||||
Sage, Dewey, Megavolt and Mos with its commit link and the two explicit scope
|
||||
rulings. `git diff --cached --check` passed before the packet commit. Verified
|
||||
14fe7513c and 4d8123b0a are ancestors; only the packet, this BUILD and append-only
|
||||
session/build-log entries differ from 4d8123b0a. No product or test delta.
|
||||
|
||||
Independent inspection after the packet confirms the boundaries: runtime.mjs
|
||||
constructs only giteaGrant/giteaKeys; broker grants.run routes only to Gitea;
|
||||
credential reference dates are calendar days, and publication identity is Gitea
|
||||
fork-based. A GitHub renewal consumer cannot safely reuse those assumptions
|
||||
without the method-specific contract. Implementation waits on the required
|
||||
Q-B3 reading and Sage's scope ruling, not on a routine coding choice.
|
||||
|
||||
## Implementation in progress (sage-3620; dewey-0931; megavolt-0206)
|
||||
|
||||
Both Q-B3 reads completed with no design blocker. Sage selected the App bot on
|
||||
configured repositories, no accounts/forks, and protected mint/renewal/evidence/
|
||||
revoke/expiry in this row. Consumer creation is excluded. DESIGN.md now names
|
||||
R1-R5 and exact clock constants, plus method-specific CLI shapes and workflow
|
||||
exclusion. PLAN.md records inline implementation. Tests were authored before
|
||||
their implementations, but no red or green execution is claimed: Sage's Node/test
|
||||
hold still applies. No test, syntax check through Node, mutant or suite has run.
|
||||
|
||||
Code in progress: private file/HTTPS/admin boundary; event-backed durable grants
|
||||
and generation cleanup; callback-only renewal; broker/CLI/runtime dispatch;
|
||||
additive reviewed schema support without changing role policy files. New tests
|
||||
use a fake HTTPS API and generated scratch CA/key. Their fake origin is injected
|
||||
only through tests; metadata still rejects every HTTP origin, including loopback.
|
||||
|
||||
### Consumer gap for Sage's follow-up row
|
||||
|
||||
No GitHub consumer, git helper, mirror push or publication is implemented. The
|
||||
protected `Broker.githubWithToken` callback is the stop point. Gitea's generic
|
||||
Credentials path has calendar-day dates and fork-specific identity; GitHub tokens
|
||||
remain protected generation files rather than loading into that unguarded path.
|
||||
A consumer row must call the protected function and honor expiry/cancellation.
|
||||
The grant's approval block and README explicitly name this gap.
|
||||
|
||||
### Pre-existing test change, Mos authorized
|
||||
|
||||
`packages/business/tests/role.test.mjs:209`: exactly 1 insertion / 1 deletion.
|
||||
Only the regex changes from `/service must be one of/` to `/unsupported.*scopes/`.
|
||||
The refusal of `{service:"github",scopes:[]}` stays. Reason: sage-3620 authorizes
|
||||
fixed-scope GitHub declarations; caller-provided scopes now fail on the unknown
|
||||
key, rather than on an unknown service. Mos mos-1518 (cc Violet, 19:29:15Z) permits
|
||||
this exact change through Sage. A new-file test in business/tests/github.test.mjs
|
||||
preserves the closed-service-list check with `not-a-service` and the original
|
||||
`/service must be one of/` pattern. No other old test has changed.
|
||||
|
||||
## Static pass before first implementation push
|
||||
|
||||
Additional new tests cover a known token left by an interrupted mint (no adoption,
|
||||
remote cleanup), unsafe slot modes/symlinks, per-agent busy claim, admin rotate
|
||||
identity/binding invalidation, fire cleanup/refusal and the lifecycle delete's
|
||||
first revoke job. No existing fixture or existing test other than the authorized
|
||||
regex changed. Static review corrected the grant.call method/op field collision
|
||||
and added revocation sequence binding so an old unspent approval cannot revive a
|
||||
revoked grant. Both corrections are unexecuted and remain unverified.
|
||||
|
||||
No idle GitHub startup sweep is emitted for a business with no GitHub residue.
|
||||
This avoids changing unrelated runtime evidence. The callback's cancellation
|
||||
signal is also aborted at the token safety deadline, not only on explicit revoke.
|
||||
|
||||
Static dependency inspection found createdCredentials in instance-settings.mjs
|
||||
would generate an invalid day-based `credentials.github` reference for a new
|
||||
instance. It now excludes this protected grant declaration, as it excludes host;
|
||||
a new business/github.test.mjs case pins the human add behavior. This is local
|
||||
schema integration needed by the new declaration, not new agent grant authority.
|
||||
|
||||
## Row198 moving base: 397ef58be (sage-3687, 2026-10-23)
|
||||
|
||||
Merged Bushroot's fix head `397ef58bebef2e265e6332bc3ede05de30c4624c`
|
||||
intact after `ab6417475`. No rebase, amend or force push. His round-3 review
|
||||
remains open; this is a moving, untested dependency, not acceptance evidence.
|
||||
Row297's own source/test candidate is still `53a454185`.
|
||||
|
||||
Sage sage-3681 allocated the five-new-file first baseline/fix run to T6, about
|
||||
21:55 UTC, with an explicit CLEAR still required. No Node, tests, browser,
|
||||
webui build, suites or mutants have run in this session. Consumer stop/gap at
|
||||
Broker.githubWithToken is accepted by sage-3681. Dewey reviews the row297 delta;
|
||||
the eventual gate must use the latest intact row198 fix head.
|
||||
|
||||
## Static review dewey-0944 / dewey-0946 follow-up (UNTESTED)
|
||||
|
||||
Dewey found no source blocker in 53a454185 and confirmed the clean carried delta
|
||||
at 5ca83173e. Required M1: add a per-path SHA-256 list for the 24 row297 paths,
|
||||
then report that digest file's own SHA separately to avoid a self-reference.
|
||||
The list will use git blob bytes at the final head sent for the gate.
|
||||
|
||||
Lifecycle-delete coverage now includes the second request completing exactly one
|
||||
instances.set write after the protected revoke job. New tests are unexecuted.
|
||||
N1: sweep closes an active generation at its conservative bound with reason
|
||||
expired, preserving the durable grant for a later renewal. N2: installation calls
|
||||
require an explicit valid token, with no App JWT fallback. N7: grants.run checks
|
||||
keys/identifier before approval lookup. Each change has new-file regression
|
||||
coverage, with Node execution still held for Sage CLEAR.
|
||||
|
||||
N3 is documented behavior, retained for Gitea parity: human admin remove can
|
||||
invalidate cleanup while residues exist; serviceLeft remains until a matching
|
||||
slot returns or conservative expiry. N4: official API-version source added to
|
||||
DESIGN.md; public GitHub docs list 2026-03-10 as supported. N5: full event scans
|
||||
remain acceptable at MVP volume; no cache introduced. N6 for Sage: preserve both
|
||||
BUILD-LOG.md and docs/SESSIONS.md seat appends alongside refactor's appends when
|
||||
landing; do not replace either append-only history.
|
||||
|
||||
Three additional mutants requested by Dewey have named new-file killers:
|
||||
- accepting DELETE 401 as revoked: `mutant: closeToken must not count a 401 as proven revocation`;
|
||||
- treating status 0/5xx as not-issued: `lost mint response stays visible through restart until conservative expiry equality` and `mutant: mint 500/503 is uncertain issuance and stays serviceLeft`;
|
||||
- dropping recheck's controller.signal.aborted: `mutant: shutdown abort alone prevents a pending generation from applying` (no revocation event, so the signal is the only veto).
|
||||
No mutant has run; every kill still needs gate evidence.
|
||||
@@ -0,0 +1,221 @@
|
||||
# Row 297: Q-B3 answer and proposed GitHub method contract
|
||||
|
||||
Violet, 2026-10-23. Issue #1803. Reviewer Dewey. Proposal for review before code.
|
||||
Source contract: #436b r5 section 10, `agents/megavolt/work/436b/DESIGN.md`
|
||||
at `1be535aca`. Row 236 is already built; this proposal changes none of its behavior.
|
||||
Row 198 dependency: merged `14fe7513c`, then `4d8123b0a`, preserving every commit.
|
||||
|
||||
## Q-B3: S1-S6
|
||||
|
||||
| Item | Answer |
|
||||
| --- | --- |
|
||||
| S1 | Agree. Keep `{method, agent, rotateBy, target}`, with `method: "github"` and `target: {projects: [...]}`. Code fixes permissions; the caller cannot name permissions, repository IDs, installation IDs, or an API origin. Resolve projects from reviewed business configuration, and bind the resolved identities in the decision. |
|
||||
| S2 | Agree. Keep `<configDir>/secrets/grants/<business>/github/admin`, human set only, never an agent service. For GitHub this contains an App RSA private key, not a PAT. Nonsecret metadata identifies the App, installation, account ID, API origin, set time and rotate-by day. One installation per business and method. Only Jason performs the live bootstrap. |
|
||||
| S3 | Agree. Every network call, including preflight, renewal, verification and remote undo, goes through reserved `action.allowed` `grant.call` then `grant.result`, paired by `callId`. Preserve the grant actor and the kind filter, and use `grantDecision` outside the single consume record. No raw response, key, JWT, installation token, authorization header or token digest enters evidence. |
|
||||
| S4 | Per method, as section 10 permits. GitHub installation tokens expire within one hour. Recommend a durable approved repository grant, with short-lived token generations renewed only by protected code inside that grant's unchanged binding and rotate-by deadline. Never treat the grant's rotate-by day as a token's expiry. Detailed proposal below. Gitea's mint/issue/settle sequence stays unchanged. |
|
||||
| S5 | Agree with local-first order. Block new issuance/use and abort the agent's pending generation first; durably remove the active grant next; revoke each known token remotely last. Keep unresolved generations in `serviceLeft` and retry with sweep. An unreturned token cannot be revoked by ID; its bounded expiry must remain visible until elapsed. Do not uninstall or suspend a shared installation to revoke one agent. |
|
||||
| S6 | Agree. Method-specific checks at admin set, every executor start/renewal, and remote undo. Verify App identity, installation identity/account, non-suspension and the required permissions. Pin repository IDs and verify minted reach across every page. No version-number analogy to Gitea; unsupported responses or absent capabilities refuse closed. |
|
||||
|
||||
## Provider facts
|
||||
|
||||
Checked against official GitHub documentation on 2026-10-23; these are documentation
|
||||
findings, not live probes. No credential file was read and no authenticated API call was made.
|
||||
|
||||
- [Create an installation access token](https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app):
|
||||
App JWT authentication; explicit `repository_ids` and `permissions` narrow issuance.
|
||||
Omitting either uses the installation's broader defaults. Tokens expire after one hour.
|
||||
Token format is opaque; no fixed-length or 40-character assumption.
|
||||
- [Generate an App JWT](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app):
|
||||
RS256 with bounded `iat`/`exp`, at most ten minutes ahead, authenticated with Bearer.
|
||||
- [Installation endpoints](https://docs.github.com/en/rest/apps/installations):
|
||||
`GET /installation/repositories` reports token-accessible repository IDs and total count.
|
||||
`DELETE /installation/token` revokes the token used to authenticate that request.
|
||||
This does not provide Gitea's recorded-token-ID deletion or `GET /token` identity contract.
|
||||
|
||||
## Approaches and recommendation
|
||||
|
||||
1. Mint once per Approve, store with its actual expiry, then require another Approve.
|
||||
Smallest lifecycle but forces another human approval within an hour of each use.
|
||||
2. Durable approved grant plus protected renewal, recommended. The approval names the
|
||||
agent, exact project/repository binding, fixed permissions and latest renewal date.
|
||||
Token expiry is separate. This needs renewal, interruption and revoke-race coverage.
|
||||
3. A human PAT or copied Gitea account/user lifecycle is not proposed. It does not supply
|
||||
the App issuance contract this brief calls out, and GitHub is not an account-creation
|
||||
API equivalent to Gitea's site-admin path.
|
||||
|
||||
The recommendation is Violet's proposal, not an owner decision. Q-B3 must be read by
|
||||
Dewey and Megavolt before implementation. Sage must settle the scope points below.
|
||||
|
||||
## Proposed per-method S4
|
||||
|
||||
The durable authority is the approved binding, not a long-lived token. Raise is chief-only;
|
||||
Approve and admin-slot writes retain their existing human gates. Preflight checks and
|
||||
binding recheck happen before consume, under an agent claim. Only consume has `decision`.
|
||||
|
||||
At grant time, resolve the configured projects to immutable repository IDs and bind the
|
||||
definition, relevant role/project configuration and admin metadata. Show the App and
|
||||
installation identity, repositories, fixed permissions, grant deadline and automatic
|
||||
renewal behavior in the human approval block. Permission proposal: `contents: write`
|
||||
plus GitHub's implicit `metadata: read`; no workflow, administration, issue or PR write
|
||||
permission. Adding any permission requires a reviewed method change.
|
||||
|
||||
Before every generation: check the grant is active, unchanged, unexpired, the agent is
|
||||
still eligible, and processing/revocation does not prohibit the operation. Re-run the
|
||||
capability floor. Write the mint intent before POST and the result after it. POST always
|
||||
includes the exact nonempty repository ID list and the fixed permission map. Verify the
|
||||
returned expiry, permissions and all token-accessible repositories before making the
|
||||
generation usable. A missing/invalid/past expiry or wider reach revokes the candidate and
|
||||
refuses. Never use a stale token during a failed renewal.
|
||||
|
||||
Use the actual provider expiry as a timestamp with a safety margin. Token replacement
|
||||
must be atomic with respect to revoke: a revoke that wins while mint/verify is pending
|
||||
prevents publication of that candidate. Only one generation per agent runs at once.
|
||||
New generations cannot widen the original approved repository IDs; changed configuration
|
||||
requires a fresh decision. The App private key and JWT never enter the agent credential
|
||||
store, and the worker never gets the App key or authority to renew.
|
||||
|
||||
There is no claim that a stored token can be identified by a Gitea-style token ID.
|
||||
On restart, an interrupted generation is held out of use. Any recoverable token is
|
||||
verified/revoked through its own installation authentication; an ambiguous generation
|
||||
is not adopted as an applied grant. Where the token was minted but its response/file was
|
||||
lost, record unresolved remote reach until the documented maximum lifetime has elapsed.
|
||||
An unanswered mint uses a conservative expiry bound that includes the request deadline;
|
||||
the implementation must document the clock/response assumptions rather than claim remote
|
||||
revocation succeeded. A new generation never hides an older unresolved one.
|
||||
|
||||
The concrete token storage and consumer hook are still scope decisions below. A generic
|
||||
`Credentials.issue` using only `YYYY-MM-DD` would lose the token lifetime and is unsuitable
|
||||
without a method-specific timestamp guard on every use and restart load.
|
||||
|
||||
## Scope points for Sage, with reviewer input
|
||||
|
||||
These are unresolved integration boundaries found in the existing implementation:
|
||||
|
||||
1. Repository authority and identity: `SERVICES`, credential dates, role identity and
|
||||
publish code currently implement Gitea/Vikunja/host, not GitHub. Recommend GitHub App
|
||||
access to the configured exact repositories, with the App bot as service identity;
|
||||
no creation of GitHub user accounts or automatic forks. If row 297 must reproduce
|
||||
per-agent forks, that needs a separate repository/identity contract before code.
|
||||
2. Renewal consumer: recommend protected on-demand renewal for the GitHub credential
|
||||
consumer, with timestamp checks and no worker mint endpoint. Confirm whether row 297
|
||||
owns that consumer integration (including GitHub git publication), or only the grant
|
||||
executor/management surface. A stored expiring token with no guarded consumer is not
|
||||
a complete renewal implementation.
|
||||
|
||||
## Acceptance and proposed test manifest
|
||||
|
||||
Fake API only; never a real GitHub token or a call to github.com/api.github.com. Bind a
|
||||
loopback fake explicitly in tests; production origins come only from the admin contract.
|
||||
Prefer new test files. No pre-existing test changes without Mos's OK through Sage.
|
||||
|
||||
Mirror row 236 X-G1 through X-G15 where meaningful: binding refusals and no writes;
|
||||
admin floor/write-on-refusal; approval end to end; foreign/rebound identities; failure
|
||||
matrix and revoke leftovers; interruption/restart; approval drift; call/result ordering
|
||||
and secrecy; admin replacement invalidation; token replacement ordering; human gates,
|
||||
single consume and busy; paginated exact reach; evidence reservation; fire/delete;
|
||||
look-alike evidence ignored. Explicitly mark Gitea-only user creation/disable, version
|
||||
floor, token-name and token-whoami cases as replaced by App/installation identity,
|
||||
fixed permissions, expiry and generation checks, not silently dropped.
|
||||
|
||||
Additional expiry cases: expiry before use; past/malformed/missing or excessive lifetime
|
||||
in mint response; controlled-clock renewal; renewal refusal after grant rotate-by;
|
||||
changed installation/repository/definition/admin binding; failed renewal with no stale
|
||||
use; restart with an interrupted mint; revoke during pending mint/verify; unknown remote
|
||||
token visible until its conservative expiry bound; no shared-installation deletion.
|
||||
|
||||
Mutants mirror these claims: omit explicit IDs; omit permissions; skip identity/floor,
|
||||
binding/expiry/reach check; read only one page; publish before verify; mint before intent;
|
||||
result before call; skip claim; revive after revoke; keep expired token after renewal
|
||||
failure; renew after grant deadline; clear unresolved reach early; perform remote undo
|
||||
before local revocation; bypass human/reserved-evidence gates; log a provider secret.
|
||||
|
||||
Full touched suites, every `scripts/test-*.sh`, and mutants wait for Sage's CLEAR. Dewey
|
||||
approves the final manifest. Row 297 lands only after row 198's own gate passes.
|
||||
|
||||
## Build design: rulings and review inputs (2026-10-23)
|
||||
|
||||
Sage sage-3620 accepts App bot access to exact configured repositories, no accounts
|
||||
or forks, and row297 ownership of protected mint/renewal/evidence/revoke/expiry.
|
||||
New GitHub mirror push, publication and credential helpers are excluded. Stop on
|
||||
the protected side where the Gitea consumer path cannot carry GitHub safely; Sage
|
||||
files the consumer gap. Dewey dewey-0931 and Megavolt megavolt-0206 read Q-B3 and
|
||||
report no design blocker. Approach 2 is selected under these rulings.
|
||||
|
||||
### Binding and protected interface
|
||||
|
||||
Proposed additive configuration: a definition declares `{service: "github"}` with
|
||||
no caller scopes; the business role declares `github: {projects: [...]}`, and each
|
||||
such project has `remote` plus positive safe-integer `githubId`. These declarations
|
||||
are separate from Gitea `identity` and fork-based `publish`. Unknown keys, including
|
||||
account/fork requests, refuse. No role policy file or live configuration is changed.
|
||||
The grant binding stores the exact project IDs, repository IDs and remote names,
|
||||
role/definition digest and admin metadata digest. Renewal compares those immutable
|
||||
snapshots to current configuration; it never adopts configuration changes as new
|
||||
authority. GitHub scope approvals remain human-only (no Q228 scope fast path).
|
||||
|
||||
Expose renewal only as a trusted host function `withToken(business, agent, fn)` in
|
||||
protected code, passing `(token, signal)` to the trusted callback. No worker or
|
||||
socket verb returns a token or mints one without a durable approval. Token files
|
||||
are generation-specific protected files, never evidence. Do not load these into
|
||||
`Credentials`, whose calendar-day guards and Gitea helpers cannot enforce expiry.
|
||||
That is the consumer gap: publication/credential-helper wiring needs another row.
|
||||
|
||||
### Dewey R1-R5 and Megavolt notes
|
||||
|
||||
- R1: Before paginated verification, require exact mint `permissions` equality to
|
||||
`{contents: "write", metadata: "read"}`, `repository_selection === "selected"`,
|
||||
and returned repository IDs equal to the bound set, without duplicate IDs.
|
||||
Missing fields, subsets and supersets refuse and revoke the candidate. Mutants
|
||||
accept extra permissions or extra repositories; both must be killed.
|
||||
- R2: Production metadata accepts normalized HTTPS origins only; no HTTP or test
|
||||
bypass. Tests run a fake HTTPS GitHub API with an injected scratch CA and optional
|
||||
transport, following row236. A named admin-set test rejects HTTP before any call.
|
||||
- R3 C1 replacement: pinned API version and required App/installation/token-response
|
||||
shapes, refusing unavailable/unsupported capabilities. No Gitea version comparison.
|
||||
C2 replacement: RSA key's signed JWT authenticates the configured App ID and the
|
||||
installation belongs to it and is not suspended. C3 replacement: installation
|
||||
permissions cover the fixed map; the issued token's map must equal it exactly.
|
||||
C4 replacement: App ID, installation ID and account ID equal the slot metadata.
|
||||
No claim that the App is a dedicated site-admin account with an empty repo list.
|
||||
- R4 constants: `TOKEN_LIFETIME_MS = 3_600_000`, `CLOCK_SKEW_MS = 60_000`,
|
||||
`TOKEN_MARGIN_MS = 60_000`, `CALL_TIMEOUT_MS = 15_000`. A generation is usable only
|
||||
before `expiresAt - TOKEN_MARGIN_MS`. Mint expiry cannot exceed the request start
|
||||
plus lifetime plus skew. Unknown mint reach remains until request start plus
|
||||
request timeout plus lifetime plus skew. Equality at each deadline is expired.
|
||||
These assume provider expiry is bounded by the documented lifetime and skew;
|
||||
a request that never returns has no claim of proven remote revocation. Each
|
||||
boundary has a controlled-clock test. JWT uses `iat = nowSeconds - 60` and
|
||||
`exp = nowSeconds + 540` (less than the ten-minute maximum).
|
||||
- R5: A changed project ID, remote, allowed-project list, role definition or admin
|
||||
slot between generations refuses renewal, even if the replacement configuration
|
||||
would independently grant access. Re-approval is required.
|
||||
- Admin CLI: separate `--app-id`, `--installation-id`, `--account-id`, `--url`,
|
||||
`--rotate-by` shape. RSA private key on bounded stdin; never misuse `--account`
|
||||
for an installation ID. Rotation replaces a Jason-provided App key on stdin;
|
||||
this method does not pretend it can manufacture/delete GitHub App private keys.
|
||||
- Approval block and README explicitly exclude `.github/workflows/` writes and
|
||||
account/fork creation. No workflow, issue, PR or administration permission is
|
||||
granted. All fake JWT claims use the injected clock.
|
||||
|
||||
### Review follow-up (dewey-0944 / dewey-0946)
|
||||
|
||||
`API_VERSION = "2026-03-10"` is pinned from GitHub's official
|
||||
[API Versions](https://docs.github.com/en/rest/about-the-rest-api/api-versions)
|
||||
page, checked 2026-10-23. It lists that version as supported and specifies the
|
||||
`X-GitHub-Api-Version` request header. This is a public-document check, not a
|
||||
live API probe; fake tests do not reach GitHub.
|
||||
|
||||
`grants sweep github` may close an expired active generation once its conservative
|
||||
`until` bound has elapsed. It deletes that generation's private file and records
|
||||
`grant.token.closed` with reason `expired`; the durable grant stays renewable.
|
||||
It must not close a live active generation early. Installation endpoints now
|
||||
require an explicit valid installation token and cannot fall back to an App JWT.
|
||||
Manual run validates keys and identifier before any approval lookup.
|
||||
|
||||
Admin remove intentionally retains the human's existing ability to remove the
|
||||
local slot while grants or residues exist (Gitea parity). This invalidates future
|
||||
use/renewal, but known residue cannot be remotely retried until a matching slot
|
||||
returns; expiry still closes it at its conservative bound. Status/sweep do not
|
||||
claim remote revocation in that case. The App itself and private keys are not
|
||||
removed at GitHub. Event projection is a full read at MVP volume; no cache is
|
||||
introduced whose invalidation could weaken revocation.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Row297 verification manifest (UNTESTED)
|
||||
|
||||
Candidate pending tests. Dewey approves this manifest; Sage owns CLEAR and gate.
|
||||
All listed tests are fake HTTPS API or scratch configuration. No live key/token,
|
||||
no GitHub service contact, and no public consumer is installed. No Node/test run
|
||||
has occurred in Violet's session. Static `git diff --check` is the only check run.
|
||||
|
||||
## Files and permission boundary
|
||||
|
||||
- New protected modules: bus/src/github-api.mjs (HTTPS, JWT, App slot and floor),
|
||||
github-files.mjs (private atomic files), github-grant.mjs (event projection,
|
||||
approval binding, renewal, generation files, revoke/recovery).
|
||||
- Broker/runtime add the method to existing grant gates and lifecycle revocation;
|
||||
no token-returning or renewal socket verb. Existing Gitea code paths remain.
|
||||
- Business role/instance parsing: a role declaration `{service:"github"}` takes no scopes; role.github
|
||||
names allowed projects; project.githubId pins its provider identity. Generic
|
||||
token-reference schema stays closed to github. No role JSON policy is changed.
|
||||
- CLI github-grants.mjs handles method-specific admin identity flags and bounded
|
||||
stdin. grants.mjs dispatches the method without changing Gitea flag shapes.
|
||||
- New tests: bus fake-github-api.mjs, github-api.test.mjs,
|
||||
github-grant.test.mjs, github-broker.test.mjs; business github.test.mjs;
|
||||
cli github-grants.test.mjs. README and packet/build records document the limits.
|
||||
- One pre-existing test change: business/tests/role.test.mjs:209, exactly 1/1,
|
||||
regex only. Mos mos-1518 authorization and replacement closed-list test are in
|
||||
BUILD.md. No other pre-existing test is edited by Violet.
|
||||
|
||||
## Row236 X-G correspondence
|
||||
|
||||
| Row236 claim | GitHub replacement / named coverage |
|
||||
| --- | --- |
|
||||
| X-G1 binding, no writes | grant binding rejects scopes/account/fork keys; business binding refuses missing declaration/project/ID; R5 drift refuses before consume |
|
||||
| X-G2 C1-C4 | C1 pinned API/required response shapes; C2 signed App authentication and App/installation identity/non-suspension; C3 installation permissions cover the fixed map; C4 App, installation and account IDs match metadata. API tests name identity, suspension, floor, HTTP and RSA refusals |
|
||||
| X-G3 approved end to end | broker chief raise, human Yes, exact durable grant, protected callback, revoke, fake token gone |
|
||||
| X-G4 foreign user | No user account lifecycle. Replaced by App/installation/account identity, exact configured repository IDs/names, no account/fork request |
|
||||
| X-G5 failures/leftovers | invalid mint candidate revoked; remote delete failure is serviceLeft; failed renewal never calls consumer; revoke wins pending renewal |
|
||||
| X-G6 restart | active generation needs its recorded binding/timestamp; lost response remains until controlled bound; interrupted call without result is visible; no interrupted generation adoption |
|
||||
| X-G7 binding drift | project ID, remote, allowed-project list, definition and admin metadata between generations; preflight refusal unspent, restart spends interrupted |
|
||||
| X-G8 calls/secrecy | intent and call visible while fake receives request; result after call; only consume has decision; no key/JWT/token in evidence |
|
||||
| X-G9 admin rotation | Human replacement key, same App/installation/account/origin, setAt changes binding; no GitHub private-key creation/deletion claim |
|
||||
| X-G10 replacement | new generation active before old token DELETE, old token removed afterward |
|
||||
| X-G11 callers/claim/spend | human-only admin/run; chief-only raise; no socket renewal/token; old approval invalid after revoke; single consume across renewal |
|
||||
| X-G12 reach | exact mint IDs/permissions/selected mode, paginated GET through page 2, repository names checked; wider GET reach refuses and deletes candidate |
|
||||
| X-G13 reserved namespace | existing grant actor/trigger retained; new direct human insert refusal test; existing grant-migration tests cover shared reservation |
|
||||
| X-G14 fire | GitHub local revoke; fire completes only after remote cleanup; remote leftover blocks fire. No shared installation suspend/uninstall |
|
||||
| X-G15 evidence kind | a look-alike applied record under action.refused cannot establish a grant |
|
||||
|
||||
Gitea-only version ordering, user nonce/adoption/disable, token-name minting,
|
||||
admin token minting and token-ID whoami settlement are replaced, not copied.
|
||||
No Gitea account or fork API is used by the GitHub method.
|
||||
|
||||
## Dewey R1-R5
|
||||
|
||||
R1 tests mint permission extra/missing/higher keys, selected mode, repository
|
||||
extra/missing/duplicate IDs before paginated verification. R2 tests admin HTTP
|
||||
refusal with no loopback exemption; fake HTTPS injects a scratch CA. R3 maps C1-C4
|
||||
above. R4 constants are lifetime 3,600,000 ms, skew/margin each 60,000 ms, request
|
||||
15,000 ms; JWT clock claims, margin equality, excessive expiry and lost-response
|
||||
bound equality have controlled-clock tests. R5 tests configuration changes between
|
||||
generations, preserving the original binding rather than granting from new config.
|
||||
|
||||
## Runs requested, not yet authorized/executed
|
||||
|
||||
1. New-file red/green or baseline diagnostic run: `node --test` on the five new
|
||||
test files. Tests were written first; the test hold prevented observing red
|
||||
before implementation, so no TDD execution evidence is claimed.
|
||||
2. Touched suites: business, bus, cli. Full every `scripts/test-*.sh` on Sage's gate.
|
||||
If the gate includes webui, roster's required editor build applies; Violet
|
||||
has not run a webui build or touched row198's inherited webui tests.
|
||||
3. Mutants: run on a disposable copy only after a green baseline. Every survivor
|
||||
is an open failure, not a waived requirement. Planned mutations: HTTPS origin,
|
||||
RSA restriction, App/installation/account identity, permission floor, explicit
|
||||
mint IDs/permissions, exact response maps/selected mode/IDs, expiry ceiling,
|
||||
margin boundary, durable config/admin digests, revocation epoch, expiry bound,
|
||||
intent/call ordering, local-before-remote revoke, evidence kind, auto-dispatch,
|
||||
lifecycle inclusion and unknown-token retention. Actual kill evidence is pending.
|
||||
|
||||
## Remaining evidence gaps before acceptance
|
||||
|
||||
No test has run, so no pass count or mutant kill is claimed. New tests now cover
|
||||
a known interrupted token, unsafe file modes/symlinks, busy claim, admin rotate
|
||||
and the lifecycle-delete revoke job. Runtime verification may require fixes or
|
||||
additional new-file coverage; lifecycle-delete now includes its second request
|
||||
completing after the revoke job. These are in-scope gaps, not deferred acceptance. The consumer gap is separately
|
||||
ruled out of scope in sage-3620 and recorded in BUILD.md.
|
||||
|
||||
## Byte manifest (Dewey M1)
|
||||
|
||||
`candidate-manifest.sha256` lists SHA-256 of the git blob bytes of the 24 paths
|
||||
reviewed for row297, including this MANIFEST.md. It excludes itself to avoid a
|
||||
self-reference. Its own SHA-256 and the final pushed head are reported together
|
||||
in Violet's review handoff message. After any byte change, regenerate the list
|
||||
and report a new checksum before the gate. Inherited row198 paths are excluded.
|
||||
|
||||
The three extra mutants from dewey-0944 are required: accept DELETE 401 as
|
||||
revoked (killer `mutant: closeToken must not count a 401 as proven revocation`);
|
||||
count 0/5xx as not-issued (lost-response test and `mutant: mint 500/503 is uncertain
|
||||
issuance and stays serviceLeft`); drop the controller abort check (`mutant:
|
||||
shutdown abort alone prevents a pending generation from applying`). All kill
|
||||
evidence remains pending Sage CLEAR.
|
||||
@@ -0,0 +1,69 @@
|
||||
# GitHub grant method implementation plan
|
||||
|
||||
Goal: implement row297's protected GitHub App grant and renewal lifecycle.
|
||||
Architecture: a GitHub transport/slot module and event-backed executor, integrated
|
||||
through the broker's existing human grant gates. No GitHub consumer is added.
|
||||
Stack: existing Node ESM, node:crypto/node:https/node:fs, append-only bus events.
|
||||
Spec: DESIGN.md, including sage-3620, dewey-0931 and megavolt-0206.
|
||||
Execution: Violet inline; Dewey reviews the final manifest. No subagent dispatch.
|
||||
|
||||
## Global constraints
|
||||
|
||||
Fake HTTPS API and scratch CA only. No credential files, live token, real API call,
|
||||
Node or test execution before Sage CLEAR. No old-test edits without Mos approval.
|
||||
Preserve row198 commits; push only violet/*; no amend or force push. No schema
|
||||
migration for grant state: use reserved grant evidence with the existing actor.
|
||||
|
||||
## Review focus
|
||||
|
||||
- Lost mint responses remain visible until conservative expiry; never claim undo.
|
||||
- Revocation racing renewal cannot publish a late token or restore the grant.
|
||||
- Malformed provider responses cannot authorize a wider token.
|
||||
- Restart cannot adopt an interrupted generation or changed configuration.
|
||||
- Refusals and audit records cannot leak RSA/JWT/token values.
|
||||
|
||||
## Task 1: configuration, HTTPS transport and admin slot
|
||||
|
||||
Files: new bus github-api.mjs and github-files.mjs; modify business role/business
|
||||
parsing; new business github.test.mjs and bus github-api.test.mjs/fake-github-api.mjs.
|
||||
Interfaces: checkGrant, binding configuration checks, githubApi({configDir, ca,
|
||||
transport, now}), adminMeta/setAdmin/removeAdmin/checkAdmin/mint/repos/revoke.
|
||||
|
||||
- [ ] Write tests first for HTTPS-only metadata, RSA-only input, App/installation/
|
||||
account identity, suspension, permission floor and fixed mint response equality.
|
||||
- [ ] Run red tests on Sage CLEAR; record the exact failure, no invented red claim.
|
||||
- [ ] Implement strict additive config and slot/transport with no raw response audit.
|
||||
- [ ] Run new tests green on CLEAR; no old tests changed.
|
||||
|
||||
## Task 2: durable executor and renewal
|
||||
|
||||
Files: new bus github-grant.mjs; new bus github-grant.test.mjs.
|
||||
Interfaces: configure({store,business,event,approved,consume,grantable,working});
|
||||
raiseBinding, run, withToken, revoke, sweep, status, reconcile, idle, close.
|
||||
The broker supplies private approved/consume/event callbacks, not a worker verb.
|
||||
|
||||
- [ ] Write lifecycle/expiry/recovery tests before implementation, including all
|
||||
R1-R5 cases, revoke races, config drift and lost-response bounds.
|
||||
- [ ] Run red tests on CLEAR, then implement evidence projection, generation files,
|
||||
exact reach, expiry-aware guarded use, local-first revoke and retryable cleanup.
|
||||
- [ ] Verify no untrusted token-returning endpoint and no token in any evidence.
|
||||
- [ ] Run green and mutant checks on CLEAR; record every unexecuted check honestly.
|
||||
|
||||
## Task 3: broker, runtime and CLI integration
|
||||
|
||||
Files: broker.mjs, runtime.mjs, cli grants.mjs; new github-broker.test.mjs and cli
|
||||
github-grants.test.mjs; bus/cli README edits. Existing Gitea behavior stays intact.
|
||||
|
||||
- [ ] Write tests first for chief raise, human approval, rejected caller scopes,
|
||||
admin human-only, run claim, status/revoke/sweep, fire/delete and restart.
|
||||
- [ ] Dispatch github before existing Gitea branches; connect reserved writer and
|
||||
consume, automatic run on human yes, reconcile, lifecycle revoke and shutdown.
|
||||
- [ ] Add method-specific CLI admin flags and RSA stdin; never print secrets.
|
||||
- [ ] Document fixed scope, workflow/account/fork refusals and consumer gap.
|
||||
- [ ] Request CLEAR for touched suites and every scripts/test-*.sh; run only after
|
||||
granted. Present actual diff/numstat, manifest and mutants to Dewey and Sage.
|
||||
|
||||
## Acceptance ledger
|
||||
|
||||
Static checks can run without Node. All red/green runs, suites, mutants and gate
|
||||
are currently unexecuted. No success claim is implied by this plan.
|
||||
@@ -0,0 +1,24 @@
|
||||
6b54b05eeabaee822ad9ec96e63ba007c6d52b7e66100ab4dc051fa414af103f BUILD-LOG.md
|
||||
e9dcab5e3125660f177989e6bf726da596149aa237c4f179fa24c13aefc009e1 agents/violet/work/queue-297/BUILD.md
|
||||
156db930b2e455c7dc8deffdd48fd832d898b32e5c8136f014bd4af84f50f526 agents/violet/work/queue-297/DESIGN.md
|
||||
99c99d4ca9bdf1397869247c454f45fd0c3fba8fb47b0b9bd424e8532b7d2453 agents/violet/work/queue-297/MANIFEST.md
|
||||
929d9a5fc28bdcdfd5c7db5cb818186633954d5f60ad2a03e129d062bcb4f725 agents/violet/work/queue-297/PLAN.md
|
||||
d599b0f0d4c248fba5a32b4f04022ac4676a4cb4029d32d29e0ab1f10ade9162 docs/SESSIONS.md
|
||||
bed3be579c94c16f6ad840b298f589339e1e0d58e3fca13571e2262a3502aace packages/bus/README.md
|
||||
e46abbba0514be8bad8c53ec3a0b19133746bb96318c13923d66e9f54c6b2a34 packages/bus/src/broker.mjs
|
||||
1f09e6a8e3b814a475fb1f7a91bd6ee670e08d7c76399212134e107a2ec4b499 packages/bus/src/github-api.mjs
|
||||
2f3fce1244446c4be93cdc8ab744de710758bdebb1106e09badd25fa57c355ff packages/bus/src/github-files.mjs
|
||||
bca3836d62525620d9af372c0c2039e3405579290e8919a1f4f6a35baafee6da packages/bus/src/github-grant.mjs
|
||||
83a2d294b87e900fcb8357724941be1475ce5ef6afde237a149eb5c138f087b2 packages/bus/src/runtime.mjs
|
||||
c6e6884471a884663db9446f662669d0977009f6f2f24f2a388e29104c9cb960 packages/bus/tests/fake-github-api.mjs
|
||||
9e0f43e5b25c3a77d746dbd1b956ff767406548fda2528cab51c8d5b38157e5b packages/bus/tests/github-api.test.mjs
|
||||
8c78d2ad8aad12632ee40eea8212fd360281bc11b4ae900bf3e38f4618c5d6db packages/bus/tests/github-broker.test.mjs
|
||||
544f15f07cfaa76394da8618d63573ef91a7c0ebf3f65a6f39339c015e4f96a5 packages/bus/tests/github-grant.test.mjs
|
||||
87dc329f40f5783a4c2e85c9a1d8c94b6dc4889cdce7191e79af852ab251fac7 packages/business/src/business.mjs
|
||||
c022a8aa32d84a189b585696b207001ba690660ed1c83c6038be4031703550a4 packages/business/src/instance-settings.mjs
|
||||
a68518d83a039ecc82ddb4cfbbc6234df8eac384acc78499c94b0d0945a7717b packages/business/src/role.mjs
|
||||
956981b7ab8f633054831a00133d44a43b5bf9be7c6fd6a326ce20b769c9f822 packages/business/tests/github.test.mjs
|
||||
445c224530f5545fd4e1e734c7b36c407db6f1b182b6c6ef65e5c9d91fd16ac0 packages/business/tests/role.test.mjs
|
||||
410b3c3e50c476de39720b0906dcf077739d029e293b444cc6e4a13bb24a9031 packages/cli/src/github-grants.mjs
|
||||
9fd3589258b83e7cf2c5828811251d0d0a24087a8bded926a44144e3b4a07438 packages/cli/src/grants.mjs
|
||||
2886fbc1c4c6f0dc22d365e76641f13b7e31f46123b9e7b3e280d2ff79ea52e9 packages/cli/tests/github-grants.test.mjs
|
||||
+2
-2
@@ -1593,5 +1593,5 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-23T17:48:43Z | Scrooge (629b6cd3) | Row 252 Console inbox Approve and Deny (#1753) | Rebased onto 306 landing as 580037105 (range-diff all =), slot run bus 907/0 webui 410/0 cli 943/0, in-review revs 3608/3609, Webby r1 APPROVE, J1-J19 specs to Sage; Sage landed 45ea3fa0d (done rev 3616). Part 1 mutant results were never in BUILD.md; they went to Sage by T3 (scrooge-0252-mutant-specs).
|
||||
2026-10-23 | Sage (1ef1e4f8) | Rows 308 and 252 landings, 571 Connections design amendment, Vikunja 597 | 308 (PRD 1.17, #1815) landed 30e6bb680 and 252 (#1753) landed 45ea3fa0d (replay of 580037105 onto 30e6bb680, range-diff all =), each after a full gate on the landed tree (252 also J1-J19, J11c and J18 equivalent); Gyro's 571 amendment (Fenton APPROVE) landed e8bb13df8; Vikunja 570, 435 and 597 done, canonical webui esbuild 0.28.2; queue revs 3589-3616 committed 59f013247
|
||||
2026-10-23 | Sage (1ef1e4f8) | Vikunja 571 W build rows; rows 13, 42, 257 and 289 check | Filed the design's ten W rows as queue rows 309-318 (revs 3617-3626, owners set to unassigned 3627-3636, queue commit 293b42fee pushed); Vikunja 571 done (comment 994). Row 13's candidate fd72d2689 is on refactor, gate is Monday's row 7 ledger run; 42 paused under mos-1184; 257 post-v1; 289 waits on 214 and 288. Slot released 17:56Z.
|
||||
2026-10-23 | Bubba (Codex gpt-6.1-sol, 3d6672b5) | Row 288 / #1793 C2-02 in bubba/row288; mos-1499, sage-3613 | Started; 283/285/286 merged intact, static build pending Sage CLEAR; queue ownership remains with Sage.
|
||||
2026-10-24 | Bubba (3d6672b5) | Row288 static preflight, sage-4282 | Creative contract/tool names match; short fixture tokens; provisional import-based whole-file run list excludes Case1; no Node/test run, awaiting landed284 base and CLEAR.
|
||||
2026-10-23 | Violet (6229eb89, Codex gpt-6.1-sol, mos-1498/Q245) | Row 297 GitHub grant method #1803 | Registered in violet/row297; merge-chained row198 14fe7513c then 4d8123b0a unchanged. Q-B3 S1-S6 proposal prepared for Dewey/Megavolt and Sage; no product code, credentials, node or tests; shared runs require CLEAR.
|
||||
2026-10-23 | Violet (6229eb89) | Row297 #1803 protected GitHub grant candidate | Q-B3 read by Dewey/Megavolt, sage-3620 scope applied. Candidate and fake HTTPS tests written, no Node/tests run; one Mos-authorized old-test regex 1/1 (mos-1518). Review manifest and consumer gap recorded. Verification/gate remain pending Sage CLEAR; no acceptance or landing claim.
|
||||
@@ -40,8 +40,8 @@ install -d -o root -g root -m 0755 /etc/mosaic-stack/node-keys /opt/mosaic-stack
|
||||
cd "$(mktemp -d)"
|
||||
curl -fsSLO https://nodejs.org/dist/<v>/node-<v>-linux-x64.tar.xz
|
||||
curl -fsSLO https://nodejs.org/dist/<v>/SHASUMS256.txt
|
||||
curl -fsSLO https://nodejs.org/dist/<v>/SHASUMS256.txt.sig
|
||||
gpg --no-default-keyring --keyring /etc/mosaic-stack/node-keys/pubring.kbx --verify SHASUMS256.txt.sig SHASUMS256.txt
|
||||
curl -fsSLO https://nodejs.org/dist/<v>/SHASUMS256.txt.asc
|
||||
gpg --no-default-keyring --keyring /etc/mosaic-stack/node-keys/pubring.kbx --verify SHASUMS256.txt.asc SHASUMS256.txt
|
||||
grep " node-<v>-linux-x64.tar.xz\$" SHASUMS256.txt | sha256sum -c -
|
||||
echo "<pinned sha256> node-<v>-linux-x64.tar.xz" | sha256sum -c -
|
||||
tar -xJf node-<v>-linux-x64.tar.xz --no-same-owner
|
||||
@@ -234,16 +234,10 @@ When a run doesn't exit 0, send Mos the exit and the last lines of `/var/log/mos
|
||||
Precheck lines: none. Root's checkout at `<sha>` and the stack's user's clone: precheck line missing.
|
||||
After install, `stack-copy.sh check --units external` reads the copy the units run.
|
||||
|
||||
The stack's user's `scripts/case2-install.sh` (C2-13b, design 10 step 2b) makes the first copy with
|
||||
`stack-copy.sh copy <sha>`. The copy runs `npm ci` from `package-lock.json`, which installs the pinned
|
||||
harness, so the host needs to reach the npm registry during the install. The install runs no
|
||||
`release.sh package` or `activate`.
|
||||
|
||||
## 7. The args file
|
||||
|
||||
`/etc/mosaic-stack/dev.args` holds the `case2-unit.sh` arguments, one per line, with the serve flags after a
|
||||
line holding `--`. Root writes it at the unit render, from the values the go names. The stack's user's
|
||||
install never writes it: that user can't write `/etc`, and root reads nothing that user wrote. It must be root's, with no group
|
||||
line holding `--`. I2's install writes it; ops-01 doesn't write it by hand. It must be root's, with no group
|
||||
or other write (`chmod 0644` or tighter). Before `--` it takes only `--user`, `--home`, `--business`,
|
||||
`--copy` (exactly once), `--node`, `--profile`, `--claude` and `--pm`. `stack-upgrade.sh` changes only the
|
||||
`--copy` line, and only after confirm passes.
|
||||
@@ -278,7 +272,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 | C2-13b's `library.sync` (not landed) |
|
||||
| `/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.
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# Brief: a GitHub consumer for row 297's protected token callback
|
||||
|
||||
Sage, 2026-10-23, filed for Mos (mos-1525). Post-10-27.
|
||||
|
||||
## Brief: a GitHub consumer for Broker.githubWithToken
|
||||
|
||||
Row 297 builds the GitHub grant method and stops at the trusted host API
|
||||
`Broker.githubWithToken(business, agent, fn)` in `packages/bus/src/broker.mjs`.
|
||||
Nothing calls it yet: no git helper, mirror push or publication uses a GitHub
|
||||
token (violet BUILD.md, "Consumer gap for Sage's follow-up row").
|
||||
|
||||
This row adds the first consumer.
|
||||
|
||||
- It gets the token only through `githubWithToken`. It never reads the
|
||||
protected generation files, and never routes GitHub through the generic
|
||||
Gitea Credentials path, which has calendar-day dates and fork-specific
|
||||
identity.
|
||||
- It honors the callback's cancellation signal. The signal aborts on revoke and
|
||||
at the token safety deadline, so the consumer stops work and drops the token
|
||||
when it fires. No operation is retried with a token past its expiry.
|
||||
- No socket verb returns a token, and no consumer output, log or run record
|
||||
holds one.
|
||||
|
||||
Design comes first, with the consumer named (a git helper or mirror push), for
|
||||
Dewey's review. Tests cover: a revoke mid-call aborts the consumer; a call
|
||||
straddling the safety deadline aborts; an expired grant refuses before any
|
||||
network call; and no token text appears in output or the store.
|
||||
|
||||
Gate: Dewey approves the design and the manifest. The touched suites and every
|
||||
`scripts/test-*.sh` are green on Sage's gate, and mutants that drop the signal
|
||||
check or the expiry check are killed. Starts after row 297 is done.
|
||||
@@ -586,3 +586,4 @@ git history + Gitea issues.
|
||||
|
||||
- 2026-09-26 (sage, lead): Jason's ruling: Sage leads the project, Darkwing is a collaborating seat, more seats to follow, development stays in T3 for now. The fleet Sage seat that took Invoice Ninja coordination outside Jason's instructions is being decommissioned; moving work onto the new stack is the fix. Suites all green. Rows 24 and 25 pushed (1685deb4..43d7574d). Open: about 11 days of uncommitted agent work in the tree (row 16 launch files, row 22, #1512, WUI evidence), 23 untracked aws-* skill directories in skills/ from 2026-09-21, and Jason's target for the next phase.
|
||||
- 2026-10-13 (sage, lead): Jason's PM re-test on 664199f7 is finished (Mos; PM run rcc0f72be8b17; Vikunja #228). Paging, the Board's held roles, the Messages page and the #229 loop (coder push 010d30cb, review, approve, PM close) all worked; #230 and #231 are done, #232 stays open for the secrets-path probe. Two faults checked and dropped: the reviewer did run the tests before its verdict, and the PM's clone did hold the row 95 Board code. Two filed: row 104 (#1598) seat git identity, row 105 (#1599) a review request says whether a reviewer is running. Rows 96, 101-105 briefed; row 85 released to build. Row 85's live steps, any bus restart and row 102's public/ change each need Mos's go. Row 100 waits on Jason (Vikunja #257). Formatting, Jason's direction: no formatting row now. Chat output will be formatted in the TUI and WebUI the way chat interfaces usually do it, made our own; T3 Code's chat is his example. Carry it into the TUI and WebUI chat work.
|
||||
- 2026-10-22 (bushroot, row 198 r3): the board runs in-process in the Console (webui, port 7332); control-board `serve` is retired and exits 2, and the Console refuses `--board`. Docs: packages/webui/README.md and packages/control-board/README.md now give the Console start and drop the 7331 serve text. Built on bushroot/row198-r3 from aca16404c; not run, awaiting Sage's CLEAR.
|
||||
@@ -678,36 +678,3 @@ claim aren't one (design D1's alternative). Bound to the next row that changes t
|
||||
Morgana, row 306 BUILD.md (#1813, landed 2d0318a44). A revoked run that also has a pending limit stop gets
|
||||
`#limitStop`'s `not-holder` on `message.receive` and `state`, because that check runs before `#agent`. It's
|
||||
rare and the run still stops. Bound to the next row that changes `#limitStop` or the order of those checks.
|
||||
|
||||
## Row226 N1: the install id regex is narrower than ID_PATTERN
|
||||
|
||||
Webby, row 226 round 1 (webby-0225, #1727, landed 661890e90). case2-install.sh:56 refuses a business id
|
||||
with `.` or `_`, which ID_PATTERN (vocabulary.mjs:199) and case2-precheck.sh:67 accept. Gosalyn confirmed
|
||||
it isn't deliberate (gosalyn-0208). It fails closed, so a real id with either character is refused, not
|
||||
mishandled. Bound to the next row that changes case2-install.sh: take the ID_PATTERN set, and add a test.
|
||||
|
||||
## Row226 N2: a first-run validate refusal leaves personas and the slot dir
|
||||
|
||||
Webby, row 226 round 1 (webby-0225). After a validate refusal on a first run, the staged personas and the
|
||||
empty token slot dir stay behind, and a re-run counts them as present. Bound to the same follow-up as N1:
|
||||
remove what this run created, as the business file path already does (install.sh:198/:202/:218).
|
||||
|
||||
## Row226 N3: the run sheet doesn't pin the clone to the copy's sha
|
||||
|
||||
Webby, row 226 round 1 (webby-0225). install.sh:217 validates with the checkout's scripts/mosaic, not the
|
||||
copy's, and host-run-sheet.md:237 never says the clone must be at `<sha>`. Bound to the same follow-up:
|
||||
a HEAD check in the install or one clause on the run sheet.
|
||||
|
||||
## Row226 N4: --library-status refuses after a 5 s lock wait
|
||||
|
||||
Webby, row 226 round 1 (webby-0225). If the bus holds the library lock for more than 5 s,
|
||||
`case2-precheck.sh --library-status` refuses with `ERROR library-sync-busy`. A re-run clears it. Recorded
|
||||
as a known operator step, no fix bound.
|
||||
|
||||
## Row283 N1: SURVEY-RUN.md shows the scopes line without supporting
|
||||
|
||||
Webby, row 283 round 1 (webby-0267, #1788, review note queue-283/REVIEW-manifest-b2a1f4092.md on
|
||||
webby/reviews c6ea27aab). docs/case2/SURVEY-RUN.md:96 documents the `--mapping` line as
|
||||
`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.
|
||||
+12
-13
@@ -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 3677 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 3636 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -252,18 +252,18 @@ Generated from `docs/plans/queue.json` revision 3677 by `queue render`. Do not e
|
||||
| 213 | Case 2 C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | morgana; reviewers webby | #1714 | done | — | Webby approves the manifest. The business, bus and harness suites, `scripts/test-config.sh` and every `scripts/test-*.sh` are green on Sage's gate, and the `models` mutant is killed. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | LANDED 361ba2387 (39 paths incl. 4 packet files). Patch-id f313e69b6f7e = c771a36f4..2b3e67e36; verify-commit ok, 35 manifest paths. Same combined gate as 195: business 200/0 bus 542/0 harness 140/0 cli 663/0, test-config 31/0 and all 10 scripts green, models mutant L1 killed. Webby N2 is row 235 acceptance (mos-1163). |
|
||||
| 214 | Case 2 C2-11a editor wikilink node, after measuring whether [[ round-trips | herb; reviewers filbert | #1715 | in-progress | — | Filbert approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11a editor wikilink node, after measuring whether [[ round-trips | — |
|
||||
| 215 | Case 2 C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | herb; reviewers filbert | #1716 | done | — | Filbert approves the manifest. The business, seat, cli, project, bus and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | LANDED e54e05463 on 6096b051b (sage-2094). Patch-id 2f68188b6c9d equals a36523713..dde3445ab; 118 paths, blobs and modes equal; tree 5b1613245 equals merge-tree. No path overlap with rows 66/243; no hash pins either way. Gate: Filbert APPROVE rev 2924; herb-0095 grant sage-2071 2634 pass 0 fail, 1 webui skip (editor rebuild needs esbuild 0.28.2, env, not a 215 path), accepted. |
|
||||
| 216 | Case 2 C2-05a lookup index and queries | researcher; reviewers filbert | #1717 | in-review, round 1, request posted | 268 done | Filbert approves the manifest. The library suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05a lookup index and queries | Rulings on phase A: agents/sage/work/queue-216/RULINGS.md (da9555ca2). r2 rebases on row 268's landing and adds the four PRD 1.14 playground tests. |
|
||||
| 217 | Case 2 C2-06 the citation guard, the page store, the runner texts and the retry | beakley; reviewers webby | #1718 | briefed | 268 done, 283 done | Webby approves the manifest. The library, bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-06 the citation guard, the page store, the runner texts and the retry | — |
|
||||
| 216 | Case 2 C2-05a lookup index and queries | researcher; reviewers filbert | #1717 | briefed | 268 done | Filbert approves the manifest. The library suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05a lookup index and queries | Rulings on phase A: agents/sage/work/queue-216/RULINGS.md (da9555ca2). r2 rebases on row 268's landing and adds the four PRD 1.14 playground tests. |
|
||||
| 217 | Case 2 C2-06 the citation guard, the page store, the runner texts and the retry | launchpad; reviewers webby | #1718 | briefed | 268 done | Webby approves the manifest. The library, bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-06 the citation guard, the page store, the runner texts and the retry | — |
|
||||
| 218 | Case 2 C2-12 the setup sheet for Melanie and the survey run sheet | sage; reviewers webby | #1719 | done | — | Doc only. Webby approves it against C2-01's real flags. Before landing, grep `packages/*/tests` and `scripts/test-*.sh` to confirm no test reads either file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12 the setup sheet for Melanie and the survey run sheet | LANDED f8276c5eb (pushed, on Darkwing sessions b65f3a0e8). Webby APPROVE r1-r3 (revs 2848, 2856, 2860; r3 candidate 1a38baf5af2a = 89ba79d94). Patch-id 18ba1e40ad29 seat 5c6f73ed7..89ba79d94 equals landed b65f3a0e8..f8276c5eb; three blobs equal 89ba79d94. Gate: doc only, 0 test readers (control 3). Melanie handover waits on C2-06 and Jason (#419). |
|
||||
| 219 | Case 2 C2-09a one thread per creative instance | rocko; reviewers dewey | #1720 | done | — | Dewey approves the manifest. The bus, cli and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. Watch for hash pins on `bus.js` and `broker.mjs` (`packages/bus/tests/channel-proof.test.mjs` pins a webui file). (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-09a one thread per creative instance | LANDED 5fdfd7275 on refactor 10:40Z 10-23 (8 row commits cherry-picked onto 2efd127ce, range-diff all =, patch-ids match, tree e86a84a85 = gated merge 054da642f; top commit packet-only evidence). Gate manifest 4a2271dd: node 2523/0/2, ten shells 449/0, 24 mutants killed, audit ok (Rocko). Dewey approve rev 3507 c31108. N1 U6 persona label DEFERRED line follows. Slug fix Vikunja 590 and network leak 591 rows next. |
|
||||
| 220 | Case 2 C2-10a no raw session view (M7) and no raw Discord engine (M8) | researcher; reviewers filbert | #1721 | in-review, round 1, request posted | — | Filbert approves the manifest. The conversation and discord suites and `scripts/test-discord.sh` are green on Sage's gate, along with every `scripts/test-*.sh`. No test reaches a Discord server. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10a no raw session view (M7) and no raw Discord engine (M8) | Rulings on phase A: agents/sage/work/queue-220/RULINGS.md (da9555ca2). Scope adds packages/business/src/library-seats.mjs (imported by path, no index export), its test, and control-board serve.mjs. r2 building as a Haiku subagent. |
|
||||
| 220 | Case 2 C2-10a no raw session view (M7) and no raw Discord engine (M8) | researcher; reviewers filbert | #1721 | briefed | — | Filbert approves the manifest. The conversation and discord suites and `scripts/test-discord.sh` are green on Sage's gate, along with every `scripts/test-*.sh`. No test reaches a Discord server. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10a no raw session view (M7) and no raw Discord engine (M8) | Rulings on phase A: agents/sage/work/queue-220/RULINGS.md (da9555ca2). Scope adds packages/business/src/library-seats.mjs (imported by path, no index export), its test, and control-board serve.mjs. r2 building as a Haiku subagent. |
|
||||
| 221 | Case 2 C2-20 Bean and Chet persona import, with a count-only dry run | gosalyn; reviewers webby | #1722 | done | — | Webby approves the manifest. The test file runs green on its own and the cli suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-20 Bean and Chet persona import, with a count-only dry run | LANDED 300766450, pushed. Cand 8f403d0d4, manifest 20f05e3a6ad5, 22 paths, verify-commit ok, patch-id dfbb754c6fb4 equals 62ddc96cc..8f403d0d4. Gate 11:07:50-11:19Z on b1fece6f0: test file alone 11/0, harness 140/0, cli 677/0, all scripts green. Brief Gate met: webby-0073 approve, test file alone, cli, scripts. No live run in the Gate; the import itself waits on Jason. N1 N2 N4 go to C2-20b, N3 to DEFERRED. |
|
||||
| 222 | Case 2 C2-03a fetch-only sync | megavolt; reviewers dewey | #1723 | done | — | Dewey approves the manifest. The library and bus suites are green on Sage's gate, along with every `scripts/test-*.sh`, and the mutant is killed. A search of the test run's output for the fixture token finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-03a fetch-only sync | sage-2024 on megavolt-0089: core.createObject=rename in the fetch argv is accepted as a brief departure, since C2-04 inspectMirror refuses nlink 2. Dewey checks it. The askpass env keys, export SAFE in project/src/git.mjs, bus/tests/library-sync.test.mjs and the SURVEY-RUN user line are accepted. The 5 s client timeout residual goes in BUILD.md and Dewey rules fix or defer. Slot after 197 proof 3 and the 249 gate. |
|
||||
| 223 | Case 2 C2-07 runner retry on a guard refusal, and the runner's notices | researcher; reviewers filbert | #1724 | blocked (from briefed): Q226: C2-07 folded into C2-06 (row 217). No work here; Jason parks it. | — | Filbert approves the manifest. The harness suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-07 folded into C2-06 | — |
|
||||
| 224 | Case 2 C2-05b broker lookup verbs and typed tools | della; reviewers dewey | #1725 | in-progress | 216 done | Dewey approves the manifest. The bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05b broker lookup verbs and typed tools | — |
|
||||
| 225 | Case 2 C2-10b Console chat: replies, citations, write notices and the status line | fethry; reviewers dewey | #1726 | briefed | 217 done | Dewey approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. Grep for hash pins on `bus.js` and `serve.mjs` before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10b Console chat: replies, citations, write notices and the status line | — |
|
||||
| 226 | Case 2 C2-13 install script and precheck for the host | gosalyn; reviewers webby | #1727 | done | 215 done, 221 done, 222 done, 230 done | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. The run on the host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13b install script and library precheck | landed 661890e90 (replay of bfed0aa6c 893b4fd25 f3fabf51b onto 8352da88c, patch-ids equal, four blobs and modes equal to f3fabf51b) and fd3d2a99f (Darkwing 8a6321790 replay, mos-1519), tree 78fc5ad21 equals merge-tree. Webby APPROVE rev 3641. Sage gate GREEN 19:56Z on f3fabf51b: 17 package suites and 10 scripts, 0 fail. Webby N1-N4 DEFERRED e2b873390. #1727 closed. |
|
||||
| 227 | Case 2 C2-14 end-to-end acceptance on a scratch root | gizmoduck; reviewers dewey | #1728 | in-progress | — | Dewey runs the script and approves. It is green on Sage's gate with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-14 end-to-end acceptance on a scratch root | — |
|
||||
| 225 | Case 2 C2-10b Console chat: replies, citations, write notices and the status line | researcher; reviewers dewey | #1726 | briefed | — | Dewey approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. Grep for hash pins on `bus.js` and `serve.mjs` before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10b Console chat: replies, citations, write notices and the status line | — |
|
||||
| 226 | Case 2 C2-13 install script and precheck for the host | gosalyn; reviewers webby | #1727 | in-progress | 215 done, 221 done, 222 done, 230 done | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. The run on the host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13b install script and library precheck | — |
|
||||
| 227 | Case 2 C2-14 end-to-end acceptance on a scratch root | launchpad; reviewers dewey | #1728 | briefed | — | Dewey runs the script and approves. It is green on Sage's gate with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-14 end-to-end acceptance on a scratch root | — |
|
||||
| 228 | Row A: ZAI_API_KEY passes through ENV_ALLOW to sessions, README line, pass-through test (XS, Haiku high) | researcher; reviewers webby | #1729 | done | — | Webby approves the manifest. The mutant is killed. The seat suite and the cli suite are green on Sage's gate with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § Brief: row A, ZAI_API_KEY in the session env allowlist | Landed 86249ac55 (pushed 07:25Z) on 59f817c32: the 3 manifest paths from cf18c42b9. Patch-id b6ffe31feb0d equals the candidate's; modes and blobs match; verify-commit ok (3 paths). Sage's combined 228+238 gate on c515273db plus both rows' 6 paths, 07:11:30Z to 07:23:34Z: seat 165/0, cli 640/0, all ten scripts/test-*.sh 585/0. |
|
||||
| 229 | Row B: per-instance pi agent dir for Chet's Codex login, design only, starts after 10-27 (#428) | gosalyn; reviewers webby | #1730 | briefed | — | A live check, with Jason's or Mos's go, that two clients on one ChatGPT login don't revoke each other's refresh tokens. No build row starts before it passes. Held until after 10-27. (sage) | `docs/plans/2026-10-20_case2-rows.md` § Brief: row B, a per-instance pi agent dir for Chet's Codex login (#428) | — |
|
||||
| 230 | Case 2 C2-13a unit render and host precheck (case2-unit.sh, case2-precheck.sh) | gosalyn; reviewers webby | #1732 | done | — | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every scripts/test-*.sh. A run on a host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13a unit render and host precheck | Landed c8bc5cf4c on refactor (base 390ea5261), pushed. Patch-id f4b49eb68997 matches git diff 722136772..96768766c on the 5 manifest paths (manifest fbb0d7e42c67, Webby approve r2 rev 2563). packages/cli/README.md 3-way with row 199, hunks apart. Gosalyn gate on 96768766c: cli 541/0, scripts 429/0, mutants 10/10. Merged tree check: case2-unit 30/0, host 42/0. |
|
||||
@@ -319,12 +319,12 @@ Generated from `docs/plans/queue.json` revision 3677 by `queue render`. Do not e
|
||||
| 280 | Bus tests: the scratch sshd proves it owns its port (194 gate follow-up, MVP, mos-1270) | rocko; reviewers duckworth | #1785 | done | — | Duckworth approves the manifest. Both files run whole, the bus suite is green on Sage's gate, the 30-run repeat has no timeout, and M1 is killed. Row 194 b17's full gate then runs on top. Any red goes to Mos (mos-1270). (sage) | `docs/plans/2026-10-22_sshd-port-race.md` § Brief: the scratch sshd's port is ours before a test uses it | Landed b0fd28b6f 07:57:30Z, ff from da5d3651d: b765f5cb8 (Duckworth approve rev 3153, manifest 27806c05) + slim packet. Sage gate bus 656/0/0, ge 41, hr 26 (c30431). Repeat 2010/2010 under cli 1588/1588, no timeout; M1/M2 killed 4/4. Full evidence d6c924764 on rocko/row280-da5d-20261022. N2-N4 carry in BUILD.md. 194 b17 rebases on b0fd28b6f. |
|
||||
| 281 | A fire or a delete revokes the instance's host and Gitea grants (194/236 follow-up, MVP, mos-1271) | megavolt; reviewers dewey | #1786 | done | 236 done | Dewey approves the design, then the manifest. Bus, business, cli and every touched test file run whole, plus the Docker gitea-grant test and every scripts/test-*.sh, green on Sage's gate. M1-M4 killed. Lands before .53 Case 1 hires a granted instance. Red goes to Sage, then Mos. (sage) | `docs/plans/2026-10-22_fire-revokes-grants.md` § Brief: no grant outlives its instance | LANDED e479521d3 ff on 385a7194c (17 commits, manifest b73d4c81). Slot on 7979fb41e green: bus 819, business 231, cli 837, dewey-bus 76 with gitea-grant-docker run, shells green under 3x limits, M1-M22 killed. M1 hang fixed in b4a (mos-1273). Skips: test-task row 244 live-provider pair, test-queue canonical root. DEFERRED: N1 host-unknown hint verb, N2 fire tool text, to the follow-up row. Dewey APPROVE c30614. |
|
||||
| 282 | PRD REQ-LIB-6 rewritten to Q223 and Q226 (mos-1320 item 8) | gosalyn; reviewers webby | #1787 | done | — | Webby approves against the ruling. requirements.test.mjs and every scripts/test-*.sh that reads the PRD or requirements.json are green on Sage's gate. Jason gets a report. (sage) | `docs/plans/2026-10-20_case2-rows.md` § PRD REQ-LIB-6 rewritten to Q223 and Q226 (mos-1320 item 8) | Landed 647424442 on 86a665bdf: 1841f7d4e and 6b5b06819 rebased, patch-ids equal (4e50b4b6, 16a7a02f), range-diff =, tree 4f30ed10a = Gosalyn's gated trial tree. Webby APPROVE rev 3308 c30764 on 6b5b06819. Gate 19:06Z on the tip tree: bus+tasks requirements and tasks verbs 37/0/0, 0 skips; no scripts/test-*.sh reads the PRD (control ok). PRD 1.15. 1.16 is row 291, waits for Jason. |
|
||||
| 283 | Case 2 C2-01d supporting folders, the supporting scope, the editor field and supportingPath (M12) | honker; reviewers webby | #1788 | in-review, round 1, request posted | 268 done, 282 done | Webby approves the manifest. The library and business suites are green on Sage's gate, along with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01d supporting folders, the supporting scope, the editor field and supportingPath (M12) | E1 (webby-0227): the manifest's existing test changes are survey.test.mjs:73 (mos-1330 re-pin, 28918824c) and business.test.mjs +4 (T7, library-supporting-other-universe). Candidate b2a1f4092. T2 receipts in agents/honker/work/queue-283/BUILD.md. |
|
||||
| 283 | Case 2 C2-01d supporting folders, the supporting scope, the editor field and supportingPath (M12) | honker; reviewers webby | #1788 | briefed | 268 done, 282 done | Webby approves the manifest. The library and business suites are green on Sage's gate, along with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01d supporting folders, the supporting scope, the editor field and supportingPath (M12) | — |
|
||||
| 284 | Case 2 C2-05c the supporting scope and blob in the lookups | della; reviewers dewey | #1789 | briefed | 224 done, 283 done | Dewey approves the manifest. Library, bus and harness suites and every scripts/test-*.sh green on Sage's gate. Grep packages/*/tests for the scope enum pin and ['canon', 'playground'] before in-review; update each. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05c the supporting scope and blob in the lookups (sage-2833) | — |
|
||||
| 285 | Case 2 C2-08b role changes for agent writes and the chief's read grant | scrooge; reviewers webby | #1790 | briefed | 213 done, 291 done | Webby approves the manifest. Business suite, test-config.sh and every scripts/test-*.sh green on Sage's gate. Grep for CREATIVE_KINDS and 64-hex pins of changed role files. Sage checks it against mos-1309 item 5. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-08b role changes for agent writes and the chief's read grant (mos-1309 item 5) | — |
|
||||
| 286 | Case 2 C2-03b push and per-seat credentials for the library mirror (with R4, mos-1323) | ludwig; reviewers dewey | #1791 | briefed | 222 done | Dewey approves the manifest. Library and bus suites and every scripts/test-*.sh green on Sage's gate, mutant killed. A search of the run output for the fixture token finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-03b push and per-seat credentials for the library mirror | — |
|
||||
| 287 | Case 2 C2-21 the grant executor for a library project | megavolt; reviewers dewey | #1792 | briefed | 226 done, 285 done | Dewey approves the manifest. Bus suite, Gitea Docker test, install test and every scripts/test-*.sh green on Sage's gate. A search of the run output for the fixture admin and seat tokens finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-21 the grant executor for a library project | — |
|
||||
| 288 | Case 2 C2-02 agent writes, the notice, revert and her Console saves | bubba; reviewers webby | #1793 | briefed | 215 done, 282 done, 283 done, 284 done, 285 done, 286 done | Webby approves the manifest. Library, bus and harness suites and every scripts/test-*.sh green on Sage's gate, mutants killed. A search of the run output for the fixture tokens finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-02 agent writes, the notice, revert and her Console saves | A2 revert contract (morgana-0199, sage-3596): library.revert is a human verb. Args {project, commit}, strict keys; project is the business.projects key (the chat repo link), not a UUID. Result {project, path, reverted, commit}. Refusals: 409 stale-base, and 409 library-revert-not-agent for an agent caller. Owner Bubba under Q245 (mos-1506). |
|
||||
| 288 | Case 2 C2-02 agent writes, the notice, revert and her Console saves | honker; reviewers webby | #1793 | briefed | 215 done, 282 done, 283 done, 284 done, 285 done, 286 done | Webby approves the manifest. Library, bus and harness suites and every scripts/test-*.sh green on Sage's gate, mutants killed. A search of the run output for the fixture tokens finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-02 agent writes, the notice, revert and her Console saves | — |
|
||||
| 289 | Case 2 C2-11b Console library pages and her saves (Haiku subagent, decision 129) | researcher; reviewers filbert | #1794 | briefed | 214 done, 288 done | Filbert approves the manifest. The webui suite and every scripts/test-*.sh are green on Sage's gate. Grep for hash pins on bus.js and serve.mjs before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11b Console library pages and her saves | — |
|
||||
| 290 | Case 2 C2-12b the setup sheet rewrite for free chat, supporting folders and saves | louie; reviewers webby | #1795 | briefed | 217 done, 218 done, 285 done, 291 done | Doc only. Webby approves it against the ruling. Before landing, grep packages/*/tests and scripts/test-*.sh to confirm no test reads the file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12b the setup sheet rewrite for free chat, supporting folders and saves | — |
|
||||
| 291 | PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | gosalyn; reviewers webby | #1796 | done | 282 done | Webby approves the text. Jason says yes to the exact text (Mos asks him, mos-1328 step 3); no landing before that. requirements.test.mjs and every scripts/test-*.sh that reads the PRD or requirements.json are green on Sage's gate. Mos gets the landing commit. (sage) | `docs/plans/2026-10-22_prd-1-16-brief.md` § PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | Landed 4874d1e83 on e909ef51a: 482e865ac replayed, patch-id equal (cef7de74), range-diff =. Webby APPROVE c30848 on 482e865ac; Jason approved the text in Q229. Gate 16:15Z on the tip tree: every package suite but queue, 0 fail 0 skipped (tasks 100, bus 891, cli 941, plugins 119, webui 392, business 231), every scripts/test-*.sh rc 0 (queue-root and row 244 skips only). PRD 1.16. 1.17 is row 308. |
|
||||
@@ -332,9 +332,9 @@ Generated from `docs/plans/queue.json` revision 3677 by `queue render`. Do not e
|
||||
| 293 | 574: maintenance-free test fixture commits (Vikunja 574) | gander; reviewers filbert | #1805 | done | — | Filbert approves. The four whole files, then the seat and cli suites, are green on Sage's gate. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: 574, maintenance-free test fixture commits (Vikunja 574) | LANDED e51d11640 03:55Z 10-23 (cherry-pick of gander b23783f39..a9cae2bd6 onto 464010fbe; range-diff 6/6 equal, range patch-id equal). Filbert APPROVE c30982 cdda41f5; run 2 4 files 153/0, seat 202/0, cli 905/0, 312 flagged/0 maintenance children. N1: lifecycle.test needs TMPDIR of 50 bytes or less. Residual: queue store.mjs, discord git.mjs, harness tools.mjs unflagged. #1805 closed c31013, P19 574 c888. |
|
||||
| 294 | FED R6: Console granted-user sign-in, user-cli transport, peer links | launchpad; reviewers duckworth | #1800 | done | — | Duckworth approves the manifest. The webui, cli and bus 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 R6, Console granted-user sign-in | landed d3059c2c3 on refactor 07:04Z: cherry-pick of c902a767d..24b745447 onto 09d0d6faf, range-diff 11 =, patch-id 8371405a equal. Duckworth approve c31053 rev 3469 on a1ff5d67, C1/C2 met. Run 4 on 8616b9a4f from npm ci: webui 365, cli 909, bus 873, 10 scripts, M1-M11 killed. Merged tree: stack-release-external 25, stack-upgrade 21, stack-release 223/224 (R9 transient, row 1810) then 224/224 rerun under mos-1422. Follow-ups rows 300, 301. Closes #1800. |
|
||||
| 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) | — |
|
||||
| 296 | FED R7: scratch two-instance acceptance | quackerjack; 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) | bushroot; 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 | megavolt; reviewers dewey | #1804 | briefed | 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) | — |
|
||||
| 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. |
|
||||
@@ -355,7 +355,6 @@ Generated from `docs/plans/queue.json` revision 3677 by `queue render`. Do not e
|
||||
| 316 | 571 W5: read-only flags, citations and chat suggestions (post-10-27) | unassigned | — | queued | 315 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` § W5 read-only flags, citations and chat suggestions | — |
|
||||
| 317 | 571 W7: human one-way chapter import and Case 2 format routing, production import closed; the first real import opens only after W8 lands (post-10-27, mos-1470) | unassigned | — | queued | 314 done, 315 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` § W7 human one-way chapter import and Case 2 format routing | — |
|
||||
| 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 | — |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+44
-1350
File diff suppressed because it is too large.
Load diff
@@ -1071,3 +1071,59 @@ TERM concurrently, then KILL after five seconds and observe for two more.
|
||||
Unknown targets remain visible and prevent resume. Existing run evidence is
|
||||
not synthesized or rewritten. First activation stops old processing before
|
||||
trusted initialization and aligns the copy and image in the human's window.
|
||||
|
||||
### GitHub App grants (row 297)
|
||||
|
||||
The `github` method uses the same chief raise, human Approve, reserved `grant.call`
|
||||
then `grant.result`, and local-first revoke boundary as Gitea. Its tokens expire
|
||||
within an hour, so the durable grant authorizes protected renewal up to its
|
||||
`rotateBy` day. Every generation remains bound to the original configuration.
|
||||
A changed definition, allowed-project list, repository ID/remote or admin metadata
|
||||
requires another human decision. No account or fork is created.
|
||||
|
||||
A role definition declares `{ "service": "github" }` without scopes. Its business
|
||||
role names `"github": { "projects": ["app"] }`, and that project supplies its HTTPS
|
||||
`remote` and positive integer `githubId`. No generic `credentials.github` reference
|
||||
is accepted. Code fixes the token permissions to `contents: write, metadata: read`,
|
||||
with exact selected repository IDs. Workflow-file writes (`.github/workflows/`),
|
||||
PR/issue writes and administration are excluded. A workflow push refusal is intended.
|
||||
|
||||
Jason alone sets the App private key, through bounded stdin, after creating and
|
||||
installing the App. Agents do not perform this live step:
|
||||
|
||||
```sh
|
||||
mosaic grants admin set github --url https://api.github.com --app-id APP_ID \
|
||||
--installation-id INSTALLATION_ID --account-id ACCOUNT_ID --rotate-by YYYY-MM-DD --business BUSINESS
|
||||
mosaic grants status github --business BUSINESS
|
||||
mosaic grants revoke AGENT github --reason REASON --business BUSINESS
|
||||
mosaic grants sweep github --business BUSINESS
|
||||
```
|
||||
|
||||
Admin rotation takes a replacement RSA private key on stdin with `admin rotate
|
||||
github --rotate-by DATE`. It preserves App/installation/account/origin identity and
|
||||
invalidates old bindings. GitHub App key creation and deletion remain Jason's own
|
||||
GitHub administration step. Admin remove removes the local slot; it does not claim
|
||||
to delete the App or remotely revoke its private keys.
|
||||
|
||||
Only trusted protected code can call `githubWithToken(business, agent, fn)`. It
|
||||
checks approval/configuration/expiry, renews when required, and passes the token
|
||||
and cancellation signal to that trusted callback. No socket verb returns a token
|
||||
or exposes renewal. Tokens stay in private generation files outside run evidence;
|
||||
RSA keys, JWTs and token values are never audit fields. Revoke blocks use and
|
||||
renewal before its remote undo. An interrupted mint with no recoverable token value
|
||||
stays visible in `serviceLeft` until its conservative expiry bound; a new token
|
||||
does not hide it. `sweep` retries known tokens and records expiry cleanup.
|
||||
|
||||
Consumer gap (sage-3620): this row supplies the protected method and renewal
|
||||
function, but adds no GitHub mirror push, publication or credential helper. The
|
||||
existing Gitea credential path has day-level dates and Gitea fork identity; those
|
||||
cannot safely carry a one-hour GitHub token. GitHub publication therefore needs a
|
||||
separate consumer row and is not enabled by approving this method.
|
||||
|
||||
`sweep` also closes an active generation's private file after its conservative
|
||||
expiry bound, recording reason `expired` while keeping the durable grant. A
|
||||
later guarded use renews from the same unchanged approval. Before that cleanup,
|
||||
status can show an expired generation; its timestamp guard still refuses use.
|
||||
Removing the admin slot while grants/residue exist invalidates renewal and stops
|
||||
remote cleanup until a matching slot is restored or the conservative bound
|
||||
elapses. Remove does not claim remote revocation or delete the App's keys.
|
||||
+123
-176
@@ -46,8 +46,6 @@ export const ACTIONS = Object.freeze([
|
||||
'policy.change',
|
||||
'prd.approve',
|
||||
'decision.resolve.technical',
|
||||
// Row 285 (C2-08b, #1790): routed under PRD 1.16 REQ-WAIT-3 and REQ-LIB-6, below.
|
||||
'library.write',
|
||||
]);
|
||||
// PRD 1.5 REQ-WAIT-3 (row 139, #1633): every action is a hard wait unless listed here, so a new
|
||||
// action stays one until a reviewed commit names it routed. Matches the business vocabulary's ROUTED.
|
||||
@@ -77,8 +75,6 @@ const ROUTED = new Set([
|
||||
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
|
||||
'role.hire',
|
||||
'role.fire',
|
||||
// PRD 1.16 REQ-WAIT-3 and REQ-LIB-6 (row 285, #1790): a creative agent's library write.
|
||||
'library.write',
|
||||
]);
|
||||
// REQ-WAIT-1's hard waits: always gated, and a raise for one goes to the human, cited or not.
|
||||
export const HARD_WAITS = Object.freeze(ACTIONS.filter((action) => !ROUTED.has(action)));
|
||||
@@ -100,16 +96,7 @@ export const TASK_PAGE_MAX = 200;
|
||||
// request allowed past the ordinary 64 KiB envelope (server.mjs and client.mjs), up to its content cap.
|
||||
export const PROJECT_FILE_VERBS = Object.freeze(['project.files', 'project.file', 'project.file.save']);
|
||||
// C2-03a: library.sync is the human's fetch, and with `accept` the one way past library-diverged.
|
||||
// Row 224: pure agent lookups are separate from the three human-only verbs. Listing them
|
||||
// here also makes server.mjs await their host effects instead of serializing a Promise as {}.
|
||||
const LIBRARY_LOOKUPS = Object.freeze({
|
||||
'library.find': { method: 'find', fields: ['universe', 'query', 'type', 'book', 'scope', 'offset'], required: ['universe', 'query'] },
|
||||
'library.get': { method: 'get', fields: ['universe', 'path', 'scope', 'from', 'links_from'], required: ['universe', 'path'] },
|
||||
'library.search': { method: 'search', fields: ['universe', 'text', 'book', 'scope', 'offset'], required: ['universe', 'text'] },
|
||||
'library.manuscript': { method: 'manuscript', fields: ['universe', 'book', 'scope', 'chapter', 'from', 'to'], required: ['universe', 'book'] },
|
||||
'library.universe': { method: 'universeInfo', fields: ['universe'], required: ['universe'] },
|
||||
});
|
||||
export const LIBRARY_VERBS = Object.freeze(['library.file', 'library.status', 'library.sync', 'library.file.save', 'library.revert', 'library.doc.write', 'library.doc.delete', ...Object.keys(LIBRARY_LOOKUPS)]);
|
||||
export const LIBRARY_VERBS = Object.freeze(['library.file', 'library.status', 'library.sync']);
|
||||
export const PROJECT_SAVE_VERB = 'project.file.save';
|
||||
export const PROJECT_SAVE_REQUEST_MAX = 1024 * 1024 + 64 * 1024;
|
||||
export const WEB_VERBS =Object.freeze(['web.search', 'web.fetch']);
|
||||
@@ -272,9 +259,6 @@ const grantName = (body) => [body?.action, body?.operation].some(n => typeof n =
|
||||
// Every grant-evidence read filters on this (duckworth-0067 N1): a look-alike under another kind or
|
||||
// actor is not evidence.
|
||||
export const GRANT_EVIDENCE = "kind='action.allowed' AND actor_role IS NULL AND actor_run='grant'";
|
||||
// C2-03b: what a library-fetch-no-grant refusal tells the human (sage-2928). A BusError carries only its
|
||||
// code, so the timer's log line and the library README carry this text.
|
||||
export const LIBRARY_FETCH_FIX = 'add the chief of staff, or grant the library through C2-21';
|
||||
// r6 R4: the host record's identity, hashed at raise and at each later check.
|
||||
export const registrySha256 = ({ address, port, user, sudo, hostKeys }) =>
|
||||
createHash('sha256').update(JSON.stringify({ address, port, user, sudo: !!sudo, hostKeys })).digest('hex');
|
||||
@@ -431,6 +415,7 @@ export class Broker {
|
||||
// Row 236 (#436b): giteaGrant and giteaKeys from gitea-grant.mjs, or null, and the remote halves of
|
||||
// section 5's restart rows and of revokes a restart cut short, for reconcileGitea.
|
||||
#giteaGrant;
|
||||
#githubGrant;
|
||||
#giteaKeys;
|
||||
#giteaUndo = [];
|
||||
// Row 281 (#1786, d6 section 3b): each tracked Gitea promise and the business/agent it acts for, or
|
||||
@@ -455,7 +440,7 @@ export class Broker {
|
||||
#clock = 0;
|
||||
#processing;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null }) {
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null, giteaGrant = null, giteaKeys = null, githubGrant = 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');
|
||||
@@ -482,6 +467,16 @@ export class Broker {
|
||||
this.#hostKeys = hostKeys;
|
||||
this.#giteaGrant = giteaGrant;
|
||||
this.#giteaKeys = giteaKeys;
|
||||
this.#githubGrant = githubGrant;
|
||||
githubGrant?.configure({
|
||||
store,
|
||||
business: (business) => this.#business(business),
|
||||
event: (business, body) => store.transaction(() => this.#grantEvent(business, body, body.agent ?? null)),
|
||||
approved: (business, decision) => this.#approvedGrant(business, decision),
|
||||
consume: (business, decision) => store.transaction(() => this.#consumeGrant(business, decision, 'github')),
|
||||
grantable: (business, agent) => this.#grantableAgent(business, agent),
|
||||
working: () => { this.#processingWork(); return true; },
|
||||
});
|
||||
this.#access = access;
|
||||
this.#accessStamp = accessStamp;
|
||||
this.#compactions = compactions;
|
||||
@@ -1967,33 +1962,6 @@ export class Broker {
|
||||
// grant.host.enrolled or grant.failed died after its registry step: the key file and the record
|
||||
// go, and it ends grant.failed interrupted. A resolved, unspent one is spent and ends the same way.
|
||||
// Neither runs late.
|
||||
// C2-03b (#1791, design 7.2): the credential the library fetch runs with, the bus's choice and not a
|
||||
// pure one: it reads the store. The chief is any roles entry of definition STAFF_TO; a fire keeps it
|
||||
// there (dewey-0860 Q2). Gitea grant evidence for a chief ends the slot fallback, and only the one
|
||||
// listed chief's live, unheld gitea_grants row answers. With no chief entry, any gitea grant evidence
|
||||
// refuses: a departure from the brief's pre-C2-21 fallback, accepted in sage-2928.
|
||||
libraryFetchCredential(business) {
|
||||
const b = this.#business(business);
|
||||
const chiefs = Object.entries(b.roles ?? {}).filter(([, r]) => r?.definition === STAFF_TO).map(([name]) => name);
|
||||
const evidence = (agent) =>
|
||||
this.#store.get(
|
||||
`SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.method')='gitea' AND (? IS NULL OR json_extract(body,'$.agent')=?) LIMIT 1`,
|
||||
business,
|
||||
agent,
|
||||
agent,
|
||||
);
|
||||
const refused = { refused: 'library-fetch-no-grant', fix: LIBRARY_FETCH_FIX };
|
||||
if (!chiefs.length) return evidence(null) ? refused : { kind: 'slot' };
|
||||
if (!chiefs.some((agent) => evidence(agent))) return { kind: 'slot' };
|
||||
const listed = chiefs.filter(
|
||||
(agent) => !this.#firing(business, agent) && (!Array.isArray(b.launch?.instances) || b.launch.instances.includes(agent) || b.launch.by === agent),
|
||||
);
|
||||
if (listed.length !== 1) return refused;
|
||||
const [agent] = listed;
|
||||
const live = this.#store.get('SELECT user FROM gitea_grants WHERE business=? AND agent=?', business, agent);
|
||||
if (!live || !this.#giteaKeys || this.#giteaKeys.held(business, agent)) return refused;
|
||||
return { kind: 'chief', agent, user: live.user };
|
||||
}
|
||||
reconcileGrants() {
|
||||
const ended = [];
|
||||
for (const business of Object.keys(this.#businesses)) {
|
||||
@@ -2066,6 +2034,16 @@ export class Broker {
|
||||
for (const m of this.#openRevokes(business)) this.#revokeUndo.push({ business, ...m });
|
||||
// Row 236 (#436b r5 section 5): the Gitea grants' local part.
|
||||
if (this.#giteaGrant && this.#giteaKeys) ended.push(...this.#giteaReconcileLocal(business));
|
||||
if (this.#githubGrant) {
|
||||
this.#githubGrant.reconcile(business);
|
||||
for (const decision of this.#openGrantDecisions(business, 'github')) {
|
||||
this.#store.transaction(() => {
|
||||
const g = this.#consumeGrant(business, decision, 'github');
|
||||
this.#grantEvent(business, { action: 'grant.failed', method: 'github', grantDecision: decision, agent: g.agent, code: 'interrupted' }, g.agent);
|
||||
});
|
||||
ended.push(decision);
|
||||
}
|
||||
}
|
||||
this.#interruptedJobs(business);
|
||||
}
|
||||
return ended;
|
||||
@@ -2245,6 +2223,8 @@ export class Broker {
|
||||
for (const d of open) if (d.grant.method === 'host') hosts.add(d.grant.host);
|
||||
for (const host of [...hosts].sort()) set.push({ label: 'host:' + host, kind: 'host', host });
|
||||
if (this.#giteaPending(business, agent, open)) set.push({ label: 'gitea', kind: 'gitea' });
|
||||
if (this.#githubGrant && (open.some((d) => d.grant.method === 'github') || this.#githubGrant.pending(business, agent)))
|
||||
set.push({ label: 'github', kind: 'github' });
|
||||
if (this.#hostGrant)
|
||||
for (const { host } of this.#store.all('SELECT host FROM hosts WHERE business=? ORDER BY host', business)) {
|
||||
if (hosts.has(host)) continue;
|
||||
@@ -2263,9 +2243,10 @@ export class Broker {
|
||||
.map((d) => ({ id: d.id, raise: this.#raised(business, d.id) }))
|
||||
.filter(({ id, raise }) => {
|
||||
const g = raise?.grant;
|
||||
if (g?.agent !== agent || (g.method !== 'host' && g.method !== 'gitea')) return false;
|
||||
if (g?.agent !== agent || (g.method !== 'host' && g.method !== 'gitea' && g.method !== 'github')) return false;
|
||||
const r = this.#closed(id);
|
||||
if (r && !(r.op === 'resolved' && r.choice === raise.approvalChoice)) return false;
|
||||
if (g.method === 'github') return !this.#spent(business, id) && !this.#githubGrant?.decisionRevoked(business, g);
|
||||
if (
|
||||
this.#store.get(
|
||||
`SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.grantDecision')=? AND json_extract(body,'$.action') IN ('grant.applied','grant.failed') LIMIT 1`,
|
||||
@@ -2354,6 +2335,12 @@ export class Broker {
|
||||
at = 'gitea';
|
||||
grants.push(...(await this.#stepGGitea(business, agent, { job, reason, by, flag }, refuse)));
|
||||
}
|
||||
if (set.some((x) => x.kind === 'github')) {
|
||||
at = 'github';
|
||||
const result = await this.#githubGrant.revoke(business, agent, reason, by);
|
||||
if (result.serviceLeft.length) refuse('github', 'github-left');
|
||||
grants.push({ grant: 'github', granted: true });
|
||||
}
|
||||
for (const g of set.filter((x) => x.kind === 'line')) {
|
||||
at = g.label;
|
||||
const record = this.#hostRecord(business, g.host);
|
||||
@@ -2603,6 +2590,15 @@ export class Broker {
|
||||
this.#store.transaction(() => this.#agent(s));
|
||||
if (a.action !== 'credential.mint' || 'spec_path' in a || 'spec_commit' in a) fail('invalid-request');
|
||||
if (this.#business(s.business).roles[s.role]?.definition !== STAFF_TO) fail('grant-chief-only');
|
||||
// Row297: the same chief/human grant gate, with a method-specific protected executor.
|
||||
if (a.grant?.method === 'github') {
|
||||
if (!this.#githubGrant) fail('github-not-configured');
|
||||
return this.#store.transaction(() => {
|
||||
const result = this.#dispatch(s, 'decision.raise', a, null, this.#githubGrant.raiseBinding(s.business, a.grant));
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
// Row 236 (#436b section 2): no network call at raise, so every check runs in the raise transaction.
|
||||
if (a.grant?.method === 'gitea') {
|
||||
if (!this.#giteaGrant) fail('gitea-not-configured');
|
||||
@@ -2669,14 +2665,12 @@ export class Broker {
|
||||
}
|
||||
// Row 236 (#1742, #436b r5 section 2): the agent's role and definition, if the agent is a role of the
|
||||
// business whose definition declares gitea and which has identity and a publish grant (199 D8). A
|
||||
// creative role gets no grant, whatever its definition declares (mos-1163). Row 285 (#1790, sage-2921):
|
||||
// nor does the chief of staff, whose definition declares gitea for a read grant only, since this path
|
||||
// mints SEAT_SCOPES, write:repository included.
|
||||
// creative role gets no grant, whatever its definition declares (mos-1163).
|
||||
#giteaRole(business, agent) {
|
||||
const b = this.#business(business);
|
||||
if (!Object.hasOwn(b.roles, agent)) fail('gitea-not-declared');
|
||||
const role = b.roles[agent];
|
||||
if (CREATIVE_DEFINITIONS.includes(role.definition) || role.definition === STAFF_TO) fail('gitea-not-declared');
|
||||
if (CREATIVE_DEFINITIONS.includes(role.definition)) fail('gitea-not-declared');
|
||||
const definition = Object.hasOwn(b.definitions ?? {}, role.definition) ? b.definitions[role.definition] : null;
|
||||
if (
|
||||
!definition?.credentials?.some((c) => c.service === 'gitea') ||
|
||||
@@ -2867,6 +2861,7 @@ export class Broker {
|
||||
async grantsIdle() {
|
||||
while (this.#grantJobs.size || this.#giteaRuns.size || this.#hostRuns.size)
|
||||
await Promise.allSettled([...this.#grantJobs, ...this.#giteaRuns.keys(), ...[...this.#hostRuns.values()].flatMap((s) => [...s])]);
|
||||
await this.#githubGrant?.idle();
|
||||
}
|
||||
// Section 4 steps 0 (the remote half) to 8. Never throws: the outcome is in the store and the
|
||||
// notices, and the return value is 'applied', 'settle-pending' or the code grant.failed records.
|
||||
@@ -3594,10 +3589,75 @@ export class Broker {
|
||||
if (this.#business(s.business).roles[s.role]?.definition !== STAFF_TO) fail('grant-chief-only');
|
||||
return s.role;
|
||||
}
|
||||
// Row297 trusted host API. No socket verb returns tokens or renews grants.
|
||||
githubWithToken(business, agent, fn) {
|
||||
if (!this.#githubGrant) fail('github-not-configured');
|
||||
return this.#githubGrant.withToken(business, agent, fn);
|
||||
}
|
||||
async reconcileGithub() {
|
||||
if (!this.#githubGrant) return;
|
||||
for (const business of Object.keys(this.#businesses))
|
||||
if (this.#githubGrant.status(business).serviceLeft.length) await this.#githubGrant.sweep(business, 'restart');
|
||||
}
|
||||
async closeGithub() { await this.#githubGrant?.close(); }
|
||||
#startGithub(business, decision) {
|
||||
if (!this.#githubGrant) fail('github-not-configured');
|
||||
const g = this.#approvedGrant(business, decision);
|
||||
if (g?.method !== 'github') fail('not-authorized');
|
||||
const job = this.#track(this.#githubGrant.run(business, decision), business + '/' + g.agent);
|
||||
job.then(
|
||||
() => this.#store.transaction(() => this.#grantNotice(business, decision, `GitHub grant to ${g.agent} applied; protected renewal stays within the approved binding.`)),
|
||||
(e) => this.#store.transaction(() => this.#grantNotice(business, decision, `GitHub grant to ${g.agent} failed: ${e instanceof BusError ? e.code : 'github-failed'}.`)),
|
||||
).catch(() => {});
|
||||
return job;
|
||||
}
|
||||
async #requestGithub(s, verb, a, by) {
|
||||
if (!this.#githubGrant) fail('github-not-configured');
|
||||
const manager = this.#githubGrant;
|
||||
if (verb === 'grants.status' || verb === 'grants.sweep') {
|
||||
keys(a, ['service'], ['service']);
|
||||
return verb === 'grants.status' ? manager.status(s.business) : manager.sweep(s.business, by);
|
||||
}
|
||||
if (verb === 'grants.revoke') {
|
||||
keys(a, ['service', 'agent', 'reason'], ['service', 'agent', 'reason']);
|
||||
identifier(a.agent); string(a.reason, 500);
|
||||
if (!Object.hasOwn(this.#business(s.business).roles, a.agent)) fail('credential-unknown');
|
||||
if (this.#revoking.has(s.business + '/' + a.agent)) fail('grant-revoke-running');
|
||||
return manager.revoke(s.business, a.agent, a.reason, by);
|
||||
}
|
||||
if (verb === 'grants.admin.remove') {
|
||||
keys(a, ['service'], ['service']);
|
||||
manager.api.removeAdmin(s.business);
|
||||
this.#store.transaction(() => this.#grantEvent(s.business, { action: 'grant.admin', method: 'github', verb: 'remove', by }));
|
||||
return { removed: true };
|
||||
}
|
||||
const replace = verb === 'grants.admin.rotate';
|
||||
keys(a, replace ? ['service', 'rotateBy', 'token'] : ['service', 'url', 'appId', 'installationId', 'accountId', 'rotateBy', 'token'],
|
||||
replace ? ['service', 'rotateBy', 'token'] : ['service', 'url', 'appId', 'installationId', 'accountId', 'rotateBy', 'token']);
|
||||
const previous = replace ? manager.api.adminMeta(s.business) : null;
|
||||
if (replace && !previous) fail('grant-admin-missing');
|
||||
const meta = { url: replace ? previous.url : a.url, appId: replace ? previous.appId : a.appId,
|
||||
installationId: replace ? previous.installationId : a.installationId, accountId: replace ? previous.accountId : a.accountId,
|
||||
rotateBy: a.rotateBy, setAt: this.#now() };
|
||||
const voided = this.#openGrantDecisions(s.business, 'github').length;
|
||||
await manager.api.setAdmin(s.business, meta, a.token, manager.audit(s.business, 'admin:' + by), { replace });
|
||||
this.#store.transaction(() => this.#grantEvent(s.business, { action: 'grant.admin', method: 'github', verb: replace ? 'rotate' : 'set', ...meta, voided, by }));
|
||||
return { ...meta, voided };
|
||||
}
|
||||
// Row 236: the grants verbs. Async, because each one may call Gitea.
|
||||
async #requestGrants(s, verb, a) {
|
||||
try {
|
||||
const by = this.#grantsCaller(s, verb);
|
||||
if (verb === 'grants.run') {
|
||||
keys(a, ['decision'], ['decision']); identifier(a.decision);
|
||||
}
|
||||
if (verb === 'grants.run' && this.#approvedGrant(s.business, a.decision)?.method === 'github') {
|
||||
if (this.#spent(s.business, a.decision)) fail('decision-consumed');
|
||||
const g = this.#approvedGrant(s.business, a.decision);
|
||||
this.#startGithub(s.business, a.decision).catch(() => {});
|
||||
return { decision: a.decision, agent: g.agent, started: true };
|
||||
}
|
||||
if (a.service === 'github') return await this.#requestGithub(s, verb, a, by);
|
||||
if (!this.#giteaGrant || !this.#giteaKeys) fail('gitea-not-configured');
|
||||
if (verb === 'grants.run') {
|
||||
keys(a, ['decision'], ['decision']);
|
||||
@@ -4838,6 +4898,8 @@ export class Broker {
|
||||
throw new BusError('outcome-unknown');
|
||||
}
|
||||
this.#secretCheck(result);
|
||||
if (verb === 'credentials.delete' && this.#githubGrant?.pending(s.business, a.agent))
|
||||
this.#track(this.#githubGrant.revoke(s.business, a.agent, a.reason, s.tester ? 'tester' : s.human), s.business + '/' + a.agent).catch(() => {});
|
||||
// Row 236 (#436b section 6): a gitea revoke, or a fire, of an agent this stack made a Gitea user for
|
||||
// takes the grant away too. Credentials has done step 1. Step 2 runs here; step 3 and the fire's
|
||||
// steps run once this returns.
|
||||
@@ -5170,147 +5232,31 @@ export class Broker {
|
||||
throw this.#refused(s, ProjectFailure && e instanceof ProjectFailure ? new BusError(e.code) : e, request);
|
||||
}
|
||||
}
|
||||
// C2-02: all authority and credential choice stays on the host. Neither an agent's tool
|
||||
// arguments nor the human's save may supply a credential, identity or commit message.
|
||||
#libraryWriter(s, project) {
|
||||
if (s.tester) fail('tester-refused');
|
||||
const b = this.#business(s.business), role = b.roles?.[s.role];
|
||||
if (s.human || s.reader || !CREATIVE_DEFINITIONS.includes(role?.definition)
|
||||
|| !role.authority?.withinRole?.includes('library.write')) fail('library-write-refused');
|
||||
this.#agent(s);
|
||||
const live = this.#store.get('SELECT user,projects,rotate_by FROM gitea_grants WHERE business=? AND agent=?', s.business, s.role);
|
||||
if (!live || !this.#giteaKeys || this.#giteaKeys.held(s.business, s.role)
|
||||
|| this.#firing(s.business, s.role) || live.rotate_by < this.#now().slice(0, 10)
|
||||
|| !role.publish?.includes(project)) fail('library-write-no-grant');
|
||||
let projects;
|
||||
try { projects = JSON.parse(live.projects); } catch { fail('library-write-no-grant'); }
|
||||
if (!Array.isArray(projects) || !projects.includes(project) || live.user !== role.identity?.gitea?.user)
|
||||
fail('library-write-no-grant');
|
||||
if (!role.identity?.name || !role.identity?.email) fail('library-write-refused');
|
||||
return { identity: { name: role.identity.name, email: role.identity.email },
|
||||
agent: `${s.business}/${s.role}`, credential: { kind: 'seat', agent: s.role, user: live.user } };
|
||||
}
|
||||
#libraryWriteNotice(s, op, result) {
|
||||
const mid = randomUUID();
|
||||
this.#store.run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
mid, this.#now(), s.business, s.role, s.run, 'human', 'INFO', null, null, null,
|
||||
JSON.stringify({ notice: 'library-write', op, path: result.path, commit: result.commit }));
|
||||
this.#route(s.business, mid, 'human', s.session);
|
||||
}
|
||||
// Row 224 C2-05b: the agent never supplies a host repo, commit or business. Its current
|
||||
// holder and business entry are rechecked before/after awaits; runtime owns Git and queries.
|
||||
async #requestLibraryLookup(s, { verb, args }) {
|
||||
try {
|
||||
this.#agent(s);
|
||||
const business = this.#business(s.business);
|
||||
const projects = Object.entries(business.projects ?? {}).filter(([, entry]) => entry.kind === 'library');
|
||||
if (!projects.length || typeof this.#library?.lookup !== 'function') fail('library-not-configured');
|
||||
const spec = LIBRARY_LOOKUPS[verb];
|
||||
keys(args, ['project', ...spec.fields], spec.required);
|
||||
if (!text(args.universe, 160)) fail('invalid-request');
|
||||
if (args.project === undefined && projects.length !== 1) fail('invalid-request');
|
||||
const project = args.project ?? projects[0][0];
|
||||
if (!text(project, 160) || !projects.some(([name]) => name === project)) fail('library-not-configured');
|
||||
const revision = JSON.stringify(business.projects[project]);
|
||||
const authorize = () => {
|
||||
this.#agent(s);
|
||||
this.#processingGate(s, verb);
|
||||
if (JSON.stringify(this.#business(s.business).projects?.[project]) !== revision) fail('library-not-configured');
|
||||
};
|
||||
const { project: ignored, ...query } = args;
|
||||
authorize();
|
||||
const result = await this.#library.lookup({ business, project }, { method: spec.method, args: query, authorize });
|
||||
authorize();
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const Failure = this.#library?.LibraryError;
|
||||
throw this.#refused(s, Failure && error instanceof Failure ? new BusError(error.code) : error, { verb, args });
|
||||
}
|
||||
}
|
||||
// C2-04 M4: only a verified human reads library text or status. Host Git
|
||||
// functions stay injected, preserving this module's sandbox import closure.
|
||||
async #requestLibrary(s, request) {
|
||||
const { verb, args } = request;
|
||||
if (Object.hasOwn(LIBRARY_LOOKUPS, verb)) return this.#requestLibraryLookup(s, request);
|
||||
try {
|
||||
const agent = verb === 'library.doc.write' || verb === 'library.doc.delete';
|
||||
const b = this.#business(s.business);
|
||||
let project = args.project, writer;
|
||||
if (agent) {
|
||||
// The caller gate precedes argument checks, including human and tester refusals.
|
||||
if (s.tester) fail('tester-refused');
|
||||
if (s.human || s.reader || !CREATIVE_DEFINITIONS.includes(b.roles?.[s.role]?.definition)
|
||||
|| !b.roles[s.role].authority?.withinRole?.includes('library.write')) fail('library-write-refused');
|
||||
this.#agent(s);
|
||||
const fields = ['universe', 'path', 'base', ...(verb === 'library.doc.write' ? ['content'] : [])];
|
||||
keys(args, fields, fields);
|
||||
if (typeof args.universe !== 'string') fail('invalid-request');
|
||||
const matches = Object.entries(b.projects ?? {}).filter(([, e]) => e.kind === 'library'
|
||||
&& Object.hasOwn(e.universes ?? {}, args.universe));
|
||||
if (matches.length !== 1) fail('project-not-configured');
|
||||
project = matches[0][0];
|
||||
writer = this.#libraryWriter(s, project);
|
||||
} else {
|
||||
this.#human(s);
|
||||
const fields = verb === 'library.file' ? ['project', 'path'] : verb === 'library.sync' ? ['project', 'accept']
|
||||
: verb === 'library.file.save' ? ['project', 'path', 'base', 'content']
|
||||
: verb === 'library.revert' ? ['project', 'commit'] : ['project'];
|
||||
keys(args, fields, verb === 'library.sync' ? ['project'] : fields);
|
||||
}
|
||||
if (typeof project !== 'string' || b.projects?.[project]?.kind !== 'library') fail('project-not-configured');
|
||||
if (verb === 'library.revert' && (typeof args.commit !== 'string' || !/^[a-f0-9]{40}$/.test(args.commit))) fail('invalid-request');
|
||||
if (agent || verb === 'library.file.save') {
|
||||
if (args.base !== null && (typeof args.base !== 'string' || !/^[a-f0-9]{40}$/.test(args.base))) fail('invalid-request');
|
||||
if (verb !== 'library.doc.delete' && (typeof args.content !== 'string' || args.content.includes('\0')
|
||||
|| Buffer.byteLength(args.content, 'utf8') > 1024 * 1024
|
||||
|| Buffer.from(args.content, 'utf8').toString('utf8') !== args.content)) fail('invalid-request');
|
||||
}
|
||||
const context = { business: b, project };
|
||||
this.#human(s);
|
||||
const fields = verb === 'library.file' ? ['project', 'path'] : verb === 'library.sync' ? ['project', 'accept'] : ['project'];
|
||||
keys(args, fields, verb === 'library.sync' ? ['project'] : fields);
|
||||
if (!this.#library?.readFile || !this.#library?.status) fail('project-not-configured');
|
||||
if (verb === 'library.sync' && !this.#library.sync) fail('project-not-configured');
|
||||
const context = { business: this.#business(s.business), project: args.project };
|
||||
let result;
|
||||
const method = agent ? 'writeDoc' : verb === 'library.file.save' ? 'saveFile'
|
||||
: verb === 'library.revert' ? 'revert' : verb === 'library.file' ? 'readFile' : verb === 'library.status' ? 'status' : 'sync';
|
||||
if (typeof this.#library?.[method] !== 'function') fail('project-not-configured');
|
||||
if (agent) {
|
||||
const op = verb === 'library.doc.delete' ? 'delete' : 'write';
|
||||
result = await this.#library.writeDoc({ ...context, ...args }, { ...writer, op,
|
||||
beforePush: () => { this.#processingWork(); this.#libraryWriter(s, project); },
|
||||
onPushed: saved => {
|
||||
try {
|
||||
this.#store.transaction(() => {
|
||||
this.#libraryWriteNotice(s, op, saved);
|
||||
this.#event(s, 'action.allowed', { action: 'library.write', class: 'within-role', project, path: saved.path, commit: saved.commit });
|
||||
});
|
||||
} catch {
|
||||
// A separate metadata-only sink survives a Console store failure. Never log args,
|
||||
// file text, credentials or raw SQL/exception output.
|
||||
try { this.#library.log?.({ event: 'library-notice-store-failed', code: 'outcome-unknown',
|
||||
business: s.business, instance: s.role, op, commit: saved.commit }); } catch {}
|
||||
fail('outcome-unknown');
|
||||
}
|
||||
} });
|
||||
} else if (verb === 'library.file.save') {
|
||||
this.#processingWork();
|
||||
result = await this.#library.saveFile({ ...context, path: args.path, base: args.base, content: args.content });
|
||||
} else if (verb === 'library.revert') {
|
||||
this.#processingWork();
|
||||
result = await this.#library.revert({ ...context, commit: args.commit });
|
||||
} else if (verb === 'library.file') result = await this.#library.readFile({ ...context, path: args.path });
|
||||
if (verb === 'library.file') result = await this.#library.readFile({ ...context, path: args.path });
|
||||
else if (verb === 'library.status') result = await this.#library.status(context);
|
||||
else {
|
||||
// A fetch that did not move served still answers with the code, so the refusal is recorded.
|
||||
const { state, code } = await this.#library.sync(context, { accept: args.accept ?? null });
|
||||
if (code !== null) fail(code);
|
||||
result = { project, ...state };
|
||||
result = { project: args.project, ...state };
|
||||
}
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const Failure = this.#library?.LibraryError;
|
||||
const mapped = Failure && error instanceof Failure ? new BusError(error.code) : error;
|
||||
const refused = this.#refused(s, mapped, request);
|
||||
// Once a push succeeded the remote effect is uncertain to the caller even if the store
|
||||
// cannot record refusal evidence. Do not replace that with an ordinary storage refusal.
|
||||
throw mapped instanceof BusError && mapped.code === 'outcome-unknown' ? mapped : refused;
|
||||
throw this.#refused(s, Failure && error instanceof Failure ? new BusError(error.code) : error, request);
|
||||
}
|
||||
}
|
||||
// The eight task verbs reach the trusted S3 handler here. It runs outside any transaction, gets the
|
||||
@@ -5818,6 +5764,7 @@ export class Broker {
|
||||
if (grant?.method === 'host-enroll') setImmediate(() => this.#runEnrollment(s.business, d.id));
|
||||
if (grant?.method === 'host') setImmediate(() => this.#runHostGrant(s.business, d.id));
|
||||
if (grant?.method === 'gitea') setImmediate(() => this.#startGitea(s.business, d.id));
|
||||
if (grant?.method === 'github') setImmediate(() => Promise.resolve().then(() => this.#startGithub(s.business, d.id)).catch(() => {}));
|
||||
// Row 115 (#1609): a close by anyone but the raising run tells the raising role, routed like message.send's.
|
||||
// Row 133 (#1627): no messaging check here. decision.raise refused a route that can't message the
|
||||
// raiser, definitions change only with a pinned copy and arbiters only through the business
|
||||
|
||||
@@ -17,7 +17,7 @@ export class Client {
|
||||
verb,
|
||||
args,
|
||||
}) + '\n';
|
||||
if (Buffer.byteLength(request) > ([PROJECT_SAVE_VERB, 'library.file.save'].includes(verb) ? PROJECT_SAVE_REQUEST_MAX : 65536))
|
||||
if (Buffer.byteLength(request) > (verb === PROJECT_SAVE_VERB ? PROJECT_SAVE_REQUEST_MAX : 65536))
|
||||
return Promise.reject(new BusError('request-too-large'));
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = connect(this.path);
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Row297 GitHub App HTTPS boundary. No PATs, user creation or fork calls.
|
||||
import { createPrivateKey, sign } from 'node:crypto';
|
||||
import { request } from 'node:https';
|
||||
import { BusError } from './broker.mjs';
|
||||
import { day } from './credentials.mjs';
|
||||
import { githubDirectory, githubRead, githubWrite, githubUnlink } from './github-files.mjs';
|
||||
|
||||
export const FIXED_PERMISSIONS = Object.freeze({ contents: 'write', metadata: 'read' });
|
||||
export const API_VERSION = '2026-03-10';
|
||||
export const CALL_TIMEOUT_MS = 15000;
|
||||
const META_KEYS = ['accountId', 'appId', 'installationId', 'rotateBy', 'setAt', 'url'];
|
||||
const id = (v) => Number.isSafeInteger(v) && v > 0;
|
||||
const fail = (code) => { throw new BusError(code); };
|
||||
export function githubOrigin(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== 'https:' || url.username || url.password || value !== url.origin) fail('invalid-request');
|
||||
return url.origin;
|
||||
} catch { fail('invalid-request'); }
|
||||
}
|
||||
export function githubMeta(meta) {
|
||||
if (!meta || typeof meta !== 'object' || Array.isArray(meta) || Object.keys(meta).sort().join() !== META_KEYS.join()) fail('invalid-request');
|
||||
githubOrigin(meta.url);
|
||||
if (![meta.appId, meta.installationId, meta.accountId].every(id) || !day(meta.rotateBy) ||
|
||||
typeof meta.setAt !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(meta.setAt) || !Number.isFinite(Date.parse(meta.setAt)) || new Date(meta.setAt).toISOString() !== meta.setAt) fail('invalid-request');
|
||||
return { ...meta };
|
||||
}
|
||||
export function githubRsa(text) {
|
||||
try {
|
||||
if (typeof text !== 'string' || text.length > 16384 || !/^-----BEGIN (RSA )?PRIVATE KEY-----\n/.test(text)) fail('credential-format');
|
||||
const key = createPrivateKey(text);
|
||||
if (key.asymmetricKeyType !== 'rsa' || key.asymmetricKeyDetails.modulusLength < 2048) fail('credential-format');
|
||||
return key;
|
||||
} catch { fail('credential-format'); }
|
||||
}
|
||||
export function githubJwt(key, appId, now) {
|
||||
const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
const secs = Math.floor(now / 1000);
|
||||
const input = encode({ alg: 'RS256', typ: 'JWT' }) + '.' + encode({ iat: secs - 60, exp: secs + 540, iss: String(appId) });
|
||||
return input + '.' + sign('RSA-SHA256', Buffer.from(input), key).toString('base64url');
|
||||
}
|
||||
// Never follows redirects or returns headers/error text. Body limit and whole-call timeout.
|
||||
export function sendGithub({ url, method, path, token, body, ca, timeoutMs = CALL_TIMEOUT_MS }) {
|
||||
return new Promise((resolve) => {
|
||||
let req, ended = false;
|
||||
const end = (status = 0, json = null) => {
|
||||
if (ended) return;
|
||||
ended = true; clearTimeout(timer); resolve({ status, json });
|
||||
};
|
||||
const timer = setTimeout(() => { req?.destroy(); end(); }, timeoutMs);
|
||||
try {
|
||||
githubOrigin(url);
|
||||
if (!path.startsWith('/') || path.startsWith('//')) fail('invalid-request');
|
||||
const data = body === undefined ? null : Buffer.from(JSON.stringify(body));
|
||||
req = request(new URL(path, url), { method, ca, agent: false, headers: {
|
||||
accept: 'application/vnd.github+json', authorization: 'Bearer ' + token,
|
||||
'x-github-api-version': API_VERSION, 'user-agent': 'Mosaic-grant',
|
||||
...(data ? { 'content-type': 'application/json', 'content-length': String(data.length) } : {}),
|
||||
} }, (res) => {
|
||||
let size = 0; const chunks = [];
|
||||
res.on('data', (part) => {
|
||||
size += part.length;
|
||||
if (size > 4 * 1024 * 1024) { req.destroy(); end(); }
|
||||
else chunks.push(part);
|
||||
});
|
||||
res.on('end', () => {
|
||||
let json = null;
|
||||
try { json = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch {}
|
||||
end(res.statusCode, json);
|
||||
});
|
||||
res.on('error', () => end());
|
||||
});
|
||||
req.on('error', () => end()); req.end(data ?? undefined);
|
||||
} catch { end(); }
|
||||
});
|
||||
}
|
||||
|
||||
export function githubApi({ configDir, ca, transport = sendGithub, now = () => Date.now(), timeoutMs = CALL_TIMEOUT_MS }) {
|
||||
const directory = (business, create = false) => githubDirectory(configDir, ['grants', business, 'github'], create);
|
||||
const adminMeta = (business) => {
|
||||
const text = githubRead(directory(business), 'admin.json');
|
||||
if (text === null) return null;
|
||||
try { return githubMeta(JSON.parse(text)); } catch { fail('credential-file'); }
|
||||
};
|
||||
const call = async (meta, key, log, method, path, { token, body, installation = false } = {}) => {
|
||||
if (typeof log !== 'function') fail('not-configured');
|
||||
if (installation && (typeof token !== 'string' || token.length < 16 || token.length > 16384 || !/^[-A-Za-z0-9._~]+$/.test(token))) fail('credential-format');
|
||||
const authorization = installation ? token : githubJwt(key, meta.appId, now());
|
||||
return log({ method, path }, () => transport({ url: meta.url, method, path, token: authorization, body, ca, timeoutMs }));
|
||||
};
|
||||
const check = async (meta, key, log) => {
|
||||
if (meta.rotateBy <= new Date(now()).toISOString().slice(0, 10)) fail('grant-admin-rotation-due');
|
||||
const app = await call(meta, key, log, 'GET', '/app');
|
||||
if ([401, 403].includes(app.status)) fail('grant-admin-account');
|
||||
if (app.status !== 200) fail('github-failed');
|
||||
if (app.json?.id !== meta.appId) fail('grant-admin-account');
|
||||
const install = await call(meta, key, log, 'GET', `/app/installations/${meta.installationId}`);
|
||||
if (install.status !== 200) fail([401, 403, 404].includes(install.status) ? 'grant-admin-account' : 'github-failed');
|
||||
const i = install.json;
|
||||
if (i?.id !== meta.installationId || i.app_id !== meta.appId || i.account?.id !== meta.accountId || i.suspended_at !== null) fail('grant-admin-account');
|
||||
if (!i.permissions || !Object.entries(FIXED_PERMISSIONS).every(([k, v]) => i.permissions[k] === v || (v === 'read' && i.permissions[k] === 'write'))) fail('grant-admin-scope');
|
||||
};
|
||||
const admin = (business) => {
|
||||
const meta = adminMeta(business);
|
||||
if (!meta) fail('grant-admin-missing');
|
||||
const key = githubRsa(githubRead(directory(business), 'admin'));
|
||||
return { meta, key };
|
||||
};
|
||||
return {
|
||||
adminMeta,
|
||||
async setAdmin(business, input, text, log, { replace = false } = {}) {
|
||||
const meta = githubMeta(input); const key = githubRsa(text);
|
||||
const old = adminMeta(business);
|
||||
if (!replace && (old || githubRead(directory(business), 'admin') !== null)) fail('grant-admin-exists');
|
||||
if (replace && (!old || ['url', 'appId', 'installationId', 'accountId'].some((k) => old[k] !== meta[k]))) fail('grant-admin-account');
|
||||
await check(meta, key, log);
|
||||
const dir = directory(business, true);
|
||||
// Rotation invalidates approved bindings before replacing the signing key.
|
||||
if (replace) githubWrite(dir, 'admin.json', JSON.stringify(meta) + '\n');
|
||||
githubWrite(dir, 'admin', text.replace(/\n?$/, '\n'));
|
||||
if (!replace) githubWrite(dir, 'admin.json', JSON.stringify(meta) + '\n');
|
||||
return meta;
|
||||
},
|
||||
removeAdmin(business) {
|
||||
const dir = directory(business);
|
||||
githubUnlink(dir, 'admin.json'); githubUnlink(dir, 'admin');
|
||||
},
|
||||
async checkAdmin(business, log) { const { meta, key } = admin(business); await check(meta, key, log); return meta; },
|
||||
mint(business, repositoryIds, log) {
|
||||
const { meta, key } = admin(business);
|
||||
return call(meta, key, log, 'POST', `/app/installations/${meta.installationId}/access_tokens`,
|
||||
{ body: { repository_ids: repositoryIds, permissions: { ...FIXED_PERMISSIONS } } });
|
||||
},
|
||||
repos(business, token, page, log) {
|
||||
const { meta, key } = admin(business);
|
||||
return call(meta, key, log, 'GET', `/installation/repositories?per_page=30&page=${page}`, { token, installation: true });
|
||||
},
|
||||
revoke(business, token, log) {
|
||||
const { meta, key } = admin(business);
|
||||
return call(meta, key, log, 'DELETE', '/installation/token', { token, installation: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Row297 protected files. Values never enter the bus event store.
|
||||
import { closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { BusError } from './broker.mjs';
|
||||
import { privateDir } from './credentials.mjs';
|
||||
|
||||
const fail = () => { throw new BusError('credential-file'); };
|
||||
const checkName = (name) => { if (typeof name !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(name) || name === '..') fail(); };
|
||||
export function githubDirectory(configDir, parts, create = false) {
|
||||
let dir = configDir;
|
||||
for (const part of ['secrets', ...parts]) {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(part) || part === '..') fail();
|
||||
dir = join(dir, part);
|
||||
if (!privateDir(dir, { missing: true })) {
|
||||
if (!create) return null;
|
||||
mkdirSync(dir, { mode: 0o700 });
|
||||
}
|
||||
privateDir(dir);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
export function githubRead(dir, name) {
|
||||
checkName(name);
|
||||
if (!dir) return null;
|
||||
let fd;
|
||||
try { fd = openSync(join(dir, name), constants.O_RDONLY | constants.O_NOFOLLOW); }
|
||||
catch (e) { if (e.code === 'ENOENT') return null; fail(); }
|
||||
try {
|
||||
const stat = fstatSync(fd);
|
||||
if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o777) !== 0o600 || stat.size > 65536) fail();
|
||||
return readFileSync(fd, 'utf8');
|
||||
} catch (e) { if (e instanceof BusError) throw e; fail(); }
|
||||
finally { closeSync(fd); }
|
||||
}
|
||||
export function githubWrite(dir, name, text) {
|
||||
checkName(name);
|
||||
const target = join(dir, name);
|
||||
// A foreign/symlink existing file is a refusal, never something to repair silently.
|
||||
try {
|
||||
const stat = lstatSync(target);
|
||||
if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o777) !== 0o600) fail();
|
||||
} catch (e) { if (e.code !== 'ENOENT') throw e; }
|
||||
const tmp = target + '.' + randomUUID() + '.tmp';
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
writeSync(fd, text); fsyncSync(fd); closeSync(fd); fd = undefined;
|
||||
renameSync(tmp, target);
|
||||
const parent = openSync(dir, constants.O_RDONLY);
|
||||
try { fsyncSync(parent); } finally { closeSync(parent); }
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
try { unlinkSync(tmp); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
||||
}
|
||||
}
|
||||
export function githubUnlink(dir, name) {
|
||||
if (!dir) return;
|
||||
// Apply the same checks before deleting any protected file.
|
||||
if (githubRead(dir, name) === null) return;
|
||||
unlinkSync(join(dir, name));
|
||||
const fd = openSync(dir, constants.O_RDONLY);
|
||||
try { fsyncSync(fd); } finally { closeSync(fd); }
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
// Row297: protected, event-backed GitHub grants. No worker-facing token or renewal verb.
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { BusError, GRANT_EVIDENCE } from './broker.mjs';
|
||||
import { canonicalJson } from '../../business/src/util.mjs';
|
||||
import { CREATIVE_DEFINITIONS, ID_PATTERN } from '../../business/src/vocabulary.mjs';
|
||||
import { day } from './credentials.mjs';
|
||||
import { FIXED_PERMISSIONS, CALL_TIMEOUT_MS } from './github-api.mjs';
|
||||
import { githubDirectory, githubRead, githubWrite, githubUnlink } from './github-files.mjs';
|
||||
export { CALL_TIMEOUT_MS };
|
||||
export const TOKEN_LIFETIME_MS = 3600000;
|
||||
export const CLOCK_SKEW_MS = 60000;
|
||||
export const TOKEN_MARGIN_MS = 60000;
|
||||
const PAGE_SIZE = 30;
|
||||
const fail = (code) => { throw new BusError(code); };
|
||||
const hash = (v) => createHash('sha256').update(canonicalJson(v)).digest('hex');
|
||||
const isId = (v) => Number.isSafeInteger(v) && v > 0;
|
||||
const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||
const exact = (v, keys) => object(v) && Object.keys(v).sort().join() === [...keys].sort().join();
|
||||
const tokenText = (v) => typeof v === 'string' && v.length >= 16 && v.length <= 16384 && /^[-A-Za-z0-9._~]+$/.test(v);
|
||||
const sameIds = (rows, bound) => Array.isArray(rows) && rows.length === bound.length &&
|
||||
rows.every((r) => isId(r?.id)) && new Set(rows.map((r) => r.id)).size === rows.length &&
|
||||
rows.every((r) => bound.some((b) => b.id === r.id));
|
||||
const identity = (m) => Object.fromEntries(['url', 'appId', 'installationId', 'accountId'].map((k) => [k, m[k]]));
|
||||
const refusal = (e) => e instanceof BusError ? e.code : 'github-failed';
|
||||
|
||||
export function githubGrant({ api, configDir, now = () => Date.now(), timeoutMs = CALL_TIMEOUT_MS }) {
|
||||
let context = null;
|
||||
const claims = new Map();
|
||||
const jobs = new Set();
|
||||
const key = (b, a) => b + '/' + a;
|
||||
const at = () => new Date(now()).toISOString();
|
||||
const ctx = () => context ?? fail('github-not-configured');
|
||||
const event = (business, body) => ctx().event(business, { ...body, method: 'github' });
|
||||
const rows = (business) => ctx().store.all(
|
||||
`SELECT seq,body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND (json_extract(body,'$.method')='github' OR json_extract(body,'$.operation')='grant.github') ORDER BY seq`, business,
|
||||
).map((r) => ({ seq: r.seq, ...JSON.parse(r.body) }));
|
||||
const state = (business) => {
|
||||
const entries = rows(business), grants = new Map(), intents = new Map(), closed = new Set(), revoked = new Map();
|
||||
for (const e of entries) {
|
||||
if (['grant.applied', 'grant.renewed'].includes(e.action)) grants.set(e.agent, e);
|
||||
if (e.action === 'grant.revoked') { grants.delete(e.agent); revoked.set(e.agent, e.seq); }
|
||||
if (e.action === 'grant.step' && e.step === 'mint') intents.set(e.generation, e);
|
||||
if (e.action === 'grant.token.closed') closed.add(e.generation);
|
||||
}
|
||||
return { entries, grants, intents, closed, revoked };
|
||||
};
|
||||
const log = (business, grantDecision, agent = null, generation = null) => async (op, fn) => {
|
||||
const callId = randomUUID();
|
||||
event(business, { action: 'grant.call', grantDecision, agent, generation, callId, op });
|
||||
let result;
|
||||
try { result = await fn(); } catch { result = { status: 0, json: null }; }
|
||||
// Deliberately never include the response body or authorization header.
|
||||
event(business, { action: 'grant.result', grantDecision, agent, generation, callId, status: result.status });
|
||||
return result;
|
||||
};
|
||||
const tracked = (fn) => {
|
||||
const job = (async () => fn())(); jobs.add(job);
|
||||
job.finally(() => jobs.delete(job)).catch(() => {});
|
||||
return job;
|
||||
};
|
||||
const claim = (business, agent) => {
|
||||
const k = key(business, agent);
|
||||
if (claims.has(k)) fail('grant-busy');
|
||||
const controller = new AbortController(); claims.set(k, controller);
|
||||
return controller;
|
||||
};
|
||||
const release = (business, agent, controller) => {
|
||||
if (claims.get(key(business, agent)) === controller) claims.delete(key(business, agent));
|
||||
};
|
||||
const generationDir = (business, agent, create = false) => githubDirectory(configDir, ['grants', business, 'github', 'tokens', agent], create);
|
||||
const readToken = (business, intent) => {
|
||||
const text = githubRead(generationDir(business, intent.agent), intent.generation);
|
||||
if (text === null) return null;
|
||||
if (!tokenText(text.trimEnd()) || text !== text.trimEnd() + '\n') fail('credential-format');
|
||||
return text.trimEnd();
|
||||
};
|
||||
const roleSnapshot = (business, agent) => {
|
||||
if (typeof agent !== 'string' || !ID_PATTERN.test(agent)) fail('invalid-request');
|
||||
const b = ctx().business(business);
|
||||
const role = Object.hasOwn(b.roles ?? {}, agent) ? b.roles[agent] : null;
|
||||
const definition = role && Object.hasOwn(b.definitions ?? {}, role.definition) ? b.definitions[role.definition] : null;
|
||||
if (!definition?.credentials?.some((c) => exact(c, ['service']) && c.service === 'github') ||
|
||||
CREATIVE_DEFINITIONS.includes(role.definition) || !exact(role.github, ['projects']) ||
|
||||
!Array.isArray(role.github.projects) || !role.github.projects.length || role.github.projects.some((p) => typeof p !== 'string' || !ID_PATTERN.test(p)) ||
|
||||
new Set(role.github.projects).size !== role.github.projects.length) fail('github-not-declared');
|
||||
const meta = api.adminMeta(business);
|
||||
if (!meta) fail('grant-admin-missing');
|
||||
if (meta.rotateBy <= at().slice(0, 10)) fail('grant-admin-rotation-due');
|
||||
const origin = meta.url === 'https://api.github.com' ? 'https://github.com' : meta.url;
|
||||
const repositories = role.github.projects.map((project) => {
|
||||
const entry = b.projects?.[project];
|
||||
if (!isId(entry?.githubId) || typeof entry.remote !== 'string' || entry.kind === 'library') fail('remote-not-granted');
|
||||
let url;
|
||||
try { url = new URL(entry.remote); } catch { fail('remote-not-granted'); }
|
||||
if (url.origin !== origin || url.protocol !== 'https:' || url.username || url.password || url.search || url.hash ||
|
||||
!/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(url.pathname) || entry.remote !== url.origin + url.pathname) fail('remote-host-mismatch');
|
||||
const name = url.pathname.slice(1).replace(/\.git$/, '');
|
||||
if (name.split('/').some((p) => p === '.' || p === '..' || p === '')) fail('remote-not-granted');
|
||||
return { project, id: entry.githubId, name, remote: entry.remote };
|
||||
});
|
||||
if (new Set(repositories.map((r) => r.id)).size !== repositories.length) fail('remote-not-granted');
|
||||
return { meta, repositories, configSha256: hash({ definition, github: role.github, repositories }) };
|
||||
};
|
||||
const binding = (business, grant) => {
|
||||
if (!exact(grant, ['method', 'agent', 'rotateBy', 'target']) || grant.method !== 'github' ||
|
||||
!exact(grant.target, ['projects']) || !Array.isArray(grant.target.projects) || grant.target.projects.length < 1 ||
|
||||
grant.target.projects.length > 50 || grant.target.projects.some((p) => typeof p !== 'string' || !ID_PATTERN.test(p)) ||
|
||||
new Set(grant.target.projects).size !== grant.target.projects.length || !day(grant.rotateBy) || grant.rotateBy <= at().slice(0, 10)) fail('invalid-request');
|
||||
ctx().grantable(business, grant.agent);
|
||||
const snap = roleSnapshot(business, grant.agent);
|
||||
const repositories = grant.target.projects.map((project) => snap.repositories.find((r) => r.project === project) ?? fail('remote-not-granted'));
|
||||
const body = { ...structuredClone(grant), repositories, configSha256: snap.configSha256,
|
||||
adminSha256: hash(snap.meta), admin: identity(snap.meta), revocationSeq: state(business).revoked.get(grant.agent) ?? 0 };
|
||||
return { body, block: `\n\nGrant ${grant.agent} GitHub App ${snap.meta.appId}, installation ${snap.meta.installationId}, account ${snap.meta.accountId}.` +
|
||||
repositories.map((r) => `\nRepository ${r.project}: ${r.remote} (id ${r.id}).`).join('') +
|
||||
`\nPermissions: contents:write, metadata:read. Protected renewal until ${grant.rotateBy}.` +
|
||||
'\nNo workflow-file writes (.github/workflows/), account creation or fork creation. No GitHub publication consumer is installed by this grant.' };
|
||||
};
|
||||
const recheck = (business, g, epoch, controller) => {
|
||||
if (controller.signal.aborted || g.revocationSeq !== epoch || (state(business).revoked.get(g.agent) ?? 0) !== epoch) fail('revoked');
|
||||
if (g.rotateBy <= at().slice(0, 10)) fail('grant-expired');
|
||||
if (ctx().working && !ctx().working()) fail('processing-halted');
|
||||
ctx().grantable(business, g.agent);
|
||||
const snap = roleSnapshot(business, g.agent);
|
||||
if (snap.configSha256 !== g.configSha256 || hash(snap.meta) !== g.adminSha256 ||
|
||||
!sameIds(g.repositories, snap.repositories.filter((r) => g.target.projects.includes(r.project)))) fail('decision-mismatch');
|
||||
};
|
||||
const closeToken = async (business, intent, by) => {
|
||||
if (state(business).closed.has(intent.generation)) return true;
|
||||
const done = (reason) => {
|
||||
githubUnlink(generationDir(business, intent.agent), intent.generation);
|
||||
event(business, { action: 'grant.token.closed', agent: intent.agent, generation: intent.generation, reason, by });
|
||||
return true;
|
||||
};
|
||||
if (now() >= Date.parse(intent.until)) return done('expired');
|
||||
const token = readToken(business, intent);
|
||||
if (!token) return false; // Lost response: only bounded expiry can close it.
|
||||
try {
|
||||
const meta = api.adminMeta(business);
|
||||
if (!meta || hash(identity(meta)) !== hash(intent.admin)) return false;
|
||||
const audit = log(business, intent.grantDecision, intent.agent, intent.generation);
|
||||
await api.checkAdmin(business, audit);
|
||||
const result = await api.revoke(business, token, audit);
|
||||
if (result.status === 204) return done('revoked');
|
||||
// Do not interpret a 401/403 as proof of revocation, especially with clock drift.
|
||||
} catch {}
|
||||
return false;
|
||||
};
|
||||
const mint = async (business, decision, g, controller, epoch, initial) => {
|
||||
recheck(business, g, epoch, controller);
|
||||
const audit = log(business, decision, g.agent);
|
||||
await api.checkAdmin(business, audit);
|
||||
recheck(business, g, epoch, controller);
|
||||
if (initial) ctx().consume(business, decision);
|
||||
const generation = randomUUID(), started = now();
|
||||
const intent = { action: 'grant.step', step: 'mint', agent: g.agent, grantDecision: decision, generation,
|
||||
until: new Date(started + timeoutMs + TOKEN_LIFETIME_MS + CLOCK_SKEW_MS).toISOString(), admin: g.admin };
|
||||
event(business, intent);
|
||||
const callLog = log(business, decision, g.agent, generation);
|
||||
let candidate;
|
||||
try {
|
||||
const response = await api.mint(business, g.repositories.map((r) => r.id), callLog);
|
||||
if (response.status !== 201) {
|
||||
// An answered rejection cannot have minted a token. An unanswered call can.
|
||||
if ([401, 403, 404, 422].includes(response.status))
|
||||
event(business, { action: 'grant.token.closed', agent: g.agent, generation, reason: 'not-issued' });
|
||||
fail('github-failed');
|
||||
}
|
||||
const body = response.json;
|
||||
if (tokenText(body?.token)) {
|
||||
candidate = body.token;
|
||||
githubWrite(generationDir(business, g.agent, true), generation, candidate + '\n');
|
||||
}
|
||||
recheck(business, g, epoch, controller);
|
||||
const expires = typeof body?.expires_at === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(body.expires_at) ? Date.parse(body.expires_at) : NaN;
|
||||
if (!candidate || !object(body.permissions) || canonicalJson(body.permissions) !== canonicalJson(FIXED_PERMISSIONS) ||
|
||||
body.repository_selection !== 'selected' || !sameIds(body.repositories, g.repositories) ||
|
||||
!Number.isFinite(expires) || expires <= now() + TOKEN_MARGIN_MS || expires > started + TOKEN_LIFETIME_MS + CLOCK_SKEW_MS) fail('github-token-invalid');
|
||||
const seen = [];
|
||||
for (let page = 1; page <= 20; page++) {
|
||||
const response = await api.repos(business, candidate, page, callLog);
|
||||
recheck(business, g, epoch, controller);
|
||||
if (response.status !== 200 || !object(response.json) || response.json.total_count !== g.repositories.length ||
|
||||
!Array.isArray(response.json.repositories) || !response.json.repositories.length || response.json.repositories.length > PAGE_SIZE) fail('github-reach-unknown');
|
||||
seen.push(...response.json.repositories);
|
||||
if (seen.length >= response.json.total_count) break;
|
||||
if (response.json.repositories.length < PAGE_SIZE) fail('github-reach-unknown');
|
||||
}
|
||||
if (!sameIds(seen, g.repositories) || seen.some((r) => !g.repositories.some((b) => b.id === r.id && b.name === r.full_name))) fail('github-reach-wider');
|
||||
recheck(business, g, epoch, controller);
|
||||
if (now() >= expires - TOKEN_MARGIN_MS) fail('github-token-invalid');
|
||||
const old = state(business).grants.get(g.agent);
|
||||
event(business, { action: initial ? 'grant.applied' : 'grant.renewed', agent: g.agent, grantDecision: decision,
|
||||
generation, binding: g, expiresAt: new Date(expires).toISOString() });
|
||||
if (old) {
|
||||
const oldIntent = state(business).intents.get(old.generation);
|
||||
if (oldIntent) await closeToken(business, oldIntent, 'replacement');
|
||||
}
|
||||
recheck(business, g, epoch, controller);
|
||||
return { agent: g.agent, decision, generation, expiresAt: new Date(expires).toISOString() };
|
||||
} catch (e) {
|
||||
await closeToken(business, intent, 'failed');
|
||||
event(business, { action: 'grant.failed', agent: g.agent, grantDecision: decision, generation, code: refusal(e) });
|
||||
throw e instanceof BusError ? e : new BusError('github-failed');
|
||||
}
|
||||
};
|
||||
const status = (business) => {
|
||||
const s = state(business);
|
||||
const active = new Set([...s.grants.values()].map((g) => g.generation));
|
||||
return {
|
||||
admin: api.adminMeta(business),
|
||||
unclosedCalls: s.entries.filter((e) => e.action === 'grant.call' && !s.entries.some((r) => r.action === 'grant.result' && r.callId === e.callId))
|
||||
.map((e) => ({ callId: e.callId, op: e.op, generation: e.generation })),
|
||||
grants: [...s.grants.values()].map((g) => ({ agent: g.agent, decision: g.grantDecision,
|
||||
generation: g.generation, repositoryIds: g.binding.repositories.map((r) => r.id), projects: g.binding.target.projects,
|
||||
rotateBy: g.binding.rotateBy, expiresAt: g.expiresAt })),
|
||||
serviceLeft: [...s.intents.values()].filter((i) => !active.has(i.generation) && !s.closed.has(i.generation))
|
||||
.map((i) => ({ agent: i.agent, generation: i.generation, until: i.until })),
|
||||
running: [...claims.keys()].filter((k) => k.startsWith(business + '/')).map((k) => ({ agent: k.slice(business.length + 1) })),
|
||||
};
|
||||
};
|
||||
const manager = {
|
||||
configure(c) { if (context) fail('github-already-configured'); context = c; },
|
||||
raiseBinding: binding,
|
||||
status,
|
||||
decisionRevoked(business, g) { return g.revocationSeq !== (state(business).revoked.get(g.agent) ?? 0); },
|
||||
pending(business, agent) {
|
||||
const s = status(business);
|
||||
return s.grants.some((g) => g.agent === agent) || s.serviceLeft.some((g) => g.agent === agent) || s.running.some((g) => g.agent === agent);
|
||||
},
|
||||
run(business, decision) { return tracked(async () => {
|
||||
const g = ctx().approved(business, decision);
|
||||
if (g?.method !== 'github') fail('not-authorized');
|
||||
if (state(business).entries.some((e) => e.operation === 'grant.github' && e.decision === decision)) fail('decision-consumed');
|
||||
const controller = claim(business, g.agent), epoch = state(business).revoked.get(g.agent) ?? 0;
|
||||
try { return await mint(business, decision, g, controller, epoch, true); }
|
||||
catch (e) {
|
||||
// Preflight failures are unspent and retryable before restart, like row236.
|
||||
if (!state(business).entries.some((e) => e.operation === 'grant.github' && e.decision === decision))
|
||||
event(business, { action: 'grant.failed', agent: g.agent, grantDecision: decision, code: refusal(e), unspent: true });
|
||||
throw e;
|
||||
} finally { release(business, g.agent, controller); }
|
||||
}); },
|
||||
withToken(business, agent, fn) { return tracked(async () => {
|
||||
const controller = claim(business, agent), epoch = state(business).revoked.get(agent) ?? 0;
|
||||
try {
|
||||
let g = state(business).grants.get(agent);
|
||||
if (!g) fail('github-not-granted');
|
||||
if (ctx().approved(business, g.grantDecision)?.method !== 'github') fail('not-authorized');
|
||||
recheck(business, g.binding, epoch, controller);
|
||||
if (now() >= Date.parse(g.expiresAt) - TOKEN_MARGIN_MS) {
|
||||
await mint(business, g.grantDecision, g.binding, controller, epoch, false);
|
||||
if (controller.signal.aborted) fail('revoked');
|
||||
g = state(business).grants.get(agent);
|
||||
if (!g) fail('revoked');
|
||||
}
|
||||
const intent = state(business).intents.get(g.generation);
|
||||
const token = intent && readToken(business, intent);
|
||||
if (!token) fail('credential-file');
|
||||
recheck(business, g.binding, epoch, controller);
|
||||
if (now() >= Date.parse(g.expiresAt) - TOKEN_MARGIN_MS) fail('github-token-expired');
|
||||
const expiryTimer = setTimeout(() => controller.abort(), Math.max(0, Date.parse(g.expiresAt) - TOKEN_MARGIN_MS - now()));
|
||||
try { return await fn(token, controller.signal); } finally { clearTimeout(expiryTimer); }
|
||||
} finally { release(business, agent, controller); }
|
||||
}); },
|
||||
revoke(business, agent, reason, by) { return tracked(async () => {
|
||||
if (typeof agent !== 'string' || !ID_PATTERN.test(agent) || typeof reason !== 'string' || !reason.length || reason.length > 500) fail('invalid-request');
|
||||
claims.get(key(business, agent))?.abort();
|
||||
event(business, { action: 'grant.revoked', agent, reason, by });
|
||||
// The live claim owns cleanup of its in-flight candidate when it returns.
|
||||
const s = state(business);
|
||||
for (const intent of s.intents.values()) if (intent.agent === agent && !s.closed.has(intent.generation))
|
||||
await closeToken(business, intent, by);
|
||||
return { agent, serviceLeft: status(business).serviceLeft.filter((e) => e.agent === agent) };
|
||||
}); },
|
||||
sweep(business, by) { return tracked(async () => {
|
||||
const s = state(business), active = new Set([...s.grants.values()].map((g) => g.generation));
|
||||
for (const intent of s.intents.values())
|
||||
if (!s.closed.has(intent.generation) && (!active.has(intent.generation) || now() >= Date.parse(intent.until)) && !claims.has(key(business, intent.agent))) await closeToken(business, intent, by);
|
||||
event(business, { action: 'grant.swept', by, left: status(business).serviceLeft });
|
||||
return status(business);
|
||||
}); },
|
||||
reconcile(business) {
|
||||
// A generation with no applied/renewed record is never loaded or adopted.
|
||||
const s = state(business);
|
||||
for (const e of s.entries.filter((e) => e.operation === 'grant.github')) {
|
||||
if (s.entries.some((r) => r.grantDecision === e.decision && ['grant.applied', 'grant.failed'].includes(r.action) && r.seq > e.seq)) continue;
|
||||
const intent = [...s.intents.values()].findLast((i) => i.grantDecision === e.decision);
|
||||
event(business, { action: 'grant.failed', grantDecision: e.decision, agent: intent?.agent ?? null, code: 'interrupted' });
|
||||
}
|
||||
},
|
||||
async idle() { while (jobs.size) await Promise.allSettled([...jobs]); },
|
||||
async close() { for (const c of claims.values()) c.abort(); await manager.idle(); },
|
||||
api,
|
||||
audit: log,
|
||||
};
|
||||
return manager;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { Store } from './store.mjs';
|
||||
import * as projectModule from '../../project/src/index.mjs';
|
||||
import * as libraryModule from '../../library/src/index.mjs';
|
||||
import { Broker, BusError, LIBRARY_FETCH_FIX } from './broker.mjs';
|
||||
import { Broker, BusError } from './broker.mjs';
|
||||
import { Credentials } from './credentials.mjs';
|
||||
import { serve } from './server.mjs';
|
||||
import { launchSettings } from '../../business/src/launch-settings.mjs';
|
||||
@@ -24,7 +24,9 @@ import { busBusiness } from './business.mjs';
|
||||
import { webService } from './web.mjs';
|
||||
import { hostExecutor, sweepRunDir } from './host-run.mjs';
|
||||
import { grantKeys, hostGrant, hostReference, sweepHostKeys } from './host-grant.mjs';
|
||||
import { giteaGrant, giteaKeys, giteaStart, giteaReference } from './gitea-grant.mjs';
|
||||
import { giteaGrant, giteaKeys, giteaStart } from './gitea-grant.mjs';
|
||||
import { githubApi } from './github-api.mjs';
|
||||
import { githubGrant } from './github-grant.mjs';
|
||||
import { verifyPrincipal } from './principal.mjs';
|
||||
import { loadAccess } from '../../business/src/access.mjs';
|
||||
import { inspectInterpreter, recordInterpreterStart } from './interpreter.mjs';
|
||||
@@ -87,38 +89,6 @@ export async function harnessLevels({ log = (e) => process.stderr.write(`plugins
|
||||
}
|
||||
return (name) => levels.get(name) ?? null;
|
||||
}
|
||||
// Row 224 C2-05b: a host-only adapter. A failed fetch keeps serving immutable old
|
||||
// objects, as C2-03a requires; no caller may select a repo, commit or sync --accept.
|
||||
export function createLibraryLookup({ dataRoot, sync, now = () => Date.now() }) {
|
||||
const methods = { find: libraryModule.find, get: libraryModule.get, search: libraryModule.search,
|
||||
manuscript: libraryModule.manuscript, universeInfo: libraryModule.universeInfo };
|
||||
return async (context, { method, args, authorize }) => {
|
||||
if (!Object.hasOwn(methods, method)) throw new libraryModule.LibraryError('query-argument-invalid', 'unknown lookup');
|
||||
authorize();
|
||||
const source = { ...context, dataRoot };
|
||||
let state = await libraryModule.libraryStatus(source);
|
||||
authorize();
|
||||
// sage-2819 / dewey-0852: failed attempts also pace inline retries. Otherwise an
|
||||
// outage makes every lookup refetch while lastFetch remains old or null.
|
||||
const recentAttempt = Math.max(state.lastFetch === null ? -Infinity : Date.parse(state.lastFetch),
|
||||
state.lastError === null ? -Infinity : Date.parse(state.lastError.at));
|
||||
const age = now() - recentAttempt;
|
||||
if (age > libraryModule.SYNC_INTERVAL_MS || age < 0) {
|
||||
try { await sync(source); }
|
||||
catch (error) {
|
||||
// A timer may already be fetching this mirror. Its current served state is still safe.
|
||||
if (!(error instanceof libraryModule.LibraryError) || error.code !== 'library-sync-busy') throw error;
|
||||
}
|
||||
authorize();
|
||||
state = await libraryModule.libraryStatus(source);
|
||||
}
|
||||
if (!state.served) throw new libraryModule.LibraryError('library-commit', 'library has never been synced');
|
||||
authorize();
|
||||
return methods[method]({ repo: libraryModule.mirrorPath(source), commit: state.served,
|
||||
mapping: libraryModule.checkMapping(context.business.projects[context.project]) }, args);
|
||||
};
|
||||
}
|
||||
|
||||
export async function startBroker({
|
||||
dataRoot,
|
||||
businesses,
|
||||
@@ -154,7 +124,7 @@ export async function startBroker({
|
||||
|| (Object.hasOwn(librarySync, 'intervalMs') && !(Number.isSafeInteger(librarySync.intervalMs) && librarySync.intervalMs > 0))
|
||||
|| (Object.hasOwn(librarySync, 'configDir') && typeof librarySync.configDir !== 'string')))
|
||||
throw new BusError('invalid-host-request');
|
||||
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null, accessTimer = null;
|
||||
let store, credentials, server, adapter, processing, github = null, syncTimer = null, syncPass = null, accessTimer = null;
|
||||
try {
|
||||
const references = {};
|
||||
for (const [business, b] of Object.entries(businesses)) {
|
||||
@@ -255,26 +225,11 @@ export async function startBroker({
|
||||
}
|
||||
// C2-03a: the slot is <configDir>/secrets/<business>/library-gitea (design 10 step 4). Sync only
|
||||
// checks that it is there; the askpass helper reads it.
|
||||
// C2-03b: the broker chooses. The chief's grant store file once a chief has Gitea grant evidence,
|
||||
// the slot before that, or a refusal sync records. The answer carries no file to sync but this one.
|
||||
const slotDir = librarySync?.configDir ?? organization?.dir ?? null;
|
||||
const fetchCredential = business => {
|
||||
const choice = broker.libraryFetchCredential(business.id);
|
||||
if (choice.kind === 'chief')
|
||||
return { kind: 'chief', user: choice.user, file: storeRoot ? giteaReference(storeRoot, business.id, choice.agent).file : null };
|
||||
if (choice.kind === 'slot') return { kind: 'slot', file: slotDir ? join(slotDir, 'secrets', business.id, libraryModule.SLOT) : null };
|
||||
return { refused: choice.refused };
|
||||
};
|
||||
const syncOptions = business => ({
|
||||
credential: fetchCredential(business),
|
||||
slot: slotDir ? join(slotDir, 'secrets', business.id, libraryModule.SLOT) : null,
|
||||
...(librarySync ? { command: librarySync.command } : {}),
|
||||
});
|
||||
const humanWriteOptions = context => {
|
||||
const mapping = libraryModule.checkMapping(context.business.projects[context.project]);
|
||||
if (!mapping.editor) throw new libraryModule.LibraryError('library-mapping-invalid', 'editor is required');
|
||||
return { credential: libraryModule.humanCredential(context, { user: mapping.editor.user, configDir: slotDir }),
|
||||
...(librarySync ? { command: librarySync.command } : {}) };
|
||||
};
|
||||
// Row 194 (#1692, D3): after every write that commits, the broker takes the business as a restart
|
||||
// would load it. The credential references, the project roots the Credentials store protects
|
||||
// and the tracker bots are fixed at start (the Credentials store, the tasks adapter), so a file
|
||||
@@ -318,6 +273,7 @@ export async function startBroker({
|
||||
return false;
|
||||
}
|
||||
}
|
||||
github = organization ? githubGrant({ api: githubApi({ configDir: organization.dir }), configDir: organization.dir }) : null;
|
||||
broker = new Broker({
|
||||
// FED R3: the access file is read per user request; a refusal leaves the user no grant.
|
||||
access: access ? () => loadAccess(access.file, access.context) : null,
|
||||
@@ -343,25 +299,15 @@ export async function startBroker({
|
||||
// Row 236 (#1742): the Gitea grant path. The admin slot is <configDir>/secrets/grants/<business>/gitea.
|
||||
giteaGrant: organization ? giteaGrant({ configDir: organization.dir }) : null,
|
||||
giteaKeys: storeRoot ? giteaKeys({ credentials, storeRoot }) : null,
|
||||
githubGrant: github,
|
||||
usage,
|
||||
limits,
|
||||
store,
|
||||
businesses,
|
||||
library: {
|
||||
log: entry => process.stderr.write(`library: ${JSON.stringify(entry)}\n`),
|
||||
lookup: createLibraryLookup({ dataRoot, sync: context =>
|
||||
processing.work(() => libraryModule.sync(context, syncOptions(context.business))) }),
|
||||
readFile: context => libraryModule.readLibraryFile({ ...context, dataRoot }),
|
||||
status: context => libraryModule.libraryStatus({ ...context, dataRoot }),
|
||||
sync: (context, { accept }) => processing.work(() => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept })),
|
||||
// C2-02: trusted references only. The askpass helper alone opens a credential file.
|
||||
writeDoc: (context, options) => processing.work(() => libraryModule.writeLibraryDoc({ ...context, dataRoot }, {
|
||||
...options, credential: { ...options.credential,
|
||||
file: storeRoot ? giteaReference(storeRoot, context.business.id, options.credential.agent).file : null },
|
||||
...(librarySync ? { command: librarySync.command } : {}),
|
||||
})),
|
||||
saveFile: context => processing.work(() => libraryModule.saveLibraryFile({ ...context, dataRoot }, humanWriteOptions(context))),
|
||||
revert: context => processing.work(() => libraryModule.revertLibraryCommit({ ...context, dataRoot }, humanWriteOptions(context))),
|
||||
LibraryError: libraryModule.LibraryError,
|
||||
},
|
||||
project: {
|
||||
@@ -517,6 +463,7 @@ export async function startBroker({
|
||||
broker.reconcileHosts();
|
||||
// Row 236 (#436b r5 section 5): the Gitea grants' remote undo, settle and open revokes, likewise.
|
||||
broker.reconcileGitea();
|
||||
broker.reconcileGithub().catch(() => {});
|
||||
// S6 launcher operations. The host decides capacity and the instance list; the broker
|
||||
// authorizes role.launch and keeps the evidence, refusals included.
|
||||
// A refusal is recorded against the caller, as request() records one.
|
||||
@@ -623,8 +570,7 @@ export async function startBroker({
|
||||
for (const { business, project } of timed) {
|
||||
try {
|
||||
const { code } = await libraryModule.sync({ business, project, dataRoot }, syncOptions(business));
|
||||
if (code !== null) syncLog({ event: 'library-sync-held', business: business.id, project, code,
|
||||
...(code === 'library-fetch-no-grant' ? { fix: LIBRARY_FETCH_FIX } : {}) });
|
||||
if (code !== null) syncLog({ event: 'library-sync-held', business: business.id, project, code });
|
||||
} catch (e) {
|
||||
if (e?.code === 'library-sync-busy') continue;
|
||||
syncLog({ event: 'library-sync-refused', business: business.id, project,
|
||||
@@ -708,6 +654,7 @@ export async function startBroker({
|
||||
await stopSync();
|
||||
await server.close();
|
||||
await adapter?.close?.();
|
||||
await github?.close();
|
||||
await processing?.close();
|
||||
store.close();
|
||||
credentials.close();
|
||||
@@ -717,6 +664,7 @@ export async function startBroker({
|
||||
clearInterval(accessTimer);
|
||||
await server?.close();
|
||||
await adapter?.close?.();
|
||||
await github?.close();
|
||||
await processing?.close();
|
||||
store?.close();
|
||||
credentials?.close();
|
||||
|
||||
@@ -6,9 +6,7 @@ import { createPeerReader } from './peercred.mjs';
|
||||
const LIMIT = 65536;
|
||||
// Row 148: a document save may carry up to 1 MiB of text. Only a request that names that verb near
|
||||
// its start (the proof object comes first) may pass LIMIT; the parsed verb is checked again below.
|
||||
const PROJECT_SAVE_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.', '\\.')}"`);
|
||||
const LIBRARY_SAVE_AT_START = /"verb":"library\.file\.save"/;
|
||||
const SAVE_VERB_AT_START = { test: text => PROJECT_SAVE_AT_START.test(text) || LIBRARY_SAVE_AT_START.test(text) };
|
||||
const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.', '\\.')}"`);
|
||||
// One request per connection. There is deliberately no reconnect/retry or SQL verb.
|
||||
// `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it.
|
||||
// `web` is the trusted web service (web.mjs), or a function from a business id to one (or null); only
|
||||
@@ -62,7 +60,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
try {
|
||||
if (end !== input.length - 1) throw new BusError('invalid-envelope');
|
||||
const r = JSON.parse(input.subarray(0, end).toString('utf8'));
|
||||
if (input.length > LIMIT && ![PROJECT_SAVE_VERB, 'library.file.save'].includes(r?.verb)) throw new BusError('request-too-large');
|
||||
if (input.length > LIMIT && r?.verb !== PROJECT_SAVE_VERB) throw new BusError('request-too-large');
|
||||
if (
|
||||
!r ||
|
||||
Array.isArray(r) ||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { businessDoc, FAR_DATE, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
||||
|
||||
// Row 213 (C2-08, #1714, Case 2 design 5.1): a creative instance talks with the user only, and it has
|
||||
// no decision channel (M6). The messaging lists are read from the shipped role files.
|
||||
@@ -98,7 +98,7 @@ async function runtime(t) {
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = businessDoc(root, 'demo');
|
||||
doc.plugins = { ...doc.plugins, harness: ['pi'] };
|
||||
doc.roles.bean = { definition: 'companion', credentials: { gitea: { store: true, rotateBy: FAR_DATE } } };
|
||||
doc.roles.bean = { definition: 'companion' };
|
||||
for (const r of Object.values(doc.roles)) r.vars = { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high', ...r.vars };
|
||||
// Only bean names the sonnet key, so the chief may still change every other key.
|
||||
for (const [name, r] of Object.entries(doc.roles)) if (name !== 'bean') r.vars.model = 'claude-opus-5-5';
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Row297: fake GitHub over HTTPS, scratch CA only. No live credentials or provider calls.
|
||||
import { generateKeyPairSync, randomBytes, verify } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createServer } from 'node:https';
|
||||
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
|
||||
|
||||
export async function fakeGithubApi(t, dir, { now = () => Date.now() } = {}) {
|
||||
const tls = scratchCa(dir);
|
||||
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const key = privateKey.export({ type: 'pkcs8', format: 'pem' });
|
||||
const tokens = new Map();
|
||||
const requests = [];
|
||||
const state = { appId: 10, installationId: 20, accountId: 30, suspended: false,
|
||||
permissions: { contents: 'write', metadata: 'read' }, repositories: [{ id: 101, full_name: 'acme/app' }] };
|
||||
let intercept = null;
|
||||
const server = createServer({ key: tls.key, cert: tls.cert }, async (req, res) => {
|
||||
const url = new URL(req.url, 'https://fake.test');
|
||||
let body = '';
|
||||
for await (const part of req) body += part;
|
||||
let json;
|
||||
try { json = body ? JSON.parse(body) : null; } catch { res.writeHead(400).end(); return; }
|
||||
const auth = (req.headers.authorization ?? '').replace(/^Bearer /, '');
|
||||
const bits = auth.split('.');
|
||||
let jwt = null;
|
||||
if (bits.length === 3) {
|
||||
try {
|
||||
if (verify('RSA-SHA256', Buffer.from(bits.slice(0, 2).join('.')), publicKey, Buffer.from(bits[2], 'base64url')))
|
||||
jwt = JSON.parse(Buffer.from(bits[1], 'base64url'));
|
||||
} catch {}
|
||||
}
|
||||
const token = tokens.get(auth);
|
||||
const entry = { method: req.method, path: url.pathname, query: url.search, body: json,
|
||||
jwt: jwt ? { ...jwt } : null, auth: jwt ? 'app' : token ? 'installation' : 'none' };
|
||||
requests.push(entry);
|
||||
const answer = (status, value) => {
|
||||
res.writeHead(status, { 'content-type': 'application/json' });
|
||||
res.end(value === undefined ? '' : JSON.stringify(value));
|
||||
};
|
||||
if (intercept && await intercept(entry, answer, { req, res, tokens })) return;
|
||||
if (url.pathname === '/installation/token' && req.method === 'DELETE' && token) {
|
||||
tokens.delete(auth); answer(204); return;
|
||||
}
|
||||
if (url.pathname === '/installation/repositories' && token && Date.parse(token.expires_at) > now()) {
|
||||
const page = Number(url.searchParams.get('page') ?? 1);
|
||||
const per = Number(url.searchParams.get('per_page') ?? 100);
|
||||
answer(200, { total_count: token.repositories.length, repositories: token.repositories.slice((page - 1) * per, page * per) }); return;
|
||||
}
|
||||
const secs = Math.floor(now() / 1000);
|
||||
if (!jwt || String(jwt.iss) !== String(state.appId) || jwt.iat > secs || jwt.exp <= secs || jwt.exp > secs + 600) {
|
||||
answer(401, {}); return;
|
||||
}
|
||||
if (url.pathname === '/app') { answer(200, { id: state.appId }); return; }
|
||||
if (url.pathname === `/app/installations/${state.installationId}` && req.method === 'GET') {
|
||||
answer(200, { id: state.installationId, app_id: state.appId, account: { id: state.accountId },
|
||||
suspended_at: state.suspended ? '2026-10-23T00:00:00Z' : null, permissions: state.permissions }); return;
|
||||
}
|
||||
if (url.pathname === `/app/installations/${state.installationId}/access_tokens` && req.method === 'POST') {
|
||||
const value = 'ghs_fake_' + randomBytes(24).toString('hex');
|
||||
const result = { token: value, expires_at: new Date(now() + 3600000).toISOString(),
|
||||
permissions: json.permissions, repository_selection: 'selected',
|
||||
repositories: state.repositories.filter((r) => json.repository_ids?.includes(r.id)) };
|
||||
tokens.set(value, structuredClone(result)); answer(201, result); return;
|
||||
}
|
||||
answer(404, {});
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(async () => { server.closeAllConnections(); await new Promise((resolve) => server.close(resolve)); });
|
||||
return { url: `https://127.0.0.1:${server.address().port}`, ca: readFileSync(tls.ca, 'utf8'),
|
||||
key, tokens, requests, state, intercept: (fn) => { intercept = fn; } };
|
||||
}
|
||||
@@ -256,22 +256,6 @@ test('X-G1: a creative role, a role without identity and a missing or due slot r
|
||||
for (const f of [creative, bare, noSlot, due, elsewhere]) assert.equal(f.gitea.requests.length, 0);
|
||||
});
|
||||
|
||||
// Row 285 (#1790, sage-2921): the chief's definition declares gitea for a read grant only, and this path
|
||||
// mints SEAT_SCOPES, so a chief with identity and publish still refuses at raise and stores nothing.
|
||||
test('X-G1: the chief of staff refuses at raise, whatever its definition, identity and publish declare', async (t) => {
|
||||
const f = await setup(t, {
|
||||
business: (b) => {
|
||||
b.definitions['chief-of-staff'].credentials = [{ service: 'gitea' }];
|
||||
b.roles.chief.identity = { name: 'Chief', email: '[email protected]', gitea: { url: b.roles.coder.identity.gitea.url, user: 'demo-chief' } };
|
||||
b.roles.chief.publish = ['app'];
|
||||
},
|
||||
});
|
||||
const n = f.store.get('SELECT count(*) AS n FROM decisions').n;
|
||||
await rejects(f.raise({ agent: 'chief' }), 'gitea-not-declared');
|
||||
assert.equal(f.store.get('SELECT count(*) AS n FROM decisions').n, n);
|
||||
assert.equal(f.gitea.requests.length, 0);
|
||||
});
|
||||
|
||||
test('X-G2: admin set refuses each failed check and writes nothing, then writes the slot once', async (t) => {
|
||||
const f = await setup(t, { slot: false });
|
||||
const set = (token, over = {}) =>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { githubApi, githubMeta, githubJwt, FIXED_PERMISSIONS } from '../src/github-api.mjs';
|
||||
import { fakeGithubApi } from './fake-github-api.mjs';
|
||||
|
||||
const at = Date.parse('2026-10-23T12:00:00Z');
|
||||
const refuses = (fn, code) => assert.throws(fn, (e) => e.code === code);
|
||||
async function fixture(t) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'github297-api-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const fake = await fakeGithubApi(t, join(dir, 'tls'), { now: () => at });
|
||||
const cfg = join(dir, 'config'); mkdirSync(cfg, { mode: 0o700 });
|
||||
const api = githubApi({ configDir: cfg, ca: fake.ca, now: () => at });
|
||||
const meta = { url: fake.url, appId: 10, installationId: 20, accountId: 30,
|
||||
rotateBy: '2026-11-23', setAt: new Date(at).toISOString() };
|
||||
const calls = [];
|
||||
const log = async (op, fn) => { calls.push(['call', op]); const r = await fn(); calls.push(['result', r.status]); return r; };
|
||||
return { fake, api, meta, cfg, calls, log };
|
||||
}
|
||||
|
||||
test('R2 admin metadata refuses HTTP without a loopback exception', () => {
|
||||
refuses(() => githubMeta({ url: 'http://127.0.0.1:1234', appId: 1, installationId: 2, accountId: 3,
|
||||
rotateBy: '2026-11-23', setAt: new Date(at).toISOString() }), 'invalid-request');
|
||||
});
|
||||
|
||||
test('R4 JWT uses the controlled clock, RS256, 60s backdate and bounded expiry', () => {
|
||||
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const encoded = githubJwt(privateKey, 10, at);
|
||||
const [header, body] = encoded.split('.').slice(0, 2).map((s) => JSON.parse(Buffer.from(s, 'base64url')));
|
||||
assert.equal(header.alg, 'RS256');
|
||||
assert.equal(body.iat, at / 1000 - 60);
|
||||
assert.equal(body.exp, at / 1000 + 540);
|
||||
assert.equal(body.iss, '10');
|
||||
});
|
||||
|
||||
test('admin set checks the fake HTTPS identity and stores private files only after checks', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.api.setAdmin('demo', f.meta, f.fake.key, f.log);
|
||||
assert.deepEqual(f.api.adminMeta('demo'), f.meta);
|
||||
assert.equal(statSync(join(f.cfg, 'secrets/grants/demo/github/admin')).mode & 0o777, 0o600);
|
||||
assert.equal(f.calls.length, 4);
|
||||
assert.deepEqual(f.fake.requests.map((r) => r.path), ['/app', '/app/installations/20']);
|
||||
assert.equal(JSON.stringify(f.calls).includes('PRIVATE KEY'), false);
|
||||
});
|
||||
|
||||
for (const [name, mutate, code] of [
|
||||
['App identity', (f) => { f.meta.appId = 11; }, 'grant-admin-account'],
|
||||
['installation account identity', (f) => { f.meta.accountId = 31; }, 'grant-admin-account'],
|
||||
['suspended installation', (f) => { f.fake.state.suspended = true; }, 'grant-admin-account'],
|
||||
['permission floor', (f) => { f.fake.state.permissions.contents = 'read'; }, 'grant-admin-scope'],
|
||||
]) test(`C1-C4 replacement: admin set refuses ${name} and writes no slot`, async (t) => {
|
||||
const f = await fixture(t); mutate(f);
|
||||
await assert.rejects(f.api.setAdmin('demo', f.meta, f.fake.key, f.log), (e) => e.code === code);
|
||||
assert.equal(existsSync(join(f.cfg, 'secrets/grants/demo/github/admin')), false);
|
||||
});
|
||||
|
||||
test('admin set refuses a non-RSA key without making an API call', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const { privateKey } = generateKeyPairSync('ed25519');
|
||||
await assert.rejects(f.api.setAdmin('demo', f.meta, privateKey.export({ type: 'pkcs8', format: 'pem' }), f.log), (e) => e.code === 'credential-format');
|
||||
assert.equal(f.fake.requests.length, 0);
|
||||
});
|
||||
|
||||
test('admin set refuses HTTP before any call, and refuses to overwrite a slot', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await assert.rejects(f.api.setAdmin('demo', { ...f.meta, url: f.fake.url.replace('https:', 'http:') }, f.fake.key, f.log), (e) => e.code === 'invalid-request');
|
||||
assert.equal(f.fake.requests.length, 0);
|
||||
await f.api.setAdmin('demo', f.meta, f.fake.key, f.log);
|
||||
await assert.rejects(f.api.setAdmin('demo', f.meta, f.fake.key, f.log), (e) => e.code === 'grant-admin-exists');
|
||||
});
|
||||
|
||||
test('mint sends fixed permissions and explicit repository IDs using an App JWT', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.api.setAdmin('demo', f.meta, f.fake.key, f.log);
|
||||
const r = await f.api.mint('demo', [101], f.log);
|
||||
assert.equal(r.status, 201);
|
||||
const req = f.fake.requests.at(-1);
|
||||
assert.deepEqual(req.body, { repository_ids: [101], permissions: FIXED_PERMISSIONS });
|
||||
assert.equal(req.auth, 'app');
|
||||
assert.equal(f.fake.requests.every((x) => !JSON.stringify(x).includes('PRIVATE KEY')), true);
|
||||
});
|
||||
|
||||
test('C2 validates the App response ID even with a successful JWT authentication', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.fake.intercept(async (r, answer) => {
|
||||
if (r.path === '/app') { answer(200, { id: 11 }); return true; }
|
||||
return false;
|
||||
});
|
||||
await assert.rejects(f.api.setAdmin('demo', f.meta, f.fake.key, f.log), (e) => e.code === 'grant-admin-account');
|
||||
});
|
||||
|
||||
test('admin slot refuses unsafe file modes and symbolic links', async (t) => {
|
||||
const f = await fixture(t); await f.api.setAdmin('demo', f.meta, f.fake.key, f.log);
|
||||
const metaFile = join(f.cfg, 'secrets/grants/demo/github/admin.json');
|
||||
chmodSync(metaFile, 0o644);
|
||||
assert.throws(() => f.api.adminMeta('demo'), (e) => e.code === 'credential-file');
|
||||
chmodSync(metaFile, 0o600);
|
||||
const keyFile = join(f.cfg, 'secrets/grants/demo/github/admin');
|
||||
rmSync(keyFile); symlinkSync(metaFile, keyFile);
|
||||
await assert.rejects(f.api.checkAdmin('demo', f.log), (e) => e.code === 'credential-file');
|
||||
});
|
||||
|
||||
for (const method of ['repos', 'revoke']) test(`N2 ${method} refuses a missing installation token before any call`, async (t) => {
|
||||
const f = await fixture(t); await f.api.setAdmin('demo', f.meta, f.fake.key, f.log);
|
||||
const before = f.fake.requests.length;
|
||||
const run = () => method === 'repos' ? f.api.repos('demo', undefined, 1, f.log) : f.api.revoke('demo', undefined, f.log);
|
||||
await assert.rejects(Promise.resolve().then(run), (e) => e.code === 'credential-format');
|
||||
assert.equal(f.fake.requests.length, before);
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker, GRANT_EVIDENCE } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { githubApi } from '../src/github-api.mjs';
|
||||
import { githubGrant } from '../src/github-grant.mjs';
|
||||
import { fakeGithubApi } from './fake-github-api.mjs';
|
||||
|
||||
async function fixture(t) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'github297-broker-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const fake = await fakeGithubApi(t, join(dir, 'tls'));
|
||||
const configDir = join(dir, 'cfg'); mkdirSync(configDir, { mode: 0o700 });
|
||||
mkdirSync(join(dir, 'data'), { mode: 0o700 });
|
||||
const store = new Store(join(dir, 'data')); t.after(() => store.close());
|
||||
const api = githubApi({ configDir, ca: fake.ca });
|
||||
const manager = githubGrant({ api, configDir });
|
||||
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send', 'role.fire'], crossRole: [] } }, pm: { definition: 'pm', ...none },
|
||||
coder: { definition: 'coder', github: { projects: ['app'] }, ...none } },
|
||||
definitions: { 'chief-of-staff': { roleVersion: 2, credentials: [] }, pm: { roleVersion: 2, credentials: [] },
|
||||
coder: { roleVersion: 2, credentials: [{ service: 'github' }] } },
|
||||
projects: { app: { remote: fake.url + '/acme/app', githubId: 101 } }, launch: { by: 'chief', instances: ['pm', 'coder'] }, vars: { 'bus.tester': true } };
|
||||
const writes = [];
|
||||
let broker;
|
||||
const organization = (id, op, args) => {
|
||||
writes.push(op);
|
||||
if (op === 'instance.fire') business.launch.instances = business.launch.instances.filter((n) => n !== args.name);
|
||||
if (op === 'instances.set' && args.op === 'delete') {
|
||||
delete business.roles[args.instance];
|
||||
business.launch.instances = business.launch.instances.filter((n) => n !== args.instance);
|
||||
}
|
||||
broker.reloadBusiness('demo', business);
|
||||
return { revision: 'b'.repeat(64), transaction: 'fake-transaction' };
|
||||
};
|
||||
broker = new Broker({ store, businesses: { demo: business }, githubGrant: manager, organization });
|
||||
const caps = { human: broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
|
||||
tester: broker.bindTester({ business: 'demo' }) };
|
||||
for (const role of ['chief', 'pm']) {
|
||||
caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
|
||||
await broker.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
const call = (who, verb, args) => broker.request(caps[who], { verb, args });
|
||||
const admin = { service: 'github', url: fake.url, appId: 10, installationId: 20, accountId: 30,
|
||||
rotateBy: '2099-01-01', token: fake.key };
|
||||
await call('human', 'grants.admin.set', admin);
|
||||
const raise = () => call('chief', 'decision.raise', { action: 'credential.mint', question: 'Grant GitHub?',
|
||||
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
|
||||
grant: { method: 'github', agent: 'coder', target: { projects: ['app'] }, rotateBy: '2098-01-01' } });
|
||||
const idle = async () => { await new Promise((resolve) => setImmediate(resolve)); await broker.grantsIdle(); };
|
||||
const events = () => store.all(`SELECT body FROM events WHERE business='demo' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((e) => JSON.parse(e.body));
|
||||
return { fake, store, broker, manager, business, call, raise, idle, events, admin, writes };
|
||||
}
|
||||
|
||||
test('chief raise and human Approve start GitHub without a Gitea admin or host executor', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const d = await f.raise();
|
||||
assert.equal(f.fake.tokens.size, 0);
|
||||
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
|
||||
const status = await f.call('chief', 'grants.status', { service: 'github' });
|
||||
assert.equal(status.grants.length, 1);
|
||||
assert.equal(status.grants[0].decision, d.id);
|
||||
await f.call('human', 'grants.revoke', { service: 'github', agent: 'coder', reason: 'test' });
|
||||
assert.equal(f.fake.tokens.size, 0);
|
||||
assert.equal((await f.call('human', 'grants.status', { service: 'github' })).grants.length, 0);
|
||||
});
|
||||
|
||||
test('GitHub admin and manual run are human-only; arbitrary agents cannot raise', async (t) => {
|
||||
const f = await fixture(t);
|
||||
for (const who of ['chief', 'tester']) {
|
||||
await assert.rejects(Promise.resolve().then(() => f.call(who, 'grants.admin.remove', { service: 'github' })), (e) => e.code === 'human-required');
|
||||
await assert.rejects(Promise.resolve().then(() => f.call(who, 'grants.run', { decision: 'unapproved' })), (e) => e.code === 'human-required');
|
||||
}
|
||||
await assert.rejects(Promise.resolve().then(() => f.call('pm', 'decision.raise', { action: 'credential.mint',
|
||||
question: 'no', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
|
||||
grant: { method: 'github', agent: 'coder', target: { projects: ['app'] }, rotateBy: '2098-01-01' } })), (e) => e.code === 'grant-chief-only');
|
||||
});
|
||||
|
||||
test('changing repository config after raise refuses before consume, then restart spends interrupted', async (t) => {
|
||||
const f = await fixture(t); const d = await f.raise();
|
||||
f.business.projects.app.githubId = 102; f.broker.reloadBusiness('demo', f.business);
|
||||
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
|
||||
assert.equal(f.fake.tokens.size, 0);
|
||||
assert.equal(f.events().some((e) => e.operation === 'grant.github'), false);
|
||||
f.broker.reconcileGrants();
|
||||
assert.equal(f.events().filter((e) => e.operation === 'grant.github' && e.decision === d.id).length, 1);
|
||||
});
|
||||
|
||||
test('no socket verb exposes a token or protected renewal', async (t) => {
|
||||
const f = await fixture(t);
|
||||
for (const verb of ['grants.token', 'grants.renew', 'github.token'])
|
||||
await assert.rejects(Promise.resolve().then(() => f.call('human', verb, { agent: 'coder' })), (e) => e.code === 'unknown-verb');
|
||||
});
|
||||
|
||||
test('an open approval revoked before Yes cannot be used by the late automatic executor', async (t) => {
|
||||
const f = await fixture(t); const d = await f.raise();
|
||||
await f.call('human', 'grants.revoke', { service: 'github', agent: 'coder', reason: 'cancel' });
|
||||
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
|
||||
assert.equal(f.fake.tokens.size, 0);
|
||||
assert.equal(f.manager.status('demo').grants.length, 0);
|
||||
});
|
||||
|
||||
test('grant evidence remains reserved from an agent or direct human record insert', async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.throws(() => f.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('forged','2026-10-23T00:00:00Z','demo','action.allowed',NULL,NULL,NULL,?)",
|
||||
JSON.stringify({ action: 'grant.applied', method: 'github', agent: 'coder' })));
|
||||
assert.equal(f.manager.status('demo').grants.length, 0);
|
||||
});
|
||||
|
||||
for (const failDelete of [false, true]) test(`fire revokes GitHub locally and ${failDelete ? 'stops on remote leftovers' : 'completes only after remote cleanup'}`, async (t) => {
|
||||
const f = await fixture(t); const d = await f.raise();
|
||||
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
|
||||
if (failDelete) f.fake.intercept(async (r, answer) => { if (r.method === 'DELETE') { answer(503, {}); return true; } return false; });
|
||||
await f.call('chief', 'instance.fire', { name: 'coder', reason: 'test' }); await f.idle();
|
||||
assert.equal(f.manager.status('demo').grants.length, 0);
|
||||
assert.equal(f.writes.includes('instance.fire'), !failDelete);
|
||||
assert.equal(f.manager.status('demo').serviceLeft.length, failDelete ? 1 : 0);
|
||||
});
|
||||
|
||||
test('lifecycle delete waits for the protected GitHub revoke job', async (t) => {
|
||||
const f = await fixture(t); const d = await f.raise();
|
||||
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
|
||||
const { request } = await f.call('human', 'instances.set', { revision: 'b'.repeat(64), instance: 'coder', op: 'delete' });
|
||||
assert.equal(f.broker.lifecycleTake({ business: 'demo', request }).go, true);
|
||||
assert.throws(() => f.broker.lifecycleDelete({ business: 'demo', request }), (e) => e.code === 'grants-revoking');
|
||||
f.broker.lifecycleDone({ business: 'demo', request, refusal: 'grants-revoking' });
|
||||
await f.idle();
|
||||
assert.equal(f.manager.status('demo').grants.length, 0);
|
||||
assert.equal(f.fake.tokens.size, 0);
|
||||
assert.equal(Object.hasOwn(f.business.roles, 'coder'), true);
|
||||
const second = await f.call('human', 'instances.set', { revision: 'b'.repeat(64), instance: 'coder', op: 'delete' });
|
||||
assert.equal(f.broker.lifecycleTake({ business: 'demo', request: second.request }).go, true);
|
||||
const removed = f.broker.lifecycleDelete({ business: 'demo', request: second.request });
|
||||
assert.equal(removed.retry, false);
|
||||
assert.equal(removed.transaction, 'fake-transaction');
|
||||
assert.equal(Object.hasOwn(f.business.roles, 'coder'), false);
|
||||
assert.equal(f.writes.filter((op) => op === 'instances.set').length, 1);
|
||||
});
|
||||
|
||||
test('admin rotate preserves installation identity and invalidates open approved bindings', async (t) => {
|
||||
const f = await fixture(t); const d = await f.raise();
|
||||
await f.call('human', 'grants.admin.rotate', { service: 'github', rotateBy: '2099-02-01', token: f.fake.key });
|
||||
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); await f.idle();
|
||||
assert.equal(f.fake.tokens.size, 0);
|
||||
assert.equal(f.manager.status('demo').admin.installationId, 20);
|
||||
assert.equal(f.manager.status('demo').grants.length, 0);
|
||||
});
|
||||
|
||||
for (const args of [{}, { decision: null }, { decision: 'bad/id' }, { decision: 'valid', extra: true }])
|
||||
test('N7 manual run validates shape and identifier before approval lookup: ' + JSON.stringify(args), async (t) => {
|
||||
const f = await fixture(t);
|
||||
await assert.rejects(Promise.resolve().then(() => f.call('human', 'grants.run', args)), (e) => e.code === 'invalid-request');
|
||||
assert.equal(f.fake.tokens.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,412 @@
|
||||
// Row297 contract tests. Run only under Sage CLEAR; fake HTTPS API, no live token.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { GRANT_EVIDENCE, writeGrantEvent } from '../src/broker.mjs';
|
||||
import { githubApi, FIXED_PERMISSIONS } from '../src/github-api.mjs';
|
||||
import { githubGrant, TOKEN_LIFETIME_MS, CLOCK_SKEW_MS, TOKEN_MARGIN_MS, CALL_TIMEOUT_MS } from '../src/github-grant.mjs';
|
||||
import { fakeGithubApi } from './fake-github-api.mjs';
|
||||
import { githubDirectory, githubWrite } from '../src/github-files.mjs';
|
||||
|
||||
const START = Date.parse('2026-10-23T12:00:00Z');
|
||||
const rejectCode = (p, code) => assert.rejects(p, (e) => e.code === code);
|
||||
async function fixture(t, options = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'github297-grant-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
let time = START;
|
||||
const now = () => time;
|
||||
const fake = await fakeGithubApi(t, join(dir, 'tls'), { now });
|
||||
const configDir = join(dir, 'config'); mkdirSync(configDir, { mode: 0o700 });
|
||||
mkdirSync(join(dir, 'data'), { mode: 0o700 });
|
||||
const store = new Store(join(dir, 'data'));
|
||||
t.after(() => store.close());
|
||||
const business = { id: 'demo', roles: { coder: { definition: 'coder', github: { projects: ['app'] } } },
|
||||
definitions: { coder: { roleVersion: 2, credentials: [{ service: 'github' }] } },
|
||||
projects: { app: { remote: fake.url + '/acme/app', githubId: 101 } } };
|
||||
const approved = new Map();
|
||||
const event = (id, body) => store.transaction(() => writeGrantEvent(store, new Date(now()).toISOString(), id, body, body.agent ?? null));
|
||||
const api = githubApi({ configDir, ca: fake.ca, now, timeoutMs: options.timeoutMs ?? CALL_TIMEOUT_MS });
|
||||
const meta = { url: fake.url, appId: 10, installationId: 20, accountId: 30,
|
||||
rotateBy: '2026-11-23', setAt: new Date(now()).toISOString() };
|
||||
const log = async (op, fn) => fn(); // Initial admin fixture; executor calls use reserved event writer.
|
||||
await api.setAdmin('demo', meta, fake.key, log);
|
||||
const spent = new Set();
|
||||
let working = true;
|
||||
const create = () => {
|
||||
const manager = githubGrant({ api, configDir, now, timeoutMs: options.timeoutMs ?? CALL_TIMEOUT_MS });
|
||||
manager.configure({ store, business: () => business, event,
|
||||
approved: (id, decision) => approved.get(decision) ?? null,
|
||||
consume: (id, decision) => {
|
||||
if (spent.has(decision)) throw Object.assign(new Error('decision-consumed'), { code: 'decision-consumed' });
|
||||
spent.add(decision); event(id, { operation: 'grant.github', decision }); return approved.get(decision);
|
||||
}, grantable: () => {}, working: () => working });
|
||||
return manager;
|
||||
};
|
||||
const manager = create();
|
||||
const grant = { method: 'github', agent: 'coder', target: { projects: ['app'] }, rotateBy: '2026-11-20' };
|
||||
const raise = () => {
|
||||
const decision = randomUUID();
|
||||
approved.set(decision, manager.raiseBinding('demo', grant).body);
|
||||
return decision;
|
||||
};
|
||||
const events = () => store.all(`SELECT body FROM events WHERE business='demo' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((r) => JSON.parse(r.body));
|
||||
const apply = async () => { const decision = raise(); await manager.run('demo', decision); return decision; };
|
||||
return { dir, configDir, store, business, fake, api, manager, meta, grant, now, raise, apply,
|
||||
events, approved, spent, create, advance: (ms) => { time += ms; }, working: (v) => { working = v; } };
|
||||
}
|
||||
|
||||
test('R4 lifetime, skew, margin and request bounds are explicit constants', () => {
|
||||
assert.equal(TOKEN_LIFETIME_MS, 3600000);
|
||||
assert.equal(CLOCK_SKEW_MS, 60000);
|
||||
assert.equal(TOKEN_MARGIN_MS, 60000);
|
||||
assert.equal(CALL_TIMEOUT_MS, 15000);
|
||||
});
|
||||
|
||||
test('grant binding cannot name scopes, accounts or fork creation', async (t) => {
|
||||
const f = await fixture(t);
|
||||
for (const extra of [{ scopes: ['all'] }, { account: 'foreign' }, { fork: true }])
|
||||
assert.throws(() => f.manager.raiseBinding('demo', { ...f.grant, ...extra }), (e) => e.code === 'invalid-request');
|
||||
assert.equal(f.events().length, 0);
|
||||
});
|
||||
|
||||
test('initial approval creates an exact durable grant and guarded token use', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const decision = await f.apply();
|
||||
const [g] = f.manager.status('demo').grants;
|
||||
assert.equal(g.agent, 'coder');
|
||||
assert.equal(g.decision, decision);
|
||||
assert.deepEqual(g.repositoryIds, [101]);
|
||||
assert.equal(g.expiresAt, new Date(START + TOKEN_LIFETIME_MS).toISOString());
|
||||
let used = false;
|
||||
await f.manager.withToken('demo', 'coder', async (token, signal) => {
|
||||
used = f.fake.tokens.has(token); assert.equal(signal.aborted, false);
|
||||
});
|
||||
assert.ok(used);
|
||||
const events = f.events();
|
||||
const calls = events.filter((e) => e.action === 'grant.call');
|
||||
assert.ok(calls.length >= 4);
|
||||
for (const call of calls) {
|
||||
const result = events.find((e) => e.action === 'grant.result' && e.callId === call.callId);
|
||||
assert.ok(result);
|
||||
assert.ok(events.indexOf(result) > events.indexOf(call));
|
||||
}
|
||||
assert.equal(events.filter((e) => Object.hasOwn(e, 'decision')).length, 1);
|
||||
const text = JSON.stringify(events);
|
||||
for (const token of f.fake.tokens.keys()) assert.ok(!text.includes(token));
|
||||
assert.ok(!text.includes('PRIVATE KEY'));
|
||||
assert.ok(!text.includes('Bearer'));
|
||||
});
|
||||
|
||||
for (const [label, mutation] of [
|
||||
['extra permission', (b) => { b.permissions.workflows = 'write'; }],
|
||||
['higher metadata level', (b) => { b.permissions.metadata = 'write'; }],
|
||||
['missing permission', (b) => { delete b.permissions.metadata; }],
|
||||
['all repositories', (b) => { b.repository_selection = 'all'; }],
|
||||
['extra repository', (b) => { b.repositories.push({ id: 102, full_name: 'acme/other' }); }],
|
||||
['missing repository', (b) => { b.repositories = []; }],
|
||||
['duplicate repository', (b) => { b.repositories.push({ ...b.repositories[0] }); }],
|
||||
['past expiry', (b) => { b.expires_at = new Date(START).toISOString(); }],
|
||||
['missing expiry', (b) => { delete b.expires_at; }],
|
||||
['excessive lifetime', (b) => { b.expires_at = new Date(START + TOKEN_LIFETIME_MS + CLOCK_SKEW_MS + 1).toISOString(); }],
|
||||
]) test(`R1/R4 mint refuses ${label} before any guarded use and revokes candidate`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
let repoReads = 0;
|
||||
f.fake.intercept(async (r, answer, { tokens }) => {
|
||||
if (r.path === '/installation/repositories') repoReads++;
|
||||
if (r.method !== 'POST') return false;
|
||||
const token = 'ghs_fake_candidate_12345678901234567890';
|
||||
const body = { token, expires_at: new Date(START + TOKEN_LIFETIME_MS).toISOString(),
|
||||
permissions: { ...FIXED_PERMISSIONS }, repository_selection: 'selected', repositories: [{ id: 101, full_name: 'acme/app' }] };
|
||||
tokens.set(token, structuredClone(body)); mutation(body); answer(201, body); return true;
|
||||
});
|
||||
await rejectCode(f.apply(), 'github-token-invalid');
|
||||
assert.equal(f.manager.status('demo').grants.length, 0);
|
||||
assert.equal(repoReads, 0);
|
||||
assert.equal(f.fake.tokens.size, 0);
|
||||
});
|
||||
|
||||
test('R4 renewal starts at the safety margin and uses a new token under the same decision', async (t) => {
|
||||
const f = await fixture(t); const decision = await f.apply();
|
||||
const old = [...f.fake.tokens.keys()][0];
|
||||
f.advance(TOKEN_LIFETIME_MS - TOKEN_MARGIN_MS - 1);
|
||||
await f.manager.withToken('demo', 'coder', () => {});
|
||||
assert.equal(f.fake.tokens.size, 1);
|
||||
f.advance(1);
|
||||
await f.manager.withToken('demo', 'coder', (token) => { assert.ok(token !== old); });
|
||||
assert.equal(f.fake.tokens.has(old), false);
|
||||
assert.equal(f.manager.status('demo').grants[0].decision, decision);
|
||||
assert.equal(f.events().filter((e) => e.operation === 'grant.github').length, 1);
|
||||
});
|
||||
|
||||
for (const [label, change] of [
|
||||
['project repository ID', (b) => { b.projects.app.githubId = 102; }],
|
||||
['project remote', (b) => { b.projects.app.remote += '-changed'; }],
|
||||
['allowed projects', (b) => { b.roles.coder.github.projects.push('other'); }],
|
||||
['definition', (b) => { b.definitions.coder.title = 'changed'; }],
|
||||
]) test(`R5 config change to ${label} refuses renewal and never uses old token`, async (t) => {
|
||||
const f = await fixture(t); await f.apply();
|
||||
change(f.business); f.advance(TOKEN_LIFETIME_MS);
|
||||
let used = false;
|
||||
await rejectCode(f.manager.withToken('demo', 'coder', () => { used = true; }), 'decision-mismatch');
|
||||
assert.equal(used, false);
|
||||
});
|
||||
|
||||
test('renewal refuses after the durable grant deadline or processing halt', async (t) => {
|
||||
const f = await fixture(t); await f.apply();
|
||||
f.working(false);
|
||||
await rejectCode(f.manager.withToken('demo', 'coder', () => {}), 'processing-halted');
|
||||
f.working(true); f.advance(40 * 86400000);
|
||||
await rejectCode(f.manager.withToken('demo', 'coder', () => {}), 'grant-expired');
|
||||
});
|
||||
|
||||
test('local revoke wins a pending renewal; candidate is revoked and grant never revives', async (t) => {
|
||||
const f = await fixture(t); await f.apply(); f.advance(TOKEN_LIFETIME_MS);
|
||||
let release, entered;
|
||||
const held = new Promise((resolve) => { entered = resolve; });
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
f.fake.intercept(async (r) => {
|
||||
if (r.method === 'POST') { entered(); await gate; }
|
||||
return false;
|
||||
});
|
||||
const pending = f.manager.withToken('demo', 'coder', () => assert.fail('late token used'));
|
||||
// Attach the rejection handler before releasing the network call.
|
||||
const refused = rejectCode(pending, 'revoked');
|
||||
await held;
|
||||
await f.manager.revoke('demo', 'coder', 'test', 'jason');
|
||||
assert.equal(f.manager.status('demo').grants.length, 0);
|
||||
release(); await refused;
|
||||
assert.equal(f.manager.status('demo').grants.length, 0);
|
||||
assert.equal(f.fake.tokens.size, 0);
|
||||
});
|
||||
|
||||
test('revoke blocks guarded use before a remote delete completes; failure is serviceLeft', async (t) => {
|
||||
const f = await fixture(t); await f.apply();
|
||||
let localGone = false;
|
||||
f.fake.intercept(async (r, answer) => {
|
||||
if (r.method === 'DELETE') { localGone = f.manager.status('demo').grants.length === 0; answer(503, {}); return true; }
|
||||
return false;
|
||||
});
|
||||
await f.manager.revoke('demo', 'coder', 'test', 'jason');
|
||||
assert.ok(localGone);
|
||||
await rejectCode(f.manager.withToken('demo', 'coder', () => assert.fail('revoked token used')), 'github-not-granted');
|
||||
assert.equal(f.manager.status('demo').serviceLeft.length, 1);
|
||||
f.fake.intercept(null); await f.manager.sweep('demo', 'jason');
|
||||
assert.equal(f.fake.tokens.size, 0);
|
||||
assert.equal(f.manager.status('demo').serviceLeft.length, 0);
|
||||
});
|
||||
|
||||
test('lost mint response stays visible through restart until conservative expiry equality', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.fake.intercept(async (r, answer, { req }) => {
|
||||
if (r.method === 'POST') { req.socket.destroy(); return true; }
|
||||
return false;
|
||||
});
|
||||
await rejectCode(f.apply(), 'github-failed');
|
||||
const status = f.manager.status('demo');
|
||||
assert.equal(status.serviceLeft.length, 1);
|
||||
const until = Date.parse(status.serviceLeft[0].until);
|
||||
assert.equal(until, START + CALL_TIMEOUT_MS + TOKEN_LIFETIME_MS + CLOCK_SKEW_MS);
|
||||
const restarted = f.create(); restarted.reconcile('demo');
|
||||
f.advance(until - f.now() - 1); await restarted.sweep('demo', 'jason');
|
||||
assert.equal(restarted.status('demo').serviceLeft.length, 1);
|
||||
f.advance(1); await restarted.sweep('demo', 'jason');
|
||||
assert.equal(restarted.status('demo').serviceLeft.length, 0);
|
||||
});
|
||||
|
||||
// Kills removal of the approval/revocation boundary check.
|
||||
test('a revoke invalidates an older unspent approval, even when no token was issued', async (t) => {
|
||||
const f = await fixture(t); const decision = f.raise();
|
||||
await f.manager.revoke('demo', 'coder', 'cancel approval', 'jason');
|
||||
await rejectCode(f.manager.run('demo', decision), 'revoked');
|
||||
assert.equal(f.spent.has(decision), false);
|
||||
assert.equal(f.fake.tokens.size, 0);
|
||||
});
|
||||
|
||||
test('verification reads every page and binds repository IDs to their configured names', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const projects = [];
|
||||
f.fake.state.repositories = [];
|
||||
for (let i = 0; i < 31; i++) {
|
||||
const p = 'repo' + i; projects.push(p);
|
||||
f.business.projects[p] = { githubId: 1000 + i, remote: f.fake.url + '/acme/' + p };
|
||||
f.fake.state.repositories.push({ id: 1000 + i, full_name: 'acme/' + p });
|
||||
}
|
||||
f.business.roles.coder.github.projects = projects;
|
||||
f.grant.target.projects = projects;
|
||||
await f.apply();
|
||||
assert.deepEqual(f.fake.requests.filter((r) => r.path === '/installation/repositories').map((r) => r.query),
|
||||
['?per_page=30&page=1', '?per_page=30&page=2']);
|
||||
assert.equal(f.manager.status('demo').grants[0].repositoryIds.length, 31);
|
||||
});
|
||||
|
||||
test('verification refuses wider reach and deletes the candidate', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.fake.intercept(async (r, answer) => {
|
||||
if (r.path !== '/installation/repositories') return false;
|
||||
answer(200, { total_count: 2, repositories: [{ id: 101, full_name: 'acme/app' }, { id: 102, full_name: 'acme/foreign' }] });
|
||||
return true;
|
||||
});
|
||||
await rejectCode(f.apply(), 'github-reach-unknown');
|
||||
assert.equal(f.fake.tokens.size, 0);
|
||||
assert.equal(f.manager.status('demo').grants.length, 0);
|
||||
});
|
||||
|
||||
test('token replacement is usable before old-token delete and not after grant revocation', async (t) => {
|
||||
const f = await fixture(t); await f.apply();
|
||||
const oldGeneration = f.manager.status('demo').grants[0].generation;
|
||||
f.fake.intercept(async (r) => {
|
||||
if (r.method === 'DELETE') {
|
||||
const active = f.manager.status('demo').grants[0];
|
||||
assert.ok(active.generation !== oldGeneration);
|
||||
assert.equal(f.fake.tokens.size, 2);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
f.advance(TOKEN_LIFETIME_MS - TOKEN_MARGIN_MS);
|
||||
await f.manager.withToken('demo', 'coder', () => {});
|
||||
assert.equal(f.fake.tokens.size, 1);
|
||||
});
|
||||
|
||||
test('failed renewal never calls the consumer with the previous token', async (t) => {
|
||||
const f = await fixture(t); await f.apply();
|
||||
f.advance(TOKEN_LIFETIME_MS - TOKEN_MARGIN_MS);
|
||||
f.fake.intercept(async (r, answer) => {
|
||||
if (r.method === 'POST') { answer(503, {}); return true; }
|
||||
return false;
|
||||
});
|
||||
let called = false;
|
||||
await rejectCode(f.manager.withToken('demo', 'coder', () => { called = true; }), 'github-failed');
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test('admin metadata replacement invalidates an approved durable binding', async (t) => {
|
||||
const f = await fixture(t); await f.apply();
|
||||
await f.api.setAdmin('demo', { ...f.meta, setAt: new Date(START + 1).toISOString() }, f.fake.key, async (op, fn) => fn(), { replace: true });
|
||||
f.advance(TOKEN_LIFETIME_MS);
|
||||
await rejectCode(f.manager.withToken('demo', 'coder', () => {}), 'decision-mismatch');
|
||||
});
|
||||
|
||||
test('preflight identity or permission failure is unspent and a retry can run before restart', async (t) => {
|
||||
const f = await fixture(t); const decision = f.raise();
|
||||
f.fake.state.permissions.contents = 'read';
|
||||
await rejectCode(f.manager.run('demo', decision), 'grant-admin-scope');
|
||||
assert.equal(f.spent.has(decision), false);
|
||||
f.fake.state.permissions.contents = 'write';
|
||||
await f.manager.run('demo', decision);
|
||||
assert.equal(f.spent.has(decision), true);
|
||||
});
|
||||
|
||||
test('an active generation restarts only under its recorded durable approval and timestamp', async (t) => {
|
||||
const f = await fixture(t); await f.apply();
|
||||
const restarted = f.create(); restarted.reconcile('demo');
|
||||
await restarted.withToken('demo', 'coder', (token) => { assert.ok(f.fake.tokens.has(token)); });
|
||||
f.advance(TOKEN_LIFETIME_MS);
|
||||
f.business.projects.app.githubId = 102;
|
||||
await rejectCode(restarted.withToken('demo', 'coder', () => {}), 'decision-mismatch');
|
||||
});
|
||||
|
||||
test('look-alike applied evidence under another kind cannot make a live grant', async (t) => {
|
||||
const f = await fixture(t); const decision = f.raise();
|
||||
f.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.refused',NULL,'grant',NULL,?)",
|
||||
randomUUID(), new Date(START).toISOString(), 'demo', JSON.stringify({ action: 'grant.applied', method: 'github',
|
||||
agent: 'coder', grantDecision: decision, generation: randomUUID(), binding: f.approved.get(decision), expiresAt: new Date(START + TOKEN_LIFETIME_MS).toISOString() }));
|
||||
assert.equal(f.manager.status('demo').grants.length, 0);
|
||||
await rejectCode(f.manager.withToken('demo', 'coder', () => {}), 'github-not-granted');
|
||||
});
|
||||
|
||||
test('mint intent and grant.call are durable before the fake sees each request', async (t) => {
|
||||
const f = await fixture(t); const observed = [];
|
||||
f.fake.intercept(async (r) => {
|
||||
const events = f.events();
|
||||
const call = events.findLast((e) => e.action === 'grant.call' && e.method === 'github' && e.op.method === r.method && e.op.path === r.path + r.query);
|
||||
observed.push(Boolean(call && !events.some((e) => e.action === 'grant.result' && e.callId === call.callId)));
|
||||
if (r.method === 'POST') observed.push(events.some((e) => e.action === 'grant.step' && e.step === 'mint'));
|
||||
return false;
|
||||
});
|
||||
await f.apply();
|
||||
assert.ok(observed.length >= 5);
|
||||
assert.ok(observed.every(Boolean));
|
||||
});
|
||||
|
||||
test('an interrupted service call without its result stays visible after restart', async (t) => {
|
||||
const f = await fixture(t); const callId = randomUUID();
|
||||
writeGrantEvent(f.store, new Date(START).toISOString(), 'demo', { action: 'grant.call', method: 'github', callId,
|
||||
grantDecision: null, agent: null, generation: null, op: { method: 'GET', path: '/app' } });
|
||||
const restarted = f.create(); restarted.reconcile('demo');
|
||||
assert.deepEqual(restarted.status('demo').unclosedCalls.map((e) => e.callId), [callId]);
|
||||
});
|
||||
|
||||
test('restart never adopts a known token from an interrupted mint and sweep revokes it', async (t) => {
|
||||
const f = await fixture(t); const decision = f.raise(), generation = randomUUID();
|
||||
const result = await f.api.mint('demo', [101], async (op, fn) => fn());
|
||||
writeGrantEvent(f.store, new Date(START).toISOString(), 'demo', { operation: 'grant.github', decision });
|
||||
writeGrantEvent(f.store, new Date(START).toISOString(), 'demo', { action: 'grant.step', method: 'github', step: 'mint', agent: 'coder',
|
||||
grantDecision: decision, generation, until: new Date(START + CALL_TIMEOUT_MS + TOKEN_LIFETIME_MS + CLOCK_SKEW_MS).toISOString(), admin: f.approved.get(decision).admin });
|
||||
githubWrite(githubDirectory(f.configDir, ['grants', 'demo', 'github', 'tokens', 'coder'], true), generation, result.json.token + '\n');
|
||||
const restarted = f.create(); restarted.reconcile('demo');
|
||||
assert.equal(restarted.status('demo').grants.length, 0);
|
||||
await rejectCode(restarted.withToken('demo', 'coder', () => {}), 'github-not-granted');
|
||||
await restarted.sweep('demo', 'restart');
|
||||
assert.equal(f.fake.tokens.size, 0);
|
||||
assert.equal(restarted.status('demo').serviceLeft.length, 0);
|
||||
});
|
||||
|
||||
test('the per-agent claim refuses a second guarded use while the first callback is live', async (t) => {
|
||||
const f = await fixture(t); await f.apply();
|
||||
let release, entered;
|
||||
const held = new Promise((r) => { entered = r; }), gate = new Promise((r) => { release = r; });
|
||||
const first = f.manager.withToken('demo', 'coder', async () => { entered(); await gate; });
|
||||
await held;
|
||||
await rejectCode(f.manager.withToken('demo', 'coder', () => assert.fail('second consumer')), 'grant-busy');
|
||||
release(); await first;
|
||||
await f.manager.withToken('demo', 'coder', () => {});
|
||||
});
|
||||
|
||||
test('N1 sweep closes an expired active generation while preserving its renewable grant', async (t) => {
|
||||
const f = await fixture(t); const decision = await f.apply();
|
||||
const active = f.manager.status('demo').grants[0];
|
||||
const file = join(f.configDir, 'secrets/grants/demo/github/tokens/coder', active.generation);
|
||||
const intent = f.events().find((e) => e.action === 'grant.step' && e.generation === active.generation);
|
||||
f.advance(Date.parse(intent.until) - f.now() - 1); await f.manager.sweep('demo', 'jason');
|
||||
assert.ok(existsSync(file));
|
||||
f.advance(1); await f.manager.sweep('demo', 'jason');
|
||||
assert.equal(existsSync(file), false);
|
||||
assert.equal(f.manager.status('demo').grants[0].decision, decision);
|
||||
assert.ok(f.events().some((e) => e.action === 'grant.token.closed' && e.generation === active.generation && e.reason === 'expired'));
|
||||
await f.manager.withToken('demo', 'coder', (token) => assert.ok(f.fake.tokens.has(token)));
|
||||
assert.ok(f.manager.status('demo').grants[0].generation !== active.generation);
|
||||
});
|
||||
|
||||
test('mutant: closeToken must not count a 401 as proven revocation', async (t) => {
|
||||
const f = await fixture(t); await f.apply();
|
||||
f.fake.intercept(async (r, answer) => { if (r.method === 'DELETE') { answer(401, {}); return true; } return false; });
|
||||
await f.manager.revoke('demo', 'coder', 'test', 'jason');
|
||||
assert.equal(f.manager.status('demo').serviceLeft.length, 1);
|
||||
assert.equal(f.fake.tokens.size, 1);
|
||||
});
|
||||
|
||||
for (const status of [500, 503]) test(`mutant: mint ${status} is uncertain issuance and stays serviceLeft`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.fake.intercept(async (r, answer) => { if (r.method === 'POST') { answer(status, {}); return true; } return false; });
|
||||
await rejectCode(f.apply(), 'github-failed');
|
||||
assert.equal(f.manager.status('demo').serviceLeft.length, 1);
|
||||
assert.equal(f.events().some((e) => e.action === 'grant.token.closed' && e.reason === 'not-issued'), false);
|
||||
});
|
||||
|
||||
test('mutant: shutdown abort alone prevents a pending generation from applying', async (t) => {
|
||||
const f = await fixture(t); const decision = f.raise();
|
||||
let release, entered;
|
||||
const held = new Promise((resolve) => { entered = resolve; }), gate = new Promise((resolve) => { release = resolve; });
|
||||
f.fake.intercept(async (r) => { if (r.method === 'POST') { entered(); await gate; } return false; });
|
||||
const pending = f.manager.run('demo', decision);
|
||||
const refused = rejectCode(pending, 'revoked');
|
||||
await held;
|
||||
const closed = f.manager.close(); release();
|
||||
await refused; await closed;
|
||||
assert.equal(f.manager.status('demo').grants.length, 0);
|
||||
assert.equal(f.fake.tokens.size, 0);
|
||||
});
|
||||
@@ -1,317 +0,0 @@
|
||||
// C2-03b (#1791, design 7.2): the credential the library fetch runs with. The chief's grant store file
|
||||
// once a chief has Gitea grant evidence, slot library-gitea before that, and a refusal otherwise.
|
||||
// T7's revoked and fired cases and T9's revoked case run the broker's real paths (dewey-0864): the
|
||||
// chief's grant through decision.raise and the human's yes against the fake Gitea in fake-gitea-api.mjs
|
||||
// on 127.0.0.1, then grants.revoke or instance.fire. roles/chief-of-staff.json names no gitea credential,
|
||||
// so these tests give chief-of-staff one in their own definitions, as gitea-grant.test.mjs gives coder
|
||||
// one. The choice function's other checks (listed, a fire under way, held, two chiefs, another project)
|
||||
// use fixtures: the evidence and the gitea_grants row go in by SQL, as gitea-grant.test.mjs does for
|
||||
// decisions. Tokens are made up or the fake's, and only ever compared.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker, GRANT_EVIDENCE, LIBRARY_FETCH_FIX, writeGrantEvent } from '../src/broker.mjs';
|
||||
import { Credentials } from '../src/credentials.mjs';
|
||||
import { giteaGrant, giteaKeys, giteaStart } from '../src/gitea-grant.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { gitEnv } from '../../seat/src/checkout.mjs';
|
||||
import { mappingEntry, repo } from '../../library/tests/helpers.mjs';
|
||||
import * as library from '../../library/src/index.mjs';
|
||||
import { fakeGiteaApi } from './fake-gitea-api.mjs';
|
||||
import { grantDecision } from './host-fixtures.mjs';
|
||||
import { rolesCopy, systemFor } from '../../business/tests/helpers.mjs';
|
||||
|
||||
const AT = '2026-10-22T00:00:00.000Z';
|
||||
const REV = 'b'.repeat(64);
|
||||
const SLOT_TOKEN = 'fixture-token-2c90-made-up-0048';
|
||||
const role = { authority: { withinRole: [], crossRole: [] } };
|
||||
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
const chiefAuthority = JSON.parse(readFileSync(fileURLToPath(new URL('../../../roles/chief-of-staff.json', import.meta.url)), 'utf8')).authority;
|
||||
const SWITCH = { version: 1, halted: false, generation: '0b6e7f6e-4c1d-4a8e-9a51-6d2f3c4b5a69' };
|
||||
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
async function until(fn, ms = 10000) {
|
||||
const end = Date.now() + ms;
|
||||
while (!fn()) {
|
||||
if (Date.now() > end) throw new Error('timed out');
|
||||
await sleep(10);
|
||||
}
|
||||
}
|
||||
|
||||
const makeBusiness = ({ chief = true, instances = null } = {}) => ({
|
||||
id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { ...(chief ? { chief: { definition: 'chief-of-staff', ...role } } : {}), coder: { definition: 'coder', ...role } },
|
||||
projects: { books: { ...mappingEntry(), user: 'books-bot' } },
|
||||
...(instances ? { launch: { instances } } : {}),
|
||||
});
|
||||
|
||||
// A Gitea grant for `agent` as row 236 leaves it: the decision, the user and grant rows, and
|
||||
// grant.applied. `project` is the grant's target; the evidence check names no project.
|
||||
function grant(store, agent, { user = agent + '-bot', project = 'books', row = true } = {}) {
|
||||
const decision = grantDecision(store, { business: 'acme', grant: { method: 'gitea', agent, target: { projects: [project] }, rotateBy: '2099-01-01' } });
|
||||
writeGrantEvent(store, AT, 'acme', { operation: 'grant.gitea', decision }, decision);
|
||||
writeGrantEvent(store, AT, 'acme', { action: 'grant.applied', method: 'gitea', agent, grantDecision: decision, user, projects: [project] }, decision);
|
||||
if (!row) return decision;
|
||||
store.run('INSERT INTO gitea_users(business,agent,user,id,nonce,decision) VALUES(?,?,?,?,?,?)', 'acme', agent, user, 7, randomBytes(8).toString('hex'), decision);
|
||||
store.run('INSERT INTO gitea_grants(business,agent,user,token_id,token_name,projects,rotate_by,decision) VALUES(?,?,?,?,?,?,?,?)',
|
||||
'acme', agent, user, 11, 'mosaic-' + agent, JSON.stringify([project]), '2099-01-01', decision);
|
||||
return decision;
|
||||
}
|
||||
|
||||
function world(t, options) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-library-credential-')), store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = makeBusiness(options);
|
||||
const held = new Set();
|
||||
const broker = new Broker({ store, businesses: { acme: business }, giteaKeys: { held: (b, a) => held.has(b + '/' + a) } });
|
||||
return { store, business, broker, held, choose: () => broker.libraryFetchCredential('acme') };
|
||||
}
|
||||
|
||||
// The business for the real paths. `boss` is launch.by: instance.fire is the launch.by role's verb and
|
||||
// refuses its own name (organization-instance-referenced), so the fired chief is the one listed
|
||||
// chief-of-staff instance, fired by boss. The grant targets project app; books is the library.
|
||||
function verbDoc(url) {
|
||||
return {
|
||||
id: 'acme', human: 'jason', arbiters: { technical: 'boss', delivery: 'boss' },
|
||||
roles: {
|
||||
boss: { definition: 'pm', authority: chiefAuthority },
|
||||
chief: { definition: 'chief-of-staff', ...none, identity: { name: 'Chief', email: '[email protected]', gitea: { url, user: 'acme-chief' } }, publish: ['app'] },
|
||||
coder: { definition: 'coder', ...none },
|
||||
},
|
||||
definitions: {
|
||||
'chief-of-staff': { roleVersion: 2, credentials: [{ service: 'gitea' }] },
|
||||
pm: { roleVersion: 2, credentials: [] },
|
||||
coder: { roleVersion: 2, credentials: [] },
|
||||
},
|
||||
projects: { app: { remote: `${url}/acme/app` }, books: { ...mappingEntry(), user: 'books-bot' } },
|
||||
launch: { by: 'boss', instances: ['chief', 'coder'] },
|
||||
};
|
||||
}
|
||||
|
||||
// A bare broker on the real grant paths, as gitea-grant.test.mjs and fire-revokes-grants.test.mjs run
|
||||
// it. A second one on the same data root and store picks the grant up as runtime.mjs does: giteaStart,
|
||||
// then Credentials with its references and held list. The admin slot sits outside storeRoot's config
|
||||
// dir, so a runtime started on that dir finds no admin slot.
|
||||
async function verbWorld(t, opts = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'bus-library-verbs-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
let gitea = opts.gitea;
|
||||
if (!gitea) {
|
||||
gitea = await fakeGiteaApi(t, join(dir, 'tls'));
|
||||
gitea.addUser('acme');
|
||||
gitea.addRepo('acme', 'app');
|
||||
}
|
||||
const adminDir = join(dir, 'admin');
|
||||
mkdirSync(adminDir, { mode: 0o700 });
|
||||
const dataRoot = opts.dataRoot ?? join(dir, 'data');
|
||||
mkdirSync(dataRoot, { recursive: true, mode: 0o700 });
|
||||
const storeRoot = opts.storeRoot ?? join(dir, 'secrets', 'agents');
|
||||
const doc = opts.doc ?? verbDoc(gitea.url);
|
||||
const store = new Store(dataRoot);
|
||||
const references = {};
|
||||
const { held } = giteaStart(store, storeRoot, { acme: doc }, references);
|
||||
const credentials = new Credentials({ references, storeRoot, dataRoot, held });
|
||||
let open = true;
|
||||
const close = () => {
|
||||
if (!open) return;
|
||||
open = false;
|
||||
credentials.close();
|
||||
store.close();
|
||||
};
|
||||
t.after(close);
|
||||
const grantApi = giteaGrant({ configDir: adminDir, ca: gitea.ca, timeoutMs: 5000 });
|
||||
grantApi.writeAdmin('acme', gitea.adminToken, { url: gitea.url, account: 'mosaic-grant', rotateBy: dayFrom(60), setAt: AT });
|
||||
let broker;
|
||||
const organization = (business, op, args) => {
|
||||
if (op === 'instance.fire') {
|
||||
doc.launch.instances = doc.launch.instances.filter((n) => n !== args.name);
|
||||
broker.reloadBusiness('acme', doc);
|
||||
return { revision: REV, transaction: 't-fire' };
|
||||
}
|
||||
return { revision: REV };
|
||||
};
|
||||
broker = new Broker({
|
||||
store, businesses: { acme: structuredClone(doc) }, secretCheck: (value) => credentials.assertClean(value), organization,
|
||||
processing: { read: () => SWITCH }, giteaGrant: grantApi, giteaKeys: giteaKeys({ credentials, storeRoot }),
|
||||
});
|
||||
const boss = broker.bindLaunch({ business: 'acme', role: 'boss', run: 'boss-run', harness: 'pi', address: 'boss' });
|
||||
broker.request(boss, { verb: 'role.claim' });
|
||||
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const events = () => store.all(`SELECT body FROM events WHERE business='acme' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((e) => JSON.parse(e.body));
|
||||
const ended = (id) => events().findLast((e) => e.grantDecision === id && ['grant.applied', 'grant.failed'].includes(e.action));
|
||||
const row = () => store.get("SELECT 1 FROM gitea_grants WHERE business='acme' AND agent='chief'");
|
||||
const approveChief = async () => {
|
||||
const d = await broker.request(boss, {
|
||||
verb: 'decision.raise',
|
||||
args: {
|
||||
action: 'credential.mint', question: 'Grant chief Gitea?', options, recommendation: 'yes', blocking: false,
|
||||
grant: { method: 'gitea', agent: 'chief', target: { projects: ['app'] }, rotateBy: dayFrom(30) },
|
||||
},
|
||||
});
|
||||
broker.request(human, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
|
||||
await until(() => ended(d.id));
|
||||
await broker.giteaIdle();
|
||||
assert.equal(ended(d.id).action, 'grant.applied', 'the chief\'s grant landed');
|
||||
assert.ok(row(), 'with its gitea_grants row');
|
||||
};
|
||||
const revoke = async () => {
|
||||
await broker.request(human, { verb: 'grants.revoke', args: { service: 'gitea', agent: 'chief', reason: 'test' } });
|
||||
await broker.giteaIdle();
|
||||
assert.equal(row(), undefined, 'grants.revoke deleted the row');
|
||||
assert.ok(events().some((e) => e.action === 'grant.revoked' && e.agent === 'chief'));
|
||||
};
|
||||
const fire = async () => {
|
||||
await broker.request(boss, { verb: 'instance.fire', args: { name: 'chief', reason: 'the work is done' } });
|
||||
await until(() => !doc.launch.instances.includes('chief'));
|
||||
await broker.grantsIdle();
|
||||
assert.equal(row(), undefined, 'the fire\'s step G deleted the row (row 281)');
|
||||
};
|
||||
return { gitea, doc, store, broker, storeRoot, row, approveChief, revoke, fire, close, choose: () => broker.libraryFetchCredential('acme') };
|
||||
}
|
||||
|
||||
const REFUSED = { refused: 'library-fetch-no-grant', fix: LIBRARY_FETCH_FIX };
|
||||
|
||||
test('C2-03b T6: the slot before any chief evidence, then the listed chief\'s live grant', t => {
|
||||
const w = world(t);
|
||||
assert.deepEqual(w.choose(), { kind: 'slot' });
|
||||
grant(w.store, 'coder');
|
||||
assert.deepEqual(w.choose(), { kind: 'slot' }, 'a seat grant is not the chief\'s: the chief entry keeps the fallback');
|
||||
grant(w.store, 'chief', { user: 'chief-bot' });
|
||||
assert.deepEqual(w.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' });
|
||||
assert.deepEqual(new Broker({ store: w.store, businesses: { acme: w.business } }).libraryFetchCredential('acme'), REFUSED,
|
||||
'a bus with no grant store refuses rather than guess');
|
||||
assert.throws(() => w.broker.libraryFetchCredential('other'), { code: 'unknown-business' });
|
||||
assert.equal(LIBRARY_FETCH_FIX, 'add the chief of staff, or grant the library through C2-21');
|
||||
});
|
||||
|
||||
test('C2-03b T7: a chief revoked through grants.revoke or fired through instance.fire refuses, and the fallback stays ended', { timeout: 60000 }, async t => {
|
||||
const revoked = await verbWorld(t);
|
||||
assert.deepEqual(revoked.choose(), { kind: 'slot' });
|
||||
await revoked.approveChief();
|
||||
assert.deepEqual(revoked.choose(), { kind: 'chief', agent: 'chief', user: 'acme-chief' });
|
||||
await revoked.revoke();
|
||||
assert.deepEqual(revoked.choose(), REFUSED, 'revoked');
|
||||
|
||||
const fired = await verbWorld(t);
|
||||
await fired.approveChief();
|
||||
assert.deepEqual(fired.choose(), { kind: 'chief', agent: 'chief', user: 'acme-chief' });
|
||||
await fired.fire();
|
||||
assert.equal(fired.doc.launch.instances.includes('chief'), false, 'delisted');
|
||||
assert.ok(fired.doc.roles.chief, 'roles[chief] remains after a fire, so its evidence still ends the fallback');
|
||||
assert.deepEqual(fired.choose(), REFUSED, 'fired');
|
||||
});
|
||||
|
||||
test('C2-03b T7 choice checks: delisted, a fire under way and held each refuse; a rehire lists the chief again', t => {
|
||||
const delisted = world(t, { instances: ['coder'] });
|
||||
grant(delisted.store, 'chief');
|
||||
assert.deepEqual(delisted.choose(), REFUSED, 'delisted with the row still there');
|
||||
delisted.business.launch.instances.push('chief');
|
||||
assert.deepEqual(delisted.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' }, 'a rehire lists it again');
|
||||
delisted.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('f1',?,?,'action.allowed','chief','run','chief',?)",
|
||||
AT, 'acme', JSON.stringify({ action: 'fire.stopped' }));
|
||||
assert.deepEqual(delisted.choose(), REFUSED, 'a fire under way');
|
||||
|
||||
const held = world(t);
|
||||
grant(held.store, 'chief');
|
||||
held.held.add('acme/chief');
|
||||
assert.deepEqual(held.choose(), REFUSED, 'held');
|
||||
});
|
||||
|
||||
test('C2-03b T8: a chief grant on another project ends the fallback, and two listed chiefs refuse library-fetch-no-grant', t => {
|
||||
const w = world(t);
|
||||
grant(w.store, 'chief', { project: 'other-project', row: false });
|
||||
assert.deepEqual(w.choose(), REFUSED);
|
||||
const two = world(t);
|
||||
grant(two.store, 'chief');
|
||||
assert.deepEqual(two.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' });
|
||||
two.business.roles.second = { definition: 'chief-of-staff', ...role };
|
||||
assert.deepEqual(two.choose(), REFUSED, 'two chief-of-staff entries, both listed: one chief only');
|
||||
two.business.launch = { instances: ['coder'] };
|
||||
assert.deepEqual(two.choose(), REFUSED, 'neither listed');
|
||||
});
|
||||
|
||||
test('C2-03b departure (sage-2928): with no chief entry, any Gitea grant evidence refuses', t => {
|
||||
const w = world(t, { chief: false });
|
||||
assert.deepEqual(w.choose(), { kind: 'slot' });
|
||||
grant(w.store, 'coder');
|
||||
assert.deepEqual(w.choose(), REFUSED);
|
||||
});
|
||||
|
||||
// T9 and the runtime wiring. The timer and library.sync both take the broker's choice: the chief's
|
||||
// store file, the slot, or a refusal that runs no git even with the slot at 0000. The grant and its
|
||||
// revoke run through the real paths on the runtime's own data root and store.
|
||||
test('C2-03b T9 runtime: the chief\'s store file and user, then a real revoke that runs no git with the slot at 0000', { timeout: 60000 }, async t => {
|
||||
const f = repo(t), root = f.root, dataRoot = join(root, 'data');
|
||||
mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const storeRoot = join(root, 'secrets', 'agents');
|
||||
// A scratch roles/ whose chief-of-staff carries a gitea credential, as row 285 ships it
|
||||
// (case2-rows.md:1823-1824), so the runtime's definitions match the ones the grant ran under
|
||||
// (dewey-0869). Rebased onto 285, the copy needs no edit.
|
||||
const rolesDir = rolesCopy(root);
|
||||
const chiefRole = join(rolesDir, 'chief-of-staff.json');
|
||||
const chiefDefinition = JSON.parse(readFileSync(chiefRole, 'utf8'));
|
||||
if (!chiefDefinition.credentials.some(c => c.service === 'gitea')) chiefDefinition.credentials.push({ service: 'gitea' });
|
||||
writeFileSync(chiefRole, JSON.stringify(chiefDefinition, null, 2) + '\n');
|
||||
const slot = join(root, 'secrets', 'acme', library.SLOT);
|
||||
mkdirSync(join(root, 'secrets', 'acme'), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(slot, SLOT_TOKEN + '\n', { mode: 0o600 });
|
||||
const granted = await verbWorld(t, { dataRoot, storeRoot });
|
||||
await granted.approveChief();
|
||||
granted.close();
|
||||
const gitea = granted.gitea;
|
||||
const chiefFile = join(storeRoot, 'acme', 'chief', 'gitea');
|
||||
assert.ok(existsSync(chiefFile), 'the grant wrote the chief\'s store file');
|
||||
const tokens = () => [SLOT_TOKEN, ...gitea.issued];
|
||||
const calls = [], lines = [], write = process.stderr.write;
|
||||
const command = (kind, url, opts) => { calls.push({ kind, opts }); return { argv: ['--version'], env: gitEnv() }; };
|
||||
process.stderr.write = function (chunk, ...rest) {
|
||||
if (String(chunk).startsWith('library: ')) { lines.push(String(chunk)); return true; }
|
||||
return write.call(this, chunk, ...rest);
|
||||
};
|
||||
t.after(() => { process.stderr.write = write; });
|
||||
const start = () => startBroker({ dataRoot, businesses: { acme: verbDoc(gitea.url) }, organization: { dir: root, rolesDir, system: systemFor(root) },
|
||||
librarySync: { command, intervalMs: 50 } });
|
||||
const untilAsync = async (fn) => { const end = Date.now() + 10000; while (!fn() && Date.now() < end) await sleep(10); };
|
||||
|
||||
let runtime = await start();
|
||||
await untilAsync(() => calls.length >= 1);
|
||||
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
await runtime.broker.request(human, { verb: 'library.sync', args: { project: 'books' } }).catch(e => e);
|
||||
// webby-0151 R3: library.status names the kind and user of the last fetch, never the file or value.
|
||||
const shown = await runtime.broker.request(human, { verb: 'library.status', args: { project: 'books' } });
|
||||
assert.deepEqual(shown.credential, { kind: 'chief', user: 'acme-chief' });
|
||||
assert.equal(JSON.stringify(shown).includes(chiefFile) || tokens().some(tok => JSON.stringify(shown).includes(tok)), false);
|
||||
await runtime.close();
|
||||
assert.ok(calls.length >= 2, 'the timer and the verb');
|
||||
assert.ok(calls.every(c => c.kind === 'fetch' && c.opts.slot === chiefFile && c.opts.user === 'acme-chief'), 'the chief\'s file and user, never the slot');
|
||||
|
||||
// Revoked through grants.revoke: the next start refuses before git, and the log names the fix.
|
||||
const revoking = await verbWorld(t, { dataRoot, storeRoot, gitea });
|
||||
await revoking.revoke();
|
||||
revoking.close();
|
||||
chmodSync(slot, 0o000);
|
||||
calls.length = 0; lines.length = 0;
|
||||
runtime = await start();
|
||||
await untilAsync(() => lines.length >= 1);
|
||||
const human2 = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
await assert.rejects(runtime.broker.request(human2, { verb: 'library.sync', args: { project: 'books' } }), { code: 'library-fetch-no-grant' });
|
||||
const after = await runtime.broker.request(human2, { verb: 'library.status', args: { project: 'books' } });
|
||||
assert.equal(after.lastError.code, 'library-fetch-no-grant');
|
||||
assert.equal(after.credential, null);
|
||||
await runtime.close();
|
||||
assert.equal(calls.length, 0, 'no git with a refusal');
|
||||
assert.equal(statSync(slot).mode & 0o777, 0, 'the slot was never fixed up');
|
||||
assert.deepEqual(JSON.parse(lines[0].slice('library: '.length)),
|
||||
{ event: 'library-sync-held', business: 'acme', project: 'books', code: 'library-fetch-no-grant', fix: LIBRARY_FETCH_FIX });
|
||||
for (const line of lines) assert.equal(tokens().some(tok => line.includes(tok)), false);
|
||||
const mirror = library.mirrorPath({ business: verbDoc(gitea.url), project: 'books', dataRoot });
|
||||
const state = await library.readSyncState(mirror, { branch: 'main' });
|
||||
assert.equal(state.lastError.code, 'library-fetch-no-grant');
|
||||
assert.equal(state.credential, null);
|
||||
});
|
||||
@@ -1,251 +0,0 @@
|
||||
// Row 224: exercise broker authorization, real C2-05a queries and host served snapshots.
|
||||
// All bodies and remotes are made up. External sync is replaced only at its trusted host seam.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { createLibraryLookup, startBroker } from '../src/runtime.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import * as library from '../../library/src/index.mjs';
|
||||
import { scratchRepo, note } from '../../library/tests/query-fixture.mjs';
|
||||
import { mappingEntry, git } from '../../library/tests/helpers.mjs';
|
||||
|
||||
const NOW = Date.parse('2026-10-22T16:00:00.000Z');
|
||||
const VERBS = ['library.find', 'library.get', 'library.search', 'library.manuscript', 'library.universe'];
|
||||
const GET = { universe: 'harbor', path: 'Book One/Characters/Tomas.md' };
|
||||
const ARGS = [
|
||||
{ universe: 'harbor', query: 'Tomas' }, GET,
|
||||
{ universe: 'harbor', text: 'ferry' }, { universe: 'harbor', book: 'Book One' }, { universe: 'harbor' },
|
||||
];
|
||||
const TEXTS = {
|
||||
'Book One/Characters/Tomas.md': note('character', 'Tomas', 'He runs the ferry. [[Elsewhere]]'),
|
||||
'Book One/Chapters/01.md': '# A ferry\nOne line.\n',
|
||||
'Book One/Playground/What If.md': note('character', 'Maybe Tomas', 'Unwritten ferry.'),
|
||||
'Second World/Characters/Elsewhere.md': note('character', 'Elsewhere', 'Other ferry.'),
|
||||
};
|
||||
|
||||
async function world(t, { projects, adapter, claimed = true, now = () => NOW, files = TEXTS } = {}) {
|
||||
const f = scratchRepo(t, files), dataRoot = join(f.root, 'data');
|
||||
mkdirSync(dataRoot);
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
||||
projects: projects ?? { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
const context = { business, project: 'books', dataRoot };
|
||||
let mirror = null, state = null, fetches = 0;
|
||||
if (business.projects.books?.kind === 'library') {
|
||||
mirror = await library.ensureMirror(context);
|
||||
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, 'HEAD:refs/heads/served');
|
||||
state = { version: 1, branch: 'main', served: f.commit, lastFetch: new Date(NOW).toISOString(),
|
||||
lastError: null, diverged: null, problems: [] };
|
||||
writeFileSync(join(mirror, library.SYNC_STATE_FILE), JSON.stringify(state), { mode: 0o600 });
|
||||
}
|
||||
const setState = patch => {
|
||||
state = { ...state, ...patch };
|
||||
writeFileSync(join(mirror, library.SYNC_STATE_FILE), JSON.stringify(state), { mode: 0o600 });
|
||||
};
|
||||
const lookup = createLibraryLookup({ dataRoot, now, sync: async () => {
|
||||
fetches++;
|
||||
setState({ lastFetch: new Date(now()).toISOString() });
|
||||
return { state, code: null };
|
||||
} });
|
||||
const store = new Store(dataRoot);
|
||||
t.after(() => store.close());
|
||||
const broker = new Broker({ store, businesses: { acme: business },
|
||||
library: adapter ?? { lookup, LibraryError: library.LibraryError } });
|
||||
const agent = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'r224', harness: 'pi' });
|
||||
if (claimed) broker.request(agent, { verb: 'role.claim' });
|
||||
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const reader = broker.bindReader({ business: 'acme' }), tester = broker.bindTester({ business: 'acme' });
|
||||
const call = (verb, args = {}, cap = agent) => Promise.resolve().then(() => broker.request(cap, { verb, args }));
|
||||
return { ...f, dataRoot, business, context, mirror, store, broker, agent, human, reader, tester,
|
||||
call, lookup, setState, fetches: () => fetches };
|
||||
}
|
||||
|
||||
test('all five agent verbs read the configured served commit through real queries', async t => {
|
||||
const f = await world(t);
|
||||
const [find, get, search, manuscript, universe] = await Promise.all(VERBS.map((verb, i) => f.call(verb, ARGS[i])));
|
||||
for (const result of [find, get, search, manuscript, universe]) assert.equal(result.commit, f.commit);
|
||||
assert.deepEqual(find.results.map(x => x.path), ['Book One/Characters/Tomas.md']);
|
||||
assert.match(JSON.stringify(get.lines), /He runs the ferry/);
|
||||
assert.deepEqual(get.links_out, []); // The only target is in another universe.
|
||||
assert.ok(search.results.length > 0);
|
||||
assert.ok(search.results.every(x => !x.path.startsWith('Second World/') && x.scope === 'canon'));
|
||||
assert.equal(manuscript.path, 'Book One/Chapters/01.md');
|
||||
assert.deepEqual(universe.books, ['Book One']);
|
||||
assert.equal(f.fetches(), 0);
|
||||
const playground = await f.call('library.find', { universe: 'harbor', query: '', scope: 'playground' });
|
||||
assert.deepEqual(playground.results.map(x => x.path), ['Book One/Playground/What If.md']);
|
||||
assert.equal(await f.call('library.get', { universe: 'harbor', path: 'Second World/Characters/Elsewhere.md' }), null);
|
||||
});
|
||||
|
||||
test('each verb refuses missing universe and unknown keys before host work', async t => {
|
||||
let calls = 0;
|
||||
const f = await world(t, { adapter: { lookup: () => { calls++; } } });
|
||||
for (const [i, verb] of VERBS.entries()) {
|
||||
const { universe, ...missing } = ARGS[i];
|
||||
await assert.rejects(f.call(verb, missing), { code: 'invalid-request' });
|
||||
for (const extra of [{ repo: '/elsewhere' }, { commit: 'b'.repeat(40) }, { business: 'other' }, { accept: 'b'.repeat(40) }])
|
||||
await assert.rejects(f.call(verb, { ...ARGS[i], ...extra }), { code: 'invalid-request' });
|
||||
await assert.rejects(f.call(verb, { ...ARGS[i], universe: '' }), { code: 'invalid-request' });
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('no library, missing host adapter and undeclared project refuse library-not-configured', async t => {
|
||||
const noLibrary = await world(t, { projects: {} });
|
||||
const noAdapter = await world(t, { adapter: {} });
|
||||
const f = await world(t);
|
||||
for (const [i, verb] of VERBS.entries()) {
|
||||
await assert.rejects(noLibrary.call(verb, ARGS[i]), { code: 'library-not-configured' });
|
||||
await assert.rejects(noAdapter.call(verb, ARGS[i]), { code: 'library-not-configured' });
|
||||
await assert.rejects(f.call(verb, { ...ARGS[i], project: 'other' }), { code: 'library-not-configured' });
|
||||
}
|
||||
f.business.projects.second = mappingEntry();
|
||||
await assert.rejects(f.call('library.get', GET), { code: 'invalid-request' });
|
||||
assert.equal((await f.call('library.get', { ...GET, project: 'books' })).commit, f.commit);
|
||||
});
|
||||
|
||||
test('reads need the current holder on each call; human/tester/reader are not agents', async t => {
|
||||
const f = await world(t, { claimed: false });
|
||||
for (const [i, verb] of VERBS.entries()) {
|
||||
await assert.rejects(f.call(verb, ARGS[i]), { code: 'not-holder' });
|
||||
await assert.rejects(f.call(verb, ARGS[i], f.human), { code: 'agent-required' });
|
||||
await assert.rejects(f.call(verb, ARGS[i], f.tester), { code: 'tester-refused' });
|
||||
await assert.rejects(f.call(verb, ARGS[i], f.reader), { code: 'read-only' });
|
||||
}
|
||||
f.broker.request(f.agent, { verb: 'role.claim' });
|
||||
assert.equal((await f.call('library.get', GET)).commit, f.commit);
|
||||
f.broker.request(f.agent, { verb: 'role.release' });
|
||||
await assert.rejects(f.call('library.get', GET), { code: 'not-holder' });
|
||||
});
|
||||
|
||||
test('releasing a claim during an async lookup fences the response', { timeout: 30_000 }, async t => {
|
||||
let release, entered;
|
||||
const started = new Promise(resolve => { entered = resolve; });
|
||||
const f = await world(t, { adapter: { lookup: async () => {
|
||||
entered(); await new Promise(resolve => { release = resolve; }); return { lines: [[1, 'not returned']] };
|
||||
} } });
|
||||
const pending = f.call('library.get', GET);
|
||||
await started;
|
||||
f.broker.request(f.agent, { verb: 'role.release' });
|
||||
release();
|
||||
await assert.rejects(pending, { code: 'not-holder' });
|
||||
});
|
||||
|
||||
test('a business library removed during an async read cannot leak its result', { timeout: 30_000 }, async t => {
|
||||
let release, entered;
|
||||
const started = new Promise(resolve => { entered = resolve; });
|
||||
const f = await world(t, { adapter: { lookup: async () => {
|
||||
entered(); await new Promise(resolve => { release = resolve; }); return { lines: [[1, 'not returned']] };
|
||||
} } });
|
||||
const pending = f.call('library.get', GET);
|
||||
await started;
|
||||
delete f.business.projects.books;
|
||||
release();
|
||||
await assert.rejects(pending, { code: 'library-not-configured' });
|
||||
});
|
||||
|
||||
test('freshness boundary, unknown last fetch and failed fetch still use immutable served state', async t => {
|
||||
const f = await world(t);
|
||||
f.setState({ lastFetch: new Date(NOW - 60_000).toISOString() });
|
||||
await f.call('library.get', GET);
|
||||
assert.equal(f.fetches(), 0);
|
||||
f.setState({ lastFetch: new Date(NOW - 60_001).toISOString() });
|
||||
await f.call('library.get', GET);
|
||||
assert.equal(f.fetches(), 1);
|
||||
f.setState({ lastFetch: null });
|
||||
await f.call('library.universe', { universe: 'harbor' });
|
||||
assert.equal(f.fetches(), 2);
|
||||
let attempted = 0, clock = NOW;
|
||||
const failing = createLibraryLookup({ dataRoot: f.dataRoot, now: () => clock, sync: async () => {
|
||||
attempted++;
|
||||
f.setState({ lastError: { code: 'library-fetch-failed', at: new Date(clock).toISOString() } });
|
||||
return { code: 'library-fetch-failed' };
|
||||
} });
|
||||
f.setState({ lastFetch: null });
|
||||
const result = await failing(f.context, { method: 'get', args: GET, authorize() {} });
|
||||
assert.equal(attempted, 1);
|
||||
assert.equal(result.commit, f.commit);
|
||||
for (const elapsed of [1, 59_999, 60_000]) {
|
||||
clock = NOW + elapsed;
|
||||
const retry = await failing(f.context, { method: 'get', args: GET, authorize() {} });
|
||||
assert.equal(retry.commit, f.commit, 'serve the old pin during the failure cooldown');
|
||||
assert.equal(attempted, 1, 'recent lastError prevents another sync even though lastFetch is null');
|
||||
}
|
||||
clock = NOW + 60_001;
|
||||
assert.equal((await failing(f.context, { method: 'get', args: GET, authorize() {} })).commit, f.commit);
|
||||
assert.equal(attempted, 2, 'retry only after the failed-attempt interval expires');
|
||||
});
|
||||
|
||||
test('an unreadable sync state fails closed; a busy sync keeps serving the existing pin', async t => {
|
||||
const f = await world(t);
|
||||
const lookup = createLibraryLookup({ dataRoot: f.dataRoot, now: () => NOW, sync: async () => {
|
||||
throw new library.LibraryError('library-sync-busy', 'another fetch');
|
||||
} });
|
||||
f.setState({ lastFetch: null });
|
||||
assert.equal((await lookup(f.context, { method: 'get', args: GET, authorize() {} })).commit, f.commit);
|
||||
writeFileSync(join(f.mirror, library.SYNC_STATE_FILE), '{bad json', { mode: 0o600 });
|
||||
await assert.rejects(f.call('library.get', GET), { code: 'library-state-invalid' });
|
||||
});
|
||||
|
||||
test('a stale lookup sees the commit published by its sync, not the prior served commit', async t => {
|
||||
const f = await world(t);
|
||||
writeFileSync(join(f.repo, GET.path), note('character', 'Tomas', 'Changed ferry.'));
|
||||
git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'second fixture');
|
||||
const next = git(f.repo, 'rev-parse', 'HEAD');
|
||||
const lookup = createLibraryLookup({ dataRoot: f.dataRoot, now: () => NOW, sync: async () => {
|
||||
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', f.mirror, 'HEAD:refs/heads/served');
|
||||
f.setState({ served: next, lastFetch: new Date(NOW).toISOString() });
|
||||
return { code: null };
|
||||
} });
|
||||
f.setState({ lastFetch: null });
|
||||
const result = await lookup(f.context, { method: 'get', args: GET, authorize() {} });
|
||||
assert.equal(result.commit, next);
|
||||
assert.match(JSON.stringify(result.lines), /Changed ferry/);
|
||||
});
|
||||
|
||||
test('real socket awaits lookup results and rechecks rejected query arguments', { timeout: 30_000 }, async t => {
|
||||
const f = await world(t);
|
||||
const path = join(f.root, 'lookup.sock');
|
||||
const server = await serve({ broker: f.broker, path }); t.after(() => server.close());
|
||||
const client = new Client({ path, cap: f.agent });
|
||||
const result = await client.call('library.get', GET);
|
||||
assert.equal(result.commit, f.commit);
|
||||
await assert.rejects(client.call('library.find', { universe: 'harbor', query: '', scope: 'anything' }), /query-argument-invalid/);
|
||||
});
|
||||
|
||||
test('runtime actually injects the lookup adapter, without exposing human library.file', { timeout: 30_000 }, async t => {
|
||||
const f = scratchRepo(t, TEXTS), dataRoot = join(f.root, 'runtime'); mkdirSync(dataRoot);
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
const runtime = await startBroker({ dataRoot, businesses: { acme: business } }); t.after(() => runtime.close());
|
||||
const mirror = library.mirrorPath({ business, project: 'books', dataRoot });
|
||||
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, 'HEAD:refs/heads/served');
|
||||
writeFileSync(join(mirror, library.SYNC_STATE_FILE), JSON.stringify({ version: 1, branch: 'main', served: f.commit,
|
||||
lastFetch: new Date().toISOString(), lastError: null, diverged: null, problems: [] }), { mode: 0o600 });
|
||||
const cap = runtime.broker.bindLaunch({ business: 'acme', role: 'coder', run: 'r224-runtime', harness: 'pi' });
|
||||
runtime.broker.request(cap, { verb: 'role.claim' });
|
||||
assert.equal((await runtime.broker.request(cap, { verb: 'library.get', args: GET })).commit, f.commit);
|
||||
await assert.rejects(runtime.broker.request(cap, { verb: 'library.file', args: { project: 'books', path: GET.path } }),
|
||||
{ code: 'human-required' });
|
||||
});
|
||||
|
||||
test('C2-05c broker forwards supporting scope and returns the served blob without adding write verbs', async t => {
|
||||
const projects = { books: mappingEntry() };
|
||||
projects.books.universes.harbor.supporting = ['Book One/Support/'];
|
||||
const path = 'Book One/Support/Helper.md';
|
||||
const f = await world(t, { projects, files: { ...TEXTS, [path]: note('summary', 'Helper', 'Supporting fixture.') } });
|
||||
assert.deepEqual((await f.call('library.find', { universe: 'harbor', query: '', scope: 'supporting' })).results.map(x => x.path), [path]);
|
||||
assert.deepEqual((await f.call('library.search', { universe: 'harbor', text: 'Supporting fixture', scope: 'supporting' })).results.map(x => x.path), [path]);
|
||||
assert.equal(await f.call('library.get', { universe: 'harbor', path }), null);
|
||||
const result = await f.call('library.get', { universe: 'harbor', path, scope: 'supporting' });
|
||||
assert.equal(result.scope, 'supporting');
|
||||
assert.equal(result.commit, f.commit);
|
||||
assert.equal(result.blob, git(f.mirror, 'rev-parse', `${f.commit}:${path}`));
|
||||
assert.equal(await f.call('library.get', { universe: 'second', path, scope: 'supporting' }), null);
|
||||
await assert.rejects(f.call('library.manuscript', { universe: 'harbor', book: 'Book One', scope: 'supporting' }), { code: 'query-argument-invalid' });
|
||||
});
|
||||
@@ -1,332 +0,0 @@
|
||||
// Row 288: broker authority, exact notice and strict Console revert API. Fixture grant rows only.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { connect } from 'node:net';
|
||||
import { startBroker, createLibraryLookup } from '../src/runtime.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { mappingEntry } from '../../library/tests/helpers.mjs';
|
||||
import { LibraryError } from '../../library/src/problems.mjs';
|
||||
import { grantDecision } from './host-fixtures.mjs';
|
||||
import { world as bareWorld } from '../../library/tests/write-fixture.mjs';
|
||||
import { sync } from '../../library/src/sync.mjs';
|
||||
import { SYNC_STATE_FILE, servedCommit } from '../../library/src/mirror.mjs';
|
||||
import { writeLibraryDoc, saveLibraryFile, revertLibraryCommit } from '../../library/src/write.mjs';
|
||||
import { git } from '../../library/tests/helpers.mjs';
|
||||
|
||||
const SHA = 'a'.repeat(40), NEXT = 'b'.repeat(40), PATH = 'Book One/Notes/Agent.md';
|
||||
function world(t, options = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-library-write-')), store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const projects = options.projects ?? { books: mappingEntry() }; projects.books.universes.harbor.supporting = ['Book One/Notes/'];
|
||||
const authority = { withinRole: ['message.send', 'library.write'], crossRole: [] };
|
||||
const roles = { bean: { definition: 'companion', authority, identity: { name: 'Bean', email: '[email protected]', gitea: { user: 'bean', url: 'https://git.example.invalid' } }, publish: ['books'] },
|
||||
chief: { definition: 'chief-of-staff', authority }, quill: { definition: 'proofreader', authority: { withinRole: ['message.send'], crossRole: [] } } };
|
||||
options.changeRole?.(roles.bean);
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true }, projects, roles,
|
||||
arbiters: { technical: 'chief', delivery: 'chief' }, definitions: { companion: {}, 'chief-of-staff': {}, proofreader: {} } };
|
||||
const seen = [], held = new Set(), logs = [];
|
||||
const result = { project: 'books', path: PATH, commit: NEXT };
|
||||
const library = { LibraryError, log: entry => logs.push(entry), readFile: async () => ({}), status: async () => ({}),
|
||||
writeDoc: async (context, options) => { seen.push({ context, options }); options.beforePush(); options.onPushed(result); return result; },
|
||||
saveFile: async context => { seen.push({ context }); return result; },
|
||||
revert: async context => { seen.push({ context }); return { ...result, reverted: context.commit }; } };
|
||||
const broker = new Broker({ store, businesses: { acme: business }, library, giteaKeys: { held: (b, a) => held.has(a) } });
|
||||
const caps = { human: broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }),
|
||||
tester: broker.bindTester({ business: 'acme' }) };
|
||||
const p = broker.request(caps.human, { verb: 'project.create', args: { name: 'Library chat', repo: 'books' } }).id;
|
||||
for (const role of ['bean', 'quill', 'chief']) {
|
||||
const thread = role === 'chief' ? null : broker.request(caps.human,
|
||||
{ verb: 'thread.create', args: { project: p, name: role, instance: role } }).id;
|
||||
caps[role] = broker.bindLaunch({ business: 'acme', role, run: role + '-run', harness: 'pi', ...(thread ? { thread } : {}) });
|
||||
broker.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
const grant = (project = 'books') => {
|
||||
const decision = grantDecision(store, { business: 'acme', grant: { method: 'gitea', agent: 'bean', target: { projects: [project] }, rotateBy: '2099-01-01' } });
|
||||
store.run('INSERT OR REPLACE INTO gitea_grants(business,agent,user,token_id,token_name,projects,rotate_by,decision) VALUES(?,?,?,?,?,?,?,?)',
|
||||
'acme', 'bean', 'bean', 7, 'fixture', JSON.stringify([project]), '2099-01-01', decision);
|
||||
};
|
||||
grant();
|
||||
const call = (who, verb, args = {}) => Promise.resolve().then(() => broker.request(caps[who], { verb, args }));
|
||||
return { root, store, broker, caps, call, seen, held, business, grant, library, logs };
|
||||
}
|
||||
const args = () => ({ universe: 'harbor', path: PATH, base: SHA, content: 'fixture\n' });
|
||||
|
||||
test('C2-02: agent verbs require creative holder, action and live unheld project grant', async t => {
|
||||
const f = world(t);
|
||||
for (const verb of ['library.doc.write', 'library.doc.delete']) {
|
||||
const a = args(); if (verb.endsWith('delete')) delete a.content;
|
||||
for (const [who, code] of [['human', 'library-write-refused'], ['tester', 'tester-refused'], ['chief', 'library-write-refused'], ['quill', 'library-write-refused']])
|
||||
await assert.rejects(f.call(who, verb, a), { code });
|
||||
f.held.add('bean'); await assert.rejects(f.call('bean', verb, a), { code: 'library-write-no-grant' }); f.held.clear();
|
||||
f.store.run('DELETE FROM gitea_grants');
|
||||
await assert.rejects(f.call('bean', verb, a), { code: 'library-write-no-grant' });
|
||||
f.grant('other'); await assert.rejects(f.call('bean', verb, a), { code: 'library-write-no-grant' });
|
||||
f.store.run('DELETE FROM gitea_grants'); f.grant();
|
||||
}
|
||||
assert.equal(f.seen.length, 0);
|
||||
});
|
||||
|
||||
test('C2-02: writing instance supplies neither ident nor notice text; broker inserts exact notice', async t => {
|
||||
const f = world(t);
|
||||
const result = await f.call('bean', 'library.doc.write', args());
|
||||
assert.equal(result.commit, NEXT);
|
||||
const m = f.store.get("SELECT * FROM messages WHERE from_role='bean' AND to_role='human'");
|
||||
assert.equal(m.body, JSON.stringify({ notice: 'library-write', op: 'write', path: PATH, commit: NEXT }));
|
||||
assert.equal(m.from_run, 'bean-run');
|
||||
assert.ok(f.store.get('SELECT 1 FROM message_threads WHERE message=?', m.id));
|
||||
assert.equal(f.seen[0].options.identity.name, 'Bean');
|
||||
assert.equal(f.seen[0].options.credential.user, 'bean');
|
||||
assert.equal(f.seen[0].options.credential.agent, 'bean');
|
||||
for (const extra of [{ author: 'other' }, { agent: 'chief' }, { credential: { agent: 'chief' } }])
|
||||
await assert.rejects(f.call('bean', 'library.doc.write', { ...args(), ...extra }), { code: 'invalid-request' });
|
||||
});
|
||||
|
||||
test('C2-02 A2: strict human revert args/result and agent human-required refusal', async t => {
|
||||
const f = world(t);
|
||||
for (const who of ['bean', 'quill', 'chief', 'tester'])
|
||||
for (const verb of ['library.revert', 'library.file.save'])
|
||||
await assert.rejects(f.call(who, verb, {}), { code: 'human-required' });
|
||||
for (const a of [{ commit: SHA }, { project: 'books' }, { project: 'books', commit: 'A'.repeat(40) },
|
||||
{ project: 'books', commit: SHA, editor: {} }, { project: 'books', commit: SHA + 'a' }])
|
||||
await assert.rejects(f.call('human', 'library.revert', a), { code: 'invalid-request' });
|
||||
assert.deepEqual(await f.call('human', 'library.revert', { project: 'books', commit: SHA }),
|
||||
{ project: 'books', path: PATH, reverted: SHA, commit: NEXT });
|
||||
await assert.rejects(f.call('human', 'library.revert', { project: 'missing', commit: SHA }), { code: 'project-not-configured' });
|
||||
});
|
||||
|
||||
test('C2-02: human revert independent of revoked seat and LibraryError codes survive', async t => {
|
||||
const f = world(t); f.store.run('DELETE FROM gitea_grants'); f.held.add('bean');
|
||||
assert.equal((await f.call('human', 'library.revert', { project: 'books', commit: SHA })).reverted, SHA);
|
||||
for (const code of ['library-revert-not-agent', 'stale-base', 'library-mapping-invalid', 'library-human-missing']) {
|
||||
f.library.revert = async () => { throw new LibraryError(code, 'fixture'); };
|
||||
await assert.rejects(f.call('human', 'library.revert', { project: 'books', commit: SHA }), { code });
|
||||
}
|
||||
});
|
||||
|
||||
test('C2-02 transport: human library.file.save exceeds 64 KiB; agent write remains bounded', async t => {
|
||||
const f = world(t), path = join(f.root, 'bus.sock');
|
||||
const server = await serve({ broker: f.broker, path, authenticateHuman: () => f.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }) });
|
||||
t.after(() => server.close());
|
||||
const human = new Client({ path, human: { fixture: true } }), agent = new Client({ path, cap: f.caps.bean });
|
||||
const content = 'x'.repeat(70000);
|
||||
assert.equal((await human.call('library.file.save', { project: 'books', path: PATH, base: SHA, content })).commit, NEXT);
|
||||
assert.equal(f.seen.at(-1).context.content, content);
|
||||
await assert.rejects(agent.call('library.doc.write', { ...args(), content }), { code: 'request-too-large' });
|
||||
await assert.rejects(human.call('library.file.save', { project: 'books', path: PATH, base: SHA, content: 'x'.repeat(2 * 1024 * 1024) }), { code: 'request-too-large' });
|
||||
});
|
||||
|
||||
|
||||
test('C2-02 M12 through broker: every refusal preserves remote, served and all mirror refs', async t => {
|
||||
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
|
||||
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.dataRoot },
|
||||
{ ...options, credential: { ...options.credential, file: bare.file }, command: bare.command });
|
||||
for (const path of ['Book One/Chapters/Chapter 01.md', 'Inbox Note.md', 'Book One/Playground/What If.md',
|
||||
'Book One/Notes/X.canvas', 'Book One/Notes/X.base', 'Book One/Notes/X.png', 'Second World/Chapters/01.md',
|
||||
'/Book One/Notes/X.md', 'Book One/Notes/../X.md', 'Book One/./Notes/X.md', 'Book One//Notes/X.md',
|
||||
'Book One/Notes/X\\Y.md', 'Book One/Notes/X\nY.md', 'Book One/Notes/Link.md', 'Book One/Notes/Submodule.md',
|
||||
'Book One/Notes/agent.md', `Book One/Notes/${'é'.repeat(512)}.md`]) {
|
||||
for (const verb of ['library.doc.write', 'library.doc.delete']) {
|
||||
const before = bare.refs(), head = bare.remoteHead(), a = { ...args(), path, base: null };
|
||||
if (verb.endsWith('delete')) delete a.content;
|
||||
await assert.rejects(f.call('bean', verb, a), { code: 'library-write-story-path' }, path);
|
||||
assert.equal(bare.refs(), before); assert.equal(bare.remoteHead(), head);
|
||||
}
|
||||
}
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='bean'").n, 0);
|
||||
});
|
||||
|
||||
test('C2-02 broker + bare: exact write/delete notices, then human revert with revoked seat grant', async t => {
|
||||
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
|
||||
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.dataRoot },
|
||||
{ ...options, credential: { ...options.credential, file: bare.file }, command: bare.command });
|
||||
f.library.revert = context => revertLibraryCommit({ ...context, dataRoot: bare.dataRoot },
|
||||
{ credential: { ...bare.credential, kind: 'human' }, command: bare.command });
|
||||
f.library.saveFile = context => saveLibraryFile({ ...context, dataRoot: bare.dataRoot },
|
||||
{ credential: { ...bare.credential, kind: 'human' }, command: bare.command });
|
||||
const base = git(bare.mirror, 'rev-parse', `served:${PATH}`);
|
||||
const saved = await f.call('bean', 'library.doc.write', { ...args(), base });
|
||||
assert.equal(saved.commit, bare.remoteHead());
|
||||
f.store.run('DELETE FROM gitea_grants'); f.held.add('bean');
|
||||
const reverted = await f.call('human', 'library.revert', { project: 'books', commit: saved.commit });
|
||||
assert.equal(reverted.reverted, saved.commit);
|
||||
assert.equal(git(bare.remote, 'show', `main:${PATH}`), 'before');
|
||||
assert.equal(git(bare.remote, 'show', '-s', '--format=%an <%ae>', reverted.commit), 'Human Fixture <[email protected]>');
|
||||
f.held.clear(); f.grant();
|
||||
const deleted = await f.call('bean', 'library.doc.delete', { universe: 'harbor', path: PATH, base });
|
||||
const messages = f.store.all("SELECT body FROM messages WHERE from_role='bean' AND to_role='human' ORDER BY seq");
|
||||
assert.deepEqual(messages.map(m => JSON.parse(m.body)), [
|
||||
{ notice: 'library-write', op: 'write', path: PATH, commit: saved.commit },
|
||||
{ notice: 'library-write', op: 'delete', path: PATH, commit: deleted.commit }]);
|
||||
assert.equal(git(bare.remote, 'ls-tree', '-r', deleted.commit, '--', PATH), '');
|
||||
});
|
||||
|
||||
|
||||
test('C2-02 socket: a save name nested in proof cannot enlarge an agent envelope', async t => {
|
||||
const f = world(t), path = join(f.root, 'raw-bus.sock');
|
||||
const server = await serve({ broker: f.broker, path, authenticateHuman: () => f.caps.human });
|
||||
t.after(() => server.close());
|
||||
const request = JSON.stringify({ human: { verb: 'library.file.save' }, verb: 'library.doc.write',
|
||||
args: { ...args(), content: 'x'.repeat(70000) } }) + '\n';
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
const socket = connect(path); let result = '';
|
||||
socket.on('error', reject); socket.on('connect', () => socket.write(request));
|
||||
socket.on('data', bytes => { result += bytes; if (result.includes('\n')) { socket.destroy(); resolve(JSON.parse(result)); } });
|
||||
});
|
||||
assert.deepEqual(response, { ok: false, error: 'request-too-large' });
|
||||
assert.equal(f.seen.length, 0);
|
||||
});
|
||||
|
||||
|
||||
test('C2-02 E2: held grant during fetch refuses at beforePush and preserves every ref', async t => {
|
||||
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
|
||||
const before = bare.refs(), head = bare.remoteHead();
|
||||
const command = (kind, url, options) => {
|
||||
if (kind === 'fetch') f.held.add('bean');
|
||||
return bare.command(kind, url, options);
|
||||
};
|
||||
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.dataRoot },
|
||||
{ ...options, credential: { ...options.credential, file: bare.file }, command });
|
||||
await assert.rejects(f.call('bean', 'library.doc.write', { ...args(), base: git(bare.mirror, 'rev-parse', `served:${PATH}`) }),
|
||||
{ code: 'library-write-no-grant' });
|
||||
assert.equal(bare.refs(), before); assert.equal(bare.remoteHead(), head);
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='bean'").n, 0);
|
||||
});
|
||||
|
||||
test('C2-02 E3: expired, firing, missing publish, wrong grant user and missing ident refuse', async t => {
|
||||
const cases = [
|
||||
{ name: 'expired', prepare: f => f.store.run("UPDATE gitea_grants SET rotate_by='2000-01-01'"), code: 'library-write-no-grant' },
|
||||
{ name: 'firing', prepare: f => f.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('fire-bean',?,'acme','action.allowed','chief','run','bean',?)",
|
||||
new Date().toISOString(), JSON.stringify({ action: 'fire.stopped' })), code: 'library-write-no-grant' },
|
||||
{ name: 'publish', changeRole: r => { r.publish = []; }, code: 'library-write-no-grant' },
|
||||
{ name: 'grant user', prepare: f => f.store.run("UPDATE gitea_grants SET user='other-seat'"), code: 'library-write-no-grant' },
|
||||
{ name: 'identity name', changeRole: r => { delete r.identity.name; }, code: 'library-write-refused' },
|
||||
{ name: 'identity email', changeRole: r => { delete r.identity.email; }, code: 'library-write-refused' },
|
||||
];
|
||||
for (const c of cases) {
|
||||
const f = world(t, { changeRole: c.changeRole }); c.prepare?.(f);
|
||||
for (const verb of ['library.doc.write', 'library.doc.delete']) {
|
||||
const a = args(); if (verb.endsWith('delete')) delete a.content;
|
||||
await assert.rejects(f.call('bean', verb, a), { code: c.code }, c.name);
|
||||
}
|
||||
assert.equal(f.seen.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('C2-02 F2/E6 runtime: human saves/reverts use editor.user; no login never uses the fetch bot', { timeout: 30000 }, async t => {
|
||||
for (const user of [undefined, 'her-gitea-login']) {
|
||||
const bare = await bareWorld(t);
|
||||
const agent = await writeLibraryDoc(bare.args(), bare.opts);
|
||||
bare.entry.user = 'fetch-bot';
|
||||
if (user) bare.entry.editor.user = user;
|
||||
const configDir = join(bare.root, 'config'), slots = join(configDir, 'secrets', 'acme');
|
||||
mkdirSync(slots, { recursive: true, mode: 0o700 });
|
||||
for (const slot of ['library-human', 'library-gitea'])
|
||||
writeFileSync(join(slots, slot), 'fx288-human\n', { mode: 0o600 });
|
||||
const pushes = [];
|
||||
const command = (kind, url, options) => { if (kind === 'push') pushes.push(options.user); return bare.command(kind, url, options); };
|
||||
const initial = (await bare.state()).lastFetch;
|
||||
const business = { ...bare.context.business, human: 'jason', roles: { coder: { authority: { withinRole: [], crossRole: [] } } },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' } };
|
||||
const runtime = await startBroker({ dataRoot: bare.dataRoot, businesses: { acme: business },
|
||||
librarySync: { configDir, command, intervalMs: 60000 } });
|
||||
try {
|
||||
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (verb, args) => Promise.resolve().then(() => runtime.broker.request(human, { verb, args }));
|
||||
// Let the boot fetch finish before inspecting only the human operation's command count.
|
||||
const deadline = Date.now() + 10000;
|
||||
while (true) {
|
||||
const state = await call('library.status', { project: 'books' });
|
||||
if (state.lastFetch !== initial) break;
|
||||
if (Date.now() > deadline) throw Error('boot fetch did not complete');
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
const path = 'Book One/Chapters/Chapter 01.md';
|
||||
const saves = [
|
||||
['library.file.save', { project: 'books', path, base: git(bare.mirror, 'rev-parse', `served:${path}`), content: 'her edit\n' }],
|
||||
['library.revert', { project: 'books', commit: agent.commit }],
|
||||
];
|
||||
const before = bare.commands.length;
|
||||
for (const [verb, args] of saves) {
|
||||
if (user) assert.match((await call(verb, args)).commit, /^[a-f0-9]{40}$/);
|
||||
else await assert.rejects(call(verb, args), { code: 'library-human-missing' });
|
||||
}
|
||||
assert.deepEqual(pushes, user ? [user, user] : []);
|
||||
if (!user) assert.equal(bare.commands.length, before);
|
||||
} finally { await runtime.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('C2-02 E4: post-push publication failure stores the exact notice and returns outcome-unknown', async t => {
|
||||
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
|
||||
const state = join(bare.mirror, SYNC_STATE_FILE), original = readFileSync(state);
|
||||
const previous = await servedCommit(bare.mirror), head = bare.remoteHead();
|
||||
const command = (kind, url, options) => {
|
||||
// Break only state decoding after push admission; the real remote push still executes.
|
||||
if (kind === 'push') writeFileSync(state, 'invalid state\n');
|
||||
return bare.command(kind, url, options);
|
||||
};
|
||||
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.dataRoot },
|
||||
{ ...options, credential: { ...options.credential, file: bare.file }, command });
|
||||
try {
|
||||
await assert.rejects(f.call('bean', 'library.doc.write', { ...args(), base: git(bare.mirror, 'rev-parse', `served:${PATH}`) }),
|
||||
{ code: 'outcome-unknown' });
|
||||
assert.notEqual(bare.remoteHead(), head);
|
||||
assert.equal(git(bare.mirror, 'rev-parse', 'served'), previous);
|
||||
const messages = f.store.all("SELECT body FROM messages WHERE from_role='bean' AND to_role='human'");
|
||||
assert.deepEqual(messages.map(m => JSON.parse(m.body)), [
|
||||
{ notice: 'library-write', op: 'write', path: PATH, commit: bare.remoteHead() }]);
|
||||
} finally { writeFileSync(state, original); }
|
||||
const commit = bare.remoteHead();
|
||||
const human = { credential: { ...bare.credential, kind: 'human' }, command: bare.command };
|
||||
await assert.rejects(revertLibraryCommit({ ...bare.context, commit }, human), { code: 'library-revert-not-agent' });
|
||||
await sync(bare.context, { credential: bare.credential, command: bare.command });
|
||||
assert.equal((await revertLibraryCommit({ ...bare.context, commit }, human)).reverted, commit);
|
||||
});
|
||||
|
||||
|
||||
test('C2-02 mos-1520: failed notice insert logs metadata and returns outcome-unknown even if refusal evidence fails', async t => {
|
||||
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
|
||||
const previous = await servedCommit(bare.mirror), head = bare.remoteHead();
|
||||
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.dataRoot },
|
||||
{ ...options, credential: { ...options.credential, file: bare.file }, command: bare.command });
|
||||
f.store.run(`CREATE TEMP TRIGGER fail_library_notice BEFORE INSERT ON messages
|
||||
WHEN NEW.from_role='bean' BEGIN SELECT RAISE(ABORT, 'fixture store failure'); END;`);
|
||||
f.store.run(`CREATE TEMP TRIGGER fail_refusal_log BEFORE INSERT ON events
|
||||
WHEN NEW.kind='action.refused' BEGIN SELECT RAISE(ABORT, 'fixture evidence failure'); END;`);
|
||||
await assert.rejects(f.call('bean', 'library.doc.write', { ...args(), base: git(bare.mirror, 'rev-parse', `served:${PATH}`) }),
|
||||
{ code: 'outcome-unknown' });
|
||||
assert.notEqual(bare.remoteHead(), head);
|
||||
assert.equal(await servedCommit(bare.mirror), previous);
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='bean'").n, 0);
|
||||
assert.deepEqual(f.logs, [{ event: 'library-notice-store-failed', code: 'outcome-unknown', business: 'acme',
|
||||
instance: 'bean', op: 'write', commit: bare.remoteHead() }]);
|
||||
});
|
||||
|
||||
|
||||
test('C2-02 + C2-05c: broker get blob feeds write/delete and new served lookups immediately', async t => {
|
||||
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
|
||||
f.library.lookup = createLibraryLookup({ dataRoot: bare.dataRoot,
|
||||
sync: context => sync({ ...context, dataRoot: bare.dataRoot }, { credential: bare.credential, command: bare.command }) });
|
||||
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.dataRoot },
|
||||
{ ...options, credential: { ...options.credential, file: bare.file }, command: bare.command });
|
||||
const query = { universe: 'harbor', path: PATH, scope: 'supporting' };
|
||||
const before = await f.call('bean', 'library.get', query);
|
||||
assert.equal(before.blob, git(bare.mirror, 'rev-parse', `served:${PATH}`));
|
||||
const saved = await f.call('bean', 'library.doc.write', { ...args(), base: before.blob, content: 'new served text\n' });
|
||||
const after = await f.call('bean', 'library.get', query);
|
||||
assert.equal(after.commit, saved.commit);
|
||||
assert.equal(after.blob, git(bare.mirror, 'rev-parse', `${saved.commit}:${PATH}`));
|
||||
assert.notEqual(after.blob, before.blob);
|
||||
assert.ok(after.lines.includes('new served text'));
|
||||
assert.equal(await f.call('bean', 'library.get', { universe: 'harbor', path: PATH }), null);
|
||||
await f.call('bean', 'library.doc.delete', { universe: 'harbor', path: PATH, base: after.blob });
|
||||
assert.equal(await f.call('bean', 'library.get', query), null);
|
||||
});
|
||||
@@ -160,7 +160,7 @@ thread run, and it configures the assistant through `assistants` (Assistant
|
||||
settings, below) and names instances through `set_name`. PRD 1.7 REQ-COS-3
|
||||
(row 194) grants it `role.hire` and `role.fire` within role: it hires and
|
||||
fires with a reason and no decision, through the broker's `instance.hire` and
|
||||
`instance.fire`, which the harness's `hire` and `fire` tools call. It has the same read-only tools, and its one credential reference is gitea, for the read grant the library mirror fetches with (row 285). The broker holds that token, never the agent. It is the only shipped role that holds `role.launch`, so
|
||||
`instance.fire`, which the harness's `hire` and `fire` tools call. It has no credentials and the same read-only tools. It is the only shipped role that holds `role.launch`, so
|
||||
it is the only role `launch.by` can name. The PM holds no `role.launch`
|
||||
(REQ-LAUNCH-1); it asks the chief of staff for a session.
|
||||
|
||||
@@ -171,7 +171,7 @@ use the shared, broker-backed organization writer. The instance names are the ow
|
||||
example file's:
|
||||
|
||||
```json
|
||||
"chief": { "definition": "chief-of-staff", "credentials": { "gitea": { "file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/chief-of-staff-gitea.token", "rotateBy": "YYYY-MM-DD" } }, "vars": { "model": "opus" } },
|
||||
"chief": { "definition": "chief-of-staff", "vars": { "model": "opus" } },
|
||||
"assistant": { "definition": "assistant", "holder": "mos", "vars": { "model": "opus" } }
|
||||
...
|
||||
"launch": { "by": "chief", "instances": ["coder", "reviewer"], "models": {
|
||||
@@ -180,10 +180,8 @@ example file's:
|
||||
}, "limits": { "models": { "anthropic": { "claude-opus-5-5": 4, "claude-sonnet-5-5": 4 } } } }
|
||||
```
|
||||
|
||||
Neither entry has a `tracker`. The chief's `credentials` must name exactly
|
||||
its definition's services, gitea alone, and the assistant has none. A
|
||||
`{ "store": true, "rotateBy": "YYYY-MM-DD" }` reference works as well as a
|
||||
file. Neither form creates an account, mints a token or grants anything. A session that launches needs a harness and a model, so give each
|
||||
Neither entry has a `tracker` or `credentials`, because neither role holds
|
||||
any. A session that launches needs a harness and a model, so give each
|
||||
instance `vars` (`harness`, `model`) as the PM has, unless the system's
|
||||
execution settings cover it. A `launch.by` that names a PM, an assistant or any other instance
|
||||
whose role lacks `role.launch` is refused. A file that still names the PM
|
||||
|
||||
@@ -113,12 +113,6 @@
|
||||
},
|
||||
"chief": {
|
||||
"definition": "chief-of-staff",
|
||||
"credentials": {
|
||||
"gitea": {
|
||||
"file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/chief-of-staff-gitea.token",
|
||||
"rotateBy": "YYYY-MM-DD"
|
||||
}
|
||||
},
|
||||
"vars": {
|
||||
"model": "opus"
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ function checkRoles(roles, file, rolesDir, business) {
|
||||
requireId(instance, `${where} instance name`);
|
||||
const at = `${where}.${instance}`;
|
||||
requireObject(entry, at);
|
||||
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish", "hosts"], at);
|
||||
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish", "hosts", "github"], at);
|
||||
requireId(entry.definition, `${at}.definition`);
|
||||
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition);
|
||||
definitions[entry.definition] = definition;
|
||||
@@ -137,7 +137,7 @@ function checkRoles(roles, file, rolesDir, business) {
|
||||
const services = definition.credentials.map((c) => c.service);
|
||||
if (entry.hosts !== undefined) refuse(`${at}.hosts ${HOSTS_MOVED}`);
|
||||
// A host.<id> key reference comes from the agent's grant record at bus start (r6 X4), never from here.
|
||||
const kinds = services.filter((s) => s !== HOST_SERVICE);
|
||||
const kinds = services.filter((s) => s !== HOST_SERVICE && s !== "github");
|
||||
let tracker = null;
|
||||
if (services.includes("vikunja")) {
|
||||
if (entry.tracker === undefined) refuse(`${at} needs "tracker" with its Vikunja bot, because ${entry.definition} uses vikunja`);
|
||||
@@ -166,6 +166,14 @@ function checkRoles(roles, file, rolesDir, business) {
|
||||
if (!out[instance].identity || !services.includes("gitea")) refuse(`${at}.publish needs identity and a gitea definition`);
|
||||
out[instance].publish = requireDistinctList(entry.publish, `${at}.publish`, (p) => requireId(p, `${at}.publish entry`), { nonEmpty: true });
|
||||
}
|
||||
// Row297: App grants have fixed scopes and protected token storage, not a generic credential ref.
|
||||
if (entry.github !== undefined) {
|
||||
if (!services.includes("github")) refuse(`${at}.github needs a github definition`);
|
||||
requireObject(entry.github, `${at}.github`);
|
||||
rejectUnknownKeys(entry.github, ["projects"], `${at}.github`);
|
||||
out[instance].github = { projects: requireDistinctList(entry.github.projects, `${at}.github.projects`,
|
||||
(p) => requireId(p, `${at}.github.projects entry`), { nonEmpty: true }) };
|
||||
}
|
||||
checkPersonaVars(out[instance].vars, entry.definition, at);
|
||||
}
|
||||
return { roles: out, definitions };
|
||||
@@ -267,7 +275,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
|
||||
projects[id] = checkMapping(entry, `${file} projects.${id}`, { fail: (code, message) => refuse(`${code}: ${message}`) });
|
||||
continue;
|
||||
}
|
||||
rejectUnknownKeys(entry, ["root", "repo", "remote"], `${file} projects.${id}`);
|
||||
rejectUnknownKeys(entry, ["root", "repo", "remote", "githubId"], `${file} projects.${id}`);
|
||||
if (typeof entry.root !== "string" || !isAbsolute(entry.root) || normalize(entry.root) !== entry.root || entry.root.includes("\0")) {
|
||||
refuse(`${file} projects.${id}.root must be a normalized absolute path`);
|
||||
}
|
||||
@@ -277,10 +285,16 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
|
||||
}
|
||||
// Row 199 (#1697, design D9 step 2): the upstream; a granted agent's work goes to its fork.
|
||||
if (Object.hasOwn(entry, "remote")) httpsUrl(entry.remote, `${file} projects.${id}.remote`, { repo: true });
|
||||
projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}),
|
||||
if (entry.githubId !== undefined) requirePositiveInt(entry.githubId, `${file} projects.${id}.githubId`);
|
||||
projects[id] = { root: entry.root, ...(entry.githubId !== undefined ? { githubId: entry.githubId } : {}), ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}),
|
||||
...(Object.hasOwn(entry, "remote") ? { remote: entry.remote } : {}) };
|
||||
}
|
||||
for (const [instance, role] of Object.entries(roles)) {
|
||||
for (const project of role.github?.projects ?? []) {
|
||||
const p = projects[project];
|
||||
if (!p || p.kind === 'library' || !p.remote || !Number.isSafeInteger(p.githubId) || p.githubId <= 0)
|
||||
refuse(`${file} roles.${instance}.github names ${project}, which needs a repository remote and githubId`);
|
||||
}
|
||||
for (const project of role.publish ?? []) {
|
||||
if (!Object.hasOwn(projects, project)) refuse(`${file} roles.${instance}.publish names ${project}, which the business file doesn't declare`);
|
||||
if (projects[project].kind === 'library') refuse(`${file} roles.${instance}.publish names library project ${project}; library repositories cannot be published by agents`);
|
||||
|
||||
@@ -108,7 +108,7 @@ function definitionFor(rolesDir, name, { credentials = false } = {}) {
|
||||
// Vikunja bot the human names. The add makes no Vikunja account. Any other definition refuses it.
|
||||
function createdCredentials(old, instance, definition, args) {
|
||||
// webby-0094 N2: a host grant is no token reference (business.mjs checkRoles drops it too).
|
||||
const services = definition.credentials.map(c => c.service).filter(service => service !== HOST_SERVICE);
|
||||
const services = definition.credentials.map(c => c.service).filter(service => service !== HOST_SERVICE && service !== "github");
|
||||
const out = {};
|
||||
if (services.includes("vikunja")) {
|
||||
if (!Object.hasOwn(args, "tracker")) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { basename, dirname, join } from "node:path";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import {
|
||||
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, HOST_SERVICE, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
||||
CREATIVE_DEFINITIONS, CREATIVE_TOOLS, CREATIVE_ACTIONS, CREATIVE_SERVICE, unhireableAction,
|
||||
CREATIVE_DEFINITIONS, CREATIVE_TOOLS, unhireableAction,
|
||||
} from "./vocabulary.mjs";
|
||||
import {
|
||||
requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
||||
@@ -131,13 +131,15 @@ function checkCredentials(credentials) {
|
||||
requireObject(entry, `role credentials[${i}]`);
|
||||
// Row 210 (#1707, design D1): "host" takes no scopes; the business file names the hosts.
|
||||
const host = entry.service === HOST_SERVICE;
|
||||
rejectUnknownKeys(entry, host ? ["service"] : ["service", "scopes"], `role credentials[${i}]`);
|
||||
if (!host && !SERVICES.includes(entry.service)) {
|
||||
refuse(`role credentials[${i}].service must be one of: ${[...SERVICES, HOST_SERVICE].join(", ")}`);
|
||||
const github = entry.service === "github";
|
||||
rejectUnknownKeys(entry, (host || github) ? ["service"] : ["service", "scopes"], `role credentials[${i}]`);
|
||||
if (!host && !github && !SERVICES.includes(entry.service)) {
|
||||
refuse(`role credentials[${i}].service must be one of: ${[...SERVICES, HOST_SERVICE, "github"].join(", ")}`);
|
||||
}
|
||||
if (seen.has(entry.service)) refuse(`role credentials name ${entry.service} twice`);
|
||||
seen.add(entry.service);
|
||||
if (host) return { service: HOST_SERVICE };
|
||||
if (github) return { service: "github" };
|
||||
const scopes = entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes);
|
||||
return { service: entry.service, scopes };
|
||||
});
|
||||
@@ -159,17 +161,15 @@ function checkContract(contract, file) {
|
||||
}
|
||||
|
||||
// Row 213 (C2-08, Case 2 design 4, M1): a creative role only reads and answers the human. Its file
|
||||
// may name no tool outside CREATIVE_TOOLS, and must carry a messaging key that names only the human,
|
||||
// so it can't relay through another role. Row 285 (C2-08b, mos-1309 item 5) lets it write supporting
|
||||
// docs: its authority is withinRole CREATIVE_ACTIONS at most, and its one credential is gitea.
|
||||
// may name no tool outside CREATIVE_TOOLS, no authority but message.send, and must carry a messaging
|
||||
// key that names only the human, so it can't relay through another role.
|
||||
function checkCreative(role) {
|
||||
const wider = role.tools.filter((tool) => !CREATIVE_TOOLS.includes(tool));
|
||||
if (wider.length > 0) refuse(`creative role ${role.name} may not name tools ${wider.join(", ")} (allowed: ${CREATIVE_TOOLS.join(", ")})`);
|
||||
if (role.authority.crossRole.length > 0 || role.authority.withinRole.some((a) => !CREATIVE_ACTIONS.includes(a))) {
|
||||
refuse(`creative role ${role.name} may hold only withinRole ${CREATIVE_ACTIONS.join(" and ")}`);
|
||||
const actions = [...role.authority.withinRole, ...role.authority.crossRole];
|
||||
if (role.authority.crossRole.length > 0 || actions.some((a) => a !== "message.send")) {
|
||||
refuse(`creative role ${role.name} may hold only withinRole message.send`);
|
||||
}
|
||||
const services = role.credentials.map((c) => c.service).filter((service) => service !== CREATIVE_SERVICE);
|
||||
if (services.length > 0) refuse(`creative role ${role.name} may hold only the ${CREATIVE_SERVICE} credential (got ${services.join(", ")})`);
|
||||
if (role.messaging === null) refuse(`creative role ${role.name} requires "messaging"`);
|
||||
for (const key of ["sendTo", "receiveFrom"]) {
|
||||
if (role.messaging[key].some((entry) => entry !== "human")) {
|
||||
|
||||
@@ -57,10 +57,6 @@ export const ACTIONS = Object.freeze([
|
||||
"policy.change",
|
||||
"prd.approve",
|
||||
"decision.resolve.technical",
|
||||
// Row 285 (C2-08b, #1790): a creative agent writes a supporting Markdown file in the human's library.
|
||||
// Added as a hard wait and routed under PRD 1.16 REQ-WAIT-3 and REQ-LIB-6; role.mjs grants it within-role
|
||||
// to the creative roles only.
|
||||
"library.write",
|
||||
]);
|
||||
|
||||
// The actions that are not hard waits. PRD 1.5 REQ-WAIT-3: an action added
|
||||
@@ -95,9 +91,6 @@ export const ROUTED = Object.freeze([
|
||||
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
|
||||
"role.hire",
|
||||
"role.fire",
|
||||
// PRD 1.16 REQ-WAIT-3 and REQ-LIB-6 (row 285, #1790): a creative agent's library write, within the
|
||||
// bounds that paragraph names.
|
||||
"library.write",
|
||||
]);
|
||||
|
||||
// The hard waits (PRD 1.5 REQ-WAIT-1): always gated, always the human's,
|
||||
@@ -209,13 +202,18 @@ export const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
// Row 155 (#1649) moves it here from the broker, so the organization writer reads the same name.
|
||||
export const THREAD_DEFINITION = "assistant";
|
||||
|
||||
// Row 213 (C2-08, #1714, Case 2 design 4): the creative roles. Each reads and answers the human, and
|
||||
// row 285 (C2-08b, #1790) lets it write supporting docs; its instances are the human's alone (M10).
|
||||
// Row 213 (C2-08, #1714, Case 2 design 4): the creative roles. Each only reads and answers the human
|
||||
// in cited items; its instances are the human's alone (M10).
|
||||
export const CREATIVE_DEFINITIONS = Object.freeze(["companion", "ideas", "research", "proofreader", "fact-checker", "continuity"]);
|
||||
// Row 285 (mos-1309 item 5): the only authority a creative role file may name, all withinRole, and
|
||||
// the one credential service it may name, the definition credential the Gitea grant path reads.
|
||||
export const CREATIVE_ACTIONS = Object.freeze(["message.send", "library.write"]);
|
||||
export const CREATIVE_SERVICE = "gitea";
|
||||
// The reply item kinds each creative definition may send (design 4, 5.2's reply-kind check).
|
||||
export const CREATIVE_KINDS = Object.freeze({
|
||||
companion: Object.freeze(["idea", "question", "finding", "fact"]),
|
||||
ideas: Object.freeze(["idea", "question"]),
|
||||
research: Object.freeze(["fact", "question"]),
|
||||
proofreader: Object.freeze(["correction", "question"]),
|
||||
"fact-checker": Object.freeze(["finding", "question"]),
|
||||
continuity: Object.freeze(["finding", "question"]),
|
||||
});
|
||||
// The only tools a creative role file may name (M1): reading, and the broker's web grant.
|
||||
export const CREATIVE_TOOLS = Object.freeze(["read", "grep", "find", "ls", "web"]);
|
||||
// The definitions that may hold a thread: the assistant and the creative roles. The definition the
|
||||
|
||||
@@ -46,21 +46,8 @@ test("a granted agent loads with its identity, grant and the project's upstream"
|
||||
|
||||
test("N10: publish needs identity and a gitea definition, and the error names the grant", () => {
|
||||
refuses(() => check((d) => { delete d.roles.coder.identity; }), /roles\.coder\.publish needs identity and a gitea definition/);
|
||||
// Row 285 (mos-1333 item 3): the chief's definition now declares gitea, so this case names an
|
||||
// instance whose definition has none.
|
||||
refuses(() => check((d) => { d.roles.helper = { definition: "assistant", holder: "mos", identity: structuredClone(IDENTITY), publish: ["stack"] }; }),
|
||||
/roles\.helper\.publish needs identity and a gitea definition/);
|
||||
});
|
||||
|
||||
// Row 285 (mos-1333 item 4, brief :1829): the chief's grant needs identity and publish on the
|
||||
// instance, so a chief with its gitea reference loads with both. X-G1 (gitea-grant.test) shows the
|
||||
// same chief refuses at raise.
|
||||
test("row 285: a chief with its gitea reference, identity and publish loads", () => {
|
||||
const identity = { name: "Chief", email: "[email protected]", gitea: { url: "https://git.example", user: "acme-chief" } };
|
||||
const business = check((d) => { d.roles.chief.identity = structuredClone(identity); d.roles.chief.publish = ["stack"]; });
|
||||
assert.deepEqual({ ...business.roles.chief.identity, gitea: { ...business.roles.chief.identity.gitea } }, identity);
|
||||
assert.deepEqual([...business.roles.chief.publish], ["stack"]);
|
||||
assert.deepEqual(Object.keys(business.roles.chief.credentials), ["gitea"]);
|
||||
refuses(() => check((d) => { d.roles.chief.identity = structuredClone(IDENTITY); d.roles.chief.publish = ["stack"]; }),
|
||||
/roles\.chief\.publish needs identity and a gitea definition/);
|
||||
});
|
||||
|
||||
test("publish names declared projects that have a remote, each once", () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { BusinessError, businessFilePath, configDir, loadBusiness, validateBusinessDocument } from "../src/index.mjs";
|
||||
import { THREAD_IDLE_SECONDS } from "../src/business.mjs";
|
||||
import { CREATIVE_DEFINITIONS } from "../src/vocabulary.mjs";
|
||||
import { launchDoc, businessDoc, REPO, REPO_ROLES, rolesCopy, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
test('C2-04: library mappings load without roots, remain frozen, and retain mapping refusals', async t => {
|
||||
@@ -28,10 +27,6 @@ test('C2-04: library mappings load without roots, remain frozen, and retain mapp
|
||||
assert.deepEqual(validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }).projects.books.universes.harbor.playground, ['Elsewhere/Playground/']);
|
||||
doc.projects.books.universes.harbor.playground = ['Second World/Playground/'];
|
||||
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-playground-other-universe/);
|
||||
// C2-01d: a supporting prefix inside another universe's book refuses with its own code.
|
||||
doc.projects.books = mappingEntry();
|
||||
doc.projects.books.universes.harbor.supporting = ['Second World/Notes/'];
|
||||
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-supporting-other-universe/);
|
||||
doc.projects.books = { ...mappingEntry(), root: '/forbidden' };
|
||||
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-mapping-invalid/);
|
||||
});
|
||||
@@ -274,56 +269,28 @@ test("the shipped example refuses as written and validates once filled in", () =
|
||||
assert.equal(id, 15);
|
||||
});
|
||||
|
||||
// Row 285 (C2-08b, #1790, mos-1330): a creative definition declares gitea, so its instance names
|
||||
// the reference. The store form needs no token file, and the bus reads a missing one as no token.
|
||||
const CREATIVE_GITEA = Object.freeze({ gitea: Object.freeze({ store: true, rotateBy: "2099-01-01" }) });
|
||||
|
||||
// Row 213 (C2-08, #1714), deviation D1: the persona vars sit on a creative instance only, and
|
||||
// both or neither. The var checks themselves are vars.mjs's.
|
||||
test("persona vars: agent layer, creative definition only, both or neither", () => {
|
||||
const sha = "a".repeat(64);
|
||||
const persona = { "persona.name": "Bean", "persona.sha256": sha };
|
||||
const business = check((d) => { d.roles.bean = { definition: "companion", credentials: CREATIVE_GITEA, vars: { ...persona } }; });
|
||||
const business = check((d) => { d.roles.bean = { definition: "companion", vars: { ...persona } }; });
|
||||
assert.deepEqual({ ...business.roles.bean.vars }, persona);
|
||||
assert.doesNotThrow(() => check((d) => { d.roles.bean = { definition: "ideas", credentials: CREATIVE_GITEA }; }));
|
||||
assert.doesNotThrow(() => check((d) => { d.roles.bean = { definition: "ideas" }; }));
|
||||
refuses(() => check((d) => { d.vars["persona.name"] = "Bean"; }), /persona\.name can't be set at the business layer/);
|
||||
refuses(() => check((d) => { d.roles.pm.vars = { ...d.roles.pm.vars, ...persona }; }),
|
||||
/roles\.pm\.vars: persona vars need a creative definition \(pm isn't one\)/);
|
||||
refuses(() => check((d) => { d.roles.chief.vars = { ...persona }; }), /chief-of-staff isn't one/);
|
||||
for (const key of Object.keys(persona)) {
|
||||
refuses(() => check((d) => { d.roles.bean = { definition: "research", credentials: CREATIVE_GITEA, vars: { [key]: persona[key] } }; }),
|
||||
refuses(() => check((d) => { d.roles.bean = { definition: "research", vars: { [key]: persona[key] } }; }),
|
||||
/roles\.bean\.vars: persona\.name and persona\.sha256 are set together or not at all/);
|
||||
}
|
||||
for (const [key, value] of [["persona.name", "x".repeat(41)], ["persona.name", "Be\nan"], ["persona.name", "Be\u0007an"], ["persona.name", "Be\u2028an"], ["persona.name", "Be\u2029an"], ["persona.name", 7],
|
||||
["persona.sha256", "A".repeat(64)], ["persona.sha256", sha.slice(1)], ["persona.sha256", `${sha}0`]]) {
|
||||
refuses(() => check((d) => { d.roles.bean = { definition: "companion", credentials: CREATIVE_GITEA, vars: { ...persona, [key]: value } }; }), new RegExp(`variable ${key.replace(".", "\\.")}`), 2);
|
||||
refuses(() => check((d) => { d.roles.bean = { definition: "companion", vars: { ...persona, [key]: value } }; }), new RegExp(`variable ${key.replace(".", "\\.")}`), 2);
|
||||
}
|
||||
});
|
||||
|
||||
// Row 285 (C2-08b, #1790, mos-1330): the chief and each creative definition declare gitea, so an
|
||||
// instance without the reference refuses at load with the credential map's message.
|
||||
test("row 285: a chief or creative instance without credentials.gitea refuses at load", () => {
|
||||
refuses(() => check((d) => { delete d.roles.chief.credentials; }),
|
||||
/roles\.chief\.credentials must reference exactly the services its role definition needs \(gitea; got none\)/);
|
||||
for (const definition of CREATIVE_DEFINITIONS) {
|
||||
refuses(() => check((d) => { d.roles.bean = { definition }; }),
|
||||
/roles\.bean\.credentials must reference exactly the services its role definition needs \(gitea; got none\)/);
|
||||
assert.doesNotThrow(() => check((d) => { d.roles.bean = { definition, credentials: CREATIVE_GITEA }; }), definition);
|
||||
}
|
||||
});
|
||||
|
||||
// Row 285 (C2-08b, brief): a creative instance may carry identity and publish, under the rules at
|
||||
// business.mjs:155-166, which the grant path reads. The grant itself still refuses it (#giteaRole).
|
||||
test("row 285: a creative instance with identity and publish loads", () => {
|
||||
const identity = { name: "Bean", email: "[email protected]", gitea: { url: "https://git.example", user: "acme-bean" } };
|
||||
const business = check((d) => {
|
||||
d.projects.stack.remote = "https://git.example/acme/stack";
|
||||
d.roles.bean = { definition: "companion", credentials: CREATIVE_GITEA, identity, publish: ["stack"] };
|
||||
});
|
||||
assert.deepEqual({ ...business.roles.bean.identity, gitea: { ...business.roles.bean.identity.gitea } }, identity);
|
||||
assert.deepEqual([...business.roles.bean.publish], ["stack"]);
|
||||
});
|
||||
|
||||
// Row 194 (#1692, D2.5, T17): the per-agent forbid and the retired list.
|
||||
test("row 194 keep, noHire and retired validate, and a file without them loads as before", () => {
|
||||
const plain = check();
|
||||
|
||||
@@ -10,7 +10,7 @@ import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ACTIONS, BusinessError, classify, loadBusiness, loadRole, resolveInstance } from "../src/index.mjs";
|
||||
import { THREAD_IDLE_SECONDS } from "../src/business.mjs";
|
||||
import { businessDoc, FAR_DATE, launchDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
import { businessDoc, launchDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
// Decision 104's list plus role.launch (decision 139) plus role.end and
|
||||
// organization.change (row 123, REQ-COS-1) and record.write (row 138), plus instance.name
|
||||
@@ -20,8 +20,7 @@ const APPROVED = Object.freeze({
|
||||
tools: ["read", "grep", "find", "ls"],
|
||||
network: "api-only",
|
||||
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name", "role.hire", "role.fire"]), crossRole: Object.freeze([]) }),
|
||||
// Row 285 (#1790, mos-1309 item 5, sage-2915): gitea for the library read grant, read scopes only.
|
||||
credentials: Object.freeze([Object.freeze({ service: "gitea", scopes: Object.freeze(["read:repository", "read:user"]) })]),
|
||||
credentials: Object.freeze([]),
|
||||
// Row 194 (#1692, Q4, sage-1374): empty until a policy commit after the PRD 1.7 resolve.
|
||||
hireable: Object.freeze([]),
|
||||
});
|
||||
@@ -80,17 +79,13 @@ test("the shipped file is exactly the approved list", () => {
|
||||
});
|
||||
|
||||
test("the resolved record matches the approved list, so a widened file fails", () => {
|
||||
const fixture = loadFixture();
|
||||
const record = resolved(fixture);
|
||||
const record = resolved(loadFixture());
|
||||
assert.deepEqual(record.limits, {
|
||||
tools: APPROVED.tools,
|
||||
network: APPROVED.network,
|
||||
authority: APPROVED.authority,
|
||||
});
|
||||
// Row 285 (mos-1330): the fixture's gitea reference, the chief's only one.
|
||||
assert.deepEqual(record.credentials, {
|
||||
gitea: { service: "gitea", file: join(fixture.root, "secrets", "chief-of-staff-gitea.token"), rotateBy: FAR_DATE },
|
||||
});
|
||||
assert.deepEqual(record.credentials, {});
|
||||
});
|
||||
|
||||
test("the chief of staff holds role.launch, message.send, role.end, organization.change, record.write, instance.name, role.hire and role.fire; every other action is gated", () => {
|
||||
@@ -154,13 +149,12 @@ test("the contract names no duty to talk to the human and the pm contract no lau
|
||||
assert.match(pm, /You don't launch sessions/);
|
||||
});
|
||||
|
||||
// Row 285 (mos-1330): the instance references exactly gitea, its definition's one service, and no tracker.
|
||||
test("the instance takes only the gitea credential reference and no tracker", () => {
|
||||
test("the instance takes no credential references and no tracker", () => {
|
||||
refuses(
|
||||
() => loadFixture((doc) => {
|
||||
doc.roles[INSTANCE].credentials.vikunja = { file: "/x/token", expires: "2099-01-01" };
|
||||
doc.roles[INSTANCE].credentials = { gitea: { file: "/x/token", rotateBy: "2099-01-01" } };
|
||||
}),
|
||||
/must reference exactly the services its role definition needs \(gitea; got gitea, vikunja\)/,
|
||||
/must reference exactly the services its role definition needs \(none; got gitea\)/,
|
||||
);
|
||||
refuses(
|
||||
() => loadFixture((doc) => {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { loadRole, validateBusinessDocument } from '../src/index.mjs';
|
||||
import { instanceSettings } from '../src/instance-settings.mjs';
|
||||
import { businessDoc, rolesCopy, systemFor, tmp, writeJson } from './helpers.mjs';
|
||||
|
||||
function fixture(t) {
|
||||
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const file = join(rolesDir, 'coder.json');
|
||||
const definition = JSON.parse(readFileSync(file, 'utf8'));
|
||||
definition.credentials.push({ service: 'github' });
|
||||
writeJson(file, definition);
|
||||
const doc = businessDoc(root);
|
||||
doc.roles.coder.github = { projects: ['stack'] };
|
||||
doc.projects.stack.remote = 'https://github.com/acme/stack';
|
||||
doc.projects.stack.githubId = 101;
|
||||
const validate = () => validateBusinessDocument(doc, join(root, 'businesses/acme.json'), { rolesDir });
|
||||
return { root, rolesDir, file, definition, doc, validate };
|
||||
}
|
||||
|
||||
test('GitHub fixed declaration and repository binding load without generic token references', (t) => {
|
||||
const f = fixture(t);
|
||||
assert.deepEqual(loadRole(f.rolesDir, 'coder').credentials.at(-1), { service: 'github' });
|
||||
const b = f.validate();
|
||||
assert.deepEqual(b.roles.coder.github, { projects: ['stack'] });
|
||||
assert.equal(b.projects.stack.githubId, 101);
|
||||
assert.equal(Object.hasOwn(b.roles.coder.credentials, 'github'), false);
|
||||
});
|
||||
|
||||
test('GitHub declaration refuses caller scopes and role account/fork requests', (t) => {
|
||||
const f = fixture(t);
|
||||
f.definition.credentials.at(-1).scopes = ['all']; writeJson(f.file, f.definition);
|
||||
assert.throws(() => loadRole(f.rolesDir, 'coder'), /unsupported.*scopes/);
|
||||
delete f.definition.credentials.at(-1).scopes; writeJson(f.file, f.definition);
|
||||
for (const field of ['account', 'fork']) {
|
||||
f.doc.roles.coder.github[field] = true;
|
||||
assert.throws(f.validate, new RegExp('unsupported.*' + field));
|
||||
delete f.doc.roles.coder.github[field];
|
||||
}
|
||||
});
|
||||
|
||||
test('GitHub bindings refuse absent declaration, unknown project and missing repository ID', (t) => {
|
||||
const f = fixture(t);
|
||||
delete f.doc.projects.stack.githubId;
|
||||
assert.throws(f.validate, /githubId/);
|
||||
f.doc.projects.stack.githubId = 101;
|
||||
f.doc.roles.coder.github.projects = ['missing'];
|
||||
assert.throws(f.validate, /github.*missing/);
|
||||
f.doc.roles.coder.github.projects = ['stack'];
|
||||
f.definition.credentials.pop(); writeJson(f.file, f.definition);
|
||||
assert.throws(f.validate, /github.*definition/);
|
||||
});
|
||||
|
||||
// mos-1518: preserve closed-service-list coverage when github becomes a fixed declaration.
|
||||
test('a service outside the closed list still refuses with service must be one of', (t) => {
|
||||
const f = fixture(t); f.definition.credentials.at(-1).service = 'not-a-service';
|
||||
writeJson(f.file, f.definition);
|
||||
assert.throws(() => loadRole(f.rolesDir, 'coder'), /service must be one of/);
|
||||
});
|
||||
|
||||
test('human add of a GitHub-declaring definition creates only generic service references', (t) => {
|
||||
const f = fixture(t), dir = join(f.root, 'config');
|
||||
const file = writeJson(join(dir, 'businesses/acme.json'), f.doc);
|
||||
const settings = instanceSettings({ dir, rolesDir: f.rolesDir, system: systemFor(f.root) });
|
||||
settings.set('acme', { revision: settings.read('acme').revision, instance: 'coder-two', op: 'add', definition: 'coder',
|
||||
tracker: { bot: 'bot-acme-coder-two', botId: 40 } }, 'jason');
|
||||
const entry = JSON.parse(readFileSync(file, 'utf8')).roles['coder-two'];
|
||||
assert.deepEqual(Object.keys(entry.credentials).sort(), ['gitea', 'vikunja']);
|
||||
assert.equal(Object.hasOwn(entry, 'github'), false);
|
||||
});
|
||||
@@ -38,13 +38,6 @@ test("REQ-COS-3: role.hire and role.fire are routed, not hard waits", () => {
|
||||
for (const action of ["role.hire", "role.fire"]) assert.ok(ROUTED.includes(action) && !GATED_ONLY.includes(action), action);
|
||||
});
|
||||
|
||||
// PRD 1.16 REQ-WAIT-3 and REQ-LIB-6 (row 285, #1790): library.write is in the vocabulary and routed.
|
||||
test("REQ-WAIT-3: library.write is routed under REQ-LIB-6 (1.16), not a hard wait", () => {
|
||||
assert.ok(ACTIONS.includes("library.write"));
|
||||
assert.ok(ROUTED.includes("library.write"));
|
||||
assert.ok(!GATED_ONLY.includes("library.write"));
|
||||
});
|
||||
|
||||
test("REQ-WAIT-3: every action is routed or a hard wait, never both, and the routed list names only actions", () => {
|
||||
for (const action of ROUTED) assert.ok(ACTIONS.includes(action), action);
|
||||
for (const action of ACTIONS) assert.notEqual(ROUTED.includes(action), GATED_ONLY.includes(action), action);
|
||||
|
||||
@@ -68,10 +68,9 @@ export function businessDoc(root, id = "acme") {
|
||||
cto: role("cto", 4, { holder: "darkwing", vars: { harness: "claude-code" } }),
|
||||
coder: role("coder", 5, { vars: { harness: "pi", "limits.network": "none" } }),
|
||||
reviewer: role("reviewer", 6),
|
||||
// The chief of staff launches (REQ-LAUNCH-1, decision 139); it has no
|
||||
// tracker bot. Its one credential reference is gitea, for the library
|
||||
// read grant (row 285, mos-1330).
|
||||
chief: { definition: "chief-of-staff", credentials: { gitea: ref("chief-of-staff", "gitea") } },
|
||||
// The chief of staff launches (REQ-LAUNCH-1, decision 139); it holds no
|
||||
// credential and no tracker bot.
|
||||
chief: { definition: "chief-of-staff" },
|
||||
},
|
||||
launch: launchDoc(),
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync }
|
||||
import { join } from "node:path";
|
||||
import { instanceSettings } from "../src/instance-settings.mjs";
|
||||
import { projectFilePath } from "../src/project.mjs";
|
||||
import { businessDoc, FAR_DATE, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
import { businessDoc, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
const CLI = join(REPO, "packages", "business", "src", "cli.mjs");
|
||||
const roots = [];
|
||||
@@ -286,8 +286,7 @@ test("row 213 a creative instance is the human's: add, vars and definition chang
|
||||
writeFileSync(join(f.root, "config", "personas", "acme", "bean.md"), text, { mode: 0o600 });
|
||||
const persona = { "persona.name": "Bean", "persona.sha256": createHash("sha256").update(text).digest("hex") };
|
||||
const view = f.settings.read("acme");
|
||||
// Row 285 (mos-1332 item 4): the companion definition declares gitea.
|
||||
assert.deepEqual(view.definitions.companion, { services: ["gitea"], accepted: true });
|
||||
assert.deepEqual(view.definitions.companion, { services: [], accepted: true });
|
||||
const add = { instance: "bean", op: "add", definition: "companion", vars: persona };
|
||||
for (const actor of ["chief-of-staff.chief", "tester"]) {
|
||||
assert.throws(() => f.settings.set("acme", { revision: view.revision, ...add }, actor), e => e.code === "organization-creative-human-only", actor);
|
||||
@@ -332,8 +331,8 @@ test("row 213 a creative instance is the human's: add, vars and definition chang
|
||||
// every actor, the human included. The listing's endProtected is that same check for its reader.
|
||||
test("row 256 T1-T3, T5 the human deletes a creative instance, no other actor does, and the assistant stays", () => {
|
||||
const f = fixture(doc => {
|
||||
doc.roles.bean = { definition: "companion", credentials: { gitea: { store: true, rotateBy: FAR_DATE } } };
|
||||
doc.roles.muse = { definition: "ideas", credentials: { gitea: { store: true, rotateBy: FAR_DATE } } };
|
||||
doc.roles.bean = { definition: "companion" };
|
||||
doc.roles.muse = { definition: "ideas" };
|
||||
doc.roles.assistant = { definition: "assistant", holder: "mos" };
|
||||
});
|
||||
const del = (instance, actor) => () => f.settings.set("acme", { revision: f.settings.read("acme").revision, instance, op: "delete" }, actor);
|
||||
@@ -378,7 +377,7 @@ test("row 256 T1-T3, T5 the human deletes a creative instance, no other actor do
|
||||
// instance can't be kept, launch.by or an arbiter, so the check it reaches is the last-listed one.
|
||||
test("row 256 T4 the human's delete of the only listed instance, a creative one, is the last-listed refusal", () => {
|
||||
const f = fixture(doc => {
|
||||
doc.roles.bean = { definition: "companion", credentials: { gitea: { store: true, rotateBy: FAR_DATE } } };
|
||||
doc.roles.bean = { definition: "companion" };
|
||||
doc.launch.instances = ["bean"];
|
||||
});
|
||||
const bytes = readFileSync(f.file, "utf8");
|
||||
|
||||
@@ -181,7 +181,7 @@ test('B7 kept instance: its route is the human\'s',async()=>{
|
||||
// can't reach its model through the models table, the instances key or a route; the human can,
|
||||
// except through the instances key, which refuses a creative instance for everyone.
|
||||
test('M10 creative instance: models keys it names, the instances key and its route are the human\'s',async()=>{
|
||||
const f=fixture();const doc=structuredClone(f.doc);doc.roles.bean={definition:'companion',credentials:{gitea:{store:true,rotateBy:FAR_DATE}},vars:{model:'sonnet'}};writeJson(f.file,doc);
|
||||
const f=fixture();const doc=structuredClone(f.doc);doc.roles.bean={definition:'companion',vars:{model:'sonnet'}};writeJson(f.file,doc);
|
||||
const g={...f,doc};const {settings}=await routeSettings(g);const file=()=>readFileSync(f.file,'utf8');
|
||||
const chief='chief-of-staff.chief';let {revision}=settings.read('acme');
|
||||
const models=structuredClone(settings.read('acme').models);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { instanceSettings } from "../src/instance-settings.mjs";
|
||||
import { CREATIVE_DEFINITIONS } from "../src/vocabulary.mjs";
|
||||
import { businessDoc, FAR_DATE, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
import { businessDoc, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
const CLI = join(REPO, "packages", "business", "src", "cli.mjs");
|
||||
const roots = [];
|
||||
@@ -234,7 +234,7 @@ test("D2.2 fire delists and keeps the entry, a repeat is done with no write, and
|
||||
// every actor's fire, the human's included.
|
||||
test("row 256 checkDelete passes the human's creative delete and refuses another actor's; fire still refuses", () => {
|
||||
const f = fixture(doc => {
|
||||
doc.roles.bean = { definition: "companion", credentials: { gitea: { store: true, rotateBy: FAR_DATE } } };
|
||||
doc.roles.bean = { definition: "companion" };
|
||||
doc.launch.instances.push("bean");
|
||||
});
|
||||
const bytes = readFileSync(f.file, "utf8");
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
||||
import {
|
||||
BusinessError, classify, projectFilePath, resolveInstance, systemVars, validateBusinessDocument, validateProjectDocument,
|
||||
} from "../src/index.mjs";
|
||||
import { businessDoc, FAR_DATE, REPO_ROLES, systemFor, tmp } from "./helpers.mjs";
|
||||
import { businessDoc, REPO_ROLES, systemFor, tmp } from "./helpers.mjs";
|
||||
|
||||
function refuses(fn, pattern) {
|
||||
assert.throws(fn, (error) => {
|
||||
@@ -114,7 +114,7 @@ test("role.launch stays within-role only for the instance the launch block names
|
||||
|
||||
// A second chief-of-staff instance is the launcher, so the first one may not launch.
|
||||
const two = setup((d) => {
|
||||
d.roles.chief2 = { definition: "chief-of-staff", credentials: { gitea: { store: true, rotateBy: FAR_DATE } } };
|
||||
d.roles.chief2 = { definition: "chief-of-staff" };
|
||||
d.launch.by = "chief2";
|
||||
});
|
||||
assert.equal(classify(resolve(two, "chief"), "role.launch"), "gated");
|
||||
|
||||
@@ -206,7 +206,7 @@ test("credentials: Vikunja scopes are a group-to-verbs map from the grantable li
|
||||
});
|
||||
|
||||
test("credentials: services", () => {
|
||||
refuses(() => check({ credentials: [{ service: "github", scopes: [] }] }), /service must be one of/);
|
||||
refuses(() => check({ credentials: [{ service: "github", scopes: [] }] }), /unsupported.*scopes/);
|
||||
refuses(() => check({ credentials: [{ service: "gitea", scopes: ["read:issue"] }, { service: "gitea", scopes: ["read:user"] }] }), /twice/);
|
||||
refuses(() => check({ credentials: [{ service: "gitea", scopes: ["read:issue"], token: "x" }] }), /unsupported role credentials\[0\] key/);
|
||||
assert.deepEqual(check({ credentials: [] }).credentials, []);
|
||||
@@ -226,16 +226,14 @@ test("contract: a non-empty regular Markdown file beside the role file", () => {
|
||||
// Row 213 (C2-08, #1714, M1): a creative role reads and answers the human, and nothing else.
|
||||
const CREATIVE = { companion: ["read", "web"], ideas: ["read"], research: ["read", "web"], proofreader: ["read"], "fact-checker": ["read"], continuity: ["read"] };
|
||||
|
||||
// Row 285 (C2-08b, #1790, mos-1309 item 5): each also holds library.write and the gitea credential, whose
|
||||
// scopes equal what the Gitea grant path mints (gitea-grant.mjs SEAT_SCOPES, sage-2915).
|
||||
test("the six creative roles load: read tools, message.send and library.write, gitea, messaging with the human only", () => {
|
||||
test("the six creative roles load: read tools, message.send only, messaging with the human only", () => {
|
||||
for (const [name, tools] of Object.entries(CREATIVE)) {
|
||||
const role = loadRole(REPO_ROLES, name);
|
||||
assert.equal(role.roleVersion, 2, name);
|
||||
assert.deepEqual(role.tools, tools, name);
|
||||
assert.equal(role.network, tools.includes("web") ? "api-only" : "none", name);
|
||||
assert.deepEqual(role.authority, { withinRole: ["message.send", "library.write"], crossRole: [] }, name);
|
||||
assert.deepEqual(role.credentials, [{ service: "gitea", scopes: ["read:user", "write:repository"] }], name);
|
||||
assert.deepEqual(role.authority, { withinRole: ["message.send"], crossRole: [] }, name);
|
||||
assert.deepEqual(role.credentials, [], name);
|
||||
assert.deepEqual(role.messaging, { sendTo: ["human"], receiveFrom: ["human"] }, name);
|
||||
}
|
||||
});
|
||||
@@ -257,20 +255,8 @@ test("M1: a creative role file wider than read and answer is refused", () => {
|
||||
for (const tool of ["write", "edit", "bash"]) {
|
||||
refuses(creative("ideas", { tools: ["read", tool] }), new RegExp(`^creative role ideas may not name tools ${tool} `));
|
||||
}
|
||||
const gitea = [{ service: "gitea", scopes: ["read:user", "write:repository"] }];
|
||||
assert.doesNotThrow(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: gitea }));
|
||||
// Row 285: any other action, at either class, refuses, each routed action looped from the list.
|
||||
const only = /^creative role proofreader may hold only withinRole message\.send and library\.write$/;
|
||||
for (const action of ACTIONS.filter((a) => !GATED_ONLY.includes(a) && !["message.send", "library.write"].includes(a))) {
|
||||
refuses(creative("proofreader", { authority: { withinRole: ["message.send", "library.write", action], crossRole: [] } }), only);
|
||||
}
|
||||
refuses(creative("proofreader", { authority: { withinRole: [], crossRole: ["message.send"] } }), only);
|
||||
refuses(creative("proofreader", { authority: { withinRole: ["message.send"], crossRole: ["library.write"] } }), only);
|
||||
// Row 285: library.write with any credential but gitea refuses.
|
||||
refuses(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: [...gitea, { service: "vikunja", scopes: { tasks: ["read_one"] } }] }),
|
||||
/^creative role ideas may hold only the gitea credential \(got vikunja\)$/);
|
||||
refuses(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: [{ service: "host" }] }),
|
||||
/^creative role ideas may hold only the gitea credential \(got host\)$/);
|
||||
refuses(creative("proofreader", { authority: { withinRole: ["message.send", "record.write"], crossRole: [] } }), /^creative role proofreader may hold only withinRole message.send$/);
|
||||
refuses(creative("proofreader", { authority: { withinRole: [], crossRole: ["message.send"] } }), /^creative role proofreader may hold only withinRole message.send$/);
|
||||
refuses(creative("continuity", { messaging: undefined }), /^creative role continuity requires "messaging"$/);
|
||||
refuses(creative("research", { messaging: { sendTo: ["human", "pm"], receiveFrom: ["human"] } }), /^creative role research messaging.sendTo may name only human$/);
|
||||
refuses(creative("research", { messaging: { sendTo: ["human"], receiveFrom: ["assistant"] } }), /^creative role research messaging.receiveFrom may name only human$/);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Row297 CLI shapes. Only the broker parses/uses the RSA private key.
|
||||
import { CliError } from './errors.mjs';
|
||||
const USAGE = 'mosaic grants status|sweep github [--json] [--tester] | revoke AGENT github --reason R | admin set github --url HTTPS_ORIGIN --app-id ID --installation-id ID --account-id ID --rotate-by DATE | admin rotate github --rotate-by DATE | admin remove github';
|
||||
const SHAPES = {
|
||||
status: { n: 1, flags: ['--json', '--tester'], need: [] },
|
||||
sweep: { n: 1, flags: ['--tester'], need: [] },
|
||||
revoke: { n: 2, flags: ['--tester'], need: ['--reason'] },
|
||||
'admin set': { n: 1, flags: [], need: ['--url', '--app-id', '--installation-id', '--account-id', '--rotate-by'] },
|
||||
'admin rotate': { n: 1, flags: [], need: ['--rotate-by'] },
|
||||
'admin remove': { n: 1, flags: [], need: [] },
|
||||
};
|
||||
const usage = () => { throw new CliError(USAGE, 4); };
|
||||
async function stdinKey(stdin) {
|
||||
let text = '';
|
||||
for await (const chunk of stdin) { text += chunk; if (text.length > 16384) throw new CliError('refused: App key on stdin is too long', 2); }
|
||||
if (!text.trim()) throw new CliError('refused: no App key on stdin', 4);
|
||||
return text;
|
||||
}
|
||||
const positive = (text) => {
|
||||
if (typeof text !== 'string' || !/^[1-9][0-9]*$/.test(text) || !Number.isSafeInteger(Number(text))) usage();
|
||||
return Number(text);
|
||||
};
|
||||
function format(status) {
|
||||
const lines = [status.admin ? `admin: App ${status.admin.appId}, installation ${status.admin.installationId}, account ${status.admin.accountId} on ${status.admin.url}, rotate by ${status.admin.rotateBy}` : 'admin: none'];
|
||||
for (const g of status.grants) lines.push(`grant\t${g.agent}\t${g.projects.join(',')}\texpires ${g.expiresAt}\trotate by ${g.rotateBy}\t${g.decision}`);
|
||||
for (const g of status.serviceLeft) lines.push(`left\t${g.agent}\t${g.generation}\tuntil ${g.until}`);
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
export async function githubGrantsCommand(parsed, verb, rest, io, callFor) {
|
||||
const shape = Object.hasOwn(SHAPES, verb) ? SHAPES[verb] : null;
|
||||
const v = parsed.values;
|
||||
if (!shape || rest.length !== shape.n || [...parsed.flags].some((f) => !shape.flags.includes(f)) ||
|
||||
Object.keys(v).some((k) => k !== '--business' && !shape.need.includes(k)) || shape.need.some((k) => !Object.hasOwn(v, k))) usage();
|
||||
let args = { service: 'github' };
|
||||
if (verb === 'revoke') args = { ...args, agent: rest[0], reason: v['--reason'] };
|
||||
if (verb === 'admin set') args = { ...args, url: v['--url'], appId: positive(v['--app-id']), installationId: positive(v['--installation-id']), accountId: positive(v['--account-id']), rotateBy: v['--rotate-by'] };
|
||||
if (verb === 'admin rotate') args.rotateBy = v['--rotate-by'];
|
||||
if (verb === 'admin set' || verb === 'admin rotate') args.token = await stdinKey(io.stdin);
|
||||
try {
|
||||
const result = await callFor(parsed)('grants.' + verb.replace(' ', '.'), args);
|
||||
io.stdout.write(verb === 'status' && !parsed.flags.has('--json') ? format(result) : JSON.stringify(result, null, 2) + '\n');
|
||||
} catch (e) { if (e instanceof CliError) throw e; throw new CliError(e.message, 2); }
|
||||
}
|
||||
@@ -4,14 +4,16 @@
|
||||
// and sweep the human's or, with --tester, the tester's. admin set reads the admin token on stdin, never
|
||||
// argv, and nothing here prints a token: the answers carry ids and names only.
|
||||
import { CliError } from "./errors.mjs";
|
||||
import { githubGrantsCommand } from "./github-grants.mjs";
|
||||
|
||||
export const GRANTS_USAGE =
|
||||
"mosaic grants status gitea [--json] [--tester] [--business ID] | run DECISION [--business ID] | " +
|
||||
"revoke AGENT gitea --reason R [--tester] [--business ID] | sweep gitea [--tester] [--business ID] | " +
|
||||
"admin set gitea --url URL --account NAME --rotate-by DATE [--business ID] | " +
|
||||
"admin rotate gitea --rotate-by DATE | admin rotate gitea --finish | admin remove gitea";
|
||||
"admin rotate gitea --rotate-by DATE | admin rotate gitea --finish | admin remove gitea | " +
|
||||
"status|sweep github | revoke AGENT github --reason R | admin set github --url URL --app-id ID --installation-id ID --account-id ID --rotate-by DATE | admin rotate github --rotate-by DATE | admin remove github";
|
||||
const MAX_TOKEN = 16384;
|
||||
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by"];
|
||||
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by", "--app-id", "--installation-id", "--account-id"];
|
||||
|
||||
function parse(args) {
|
||||
const out = { positional: [], flags: new Set(), values: {} };
|
||||
@@ -72,6 +74,7 @@ export async function grantsCommand(args, io, callFor) {
|
||||
verb = `admin ${rest[0] ?? ""}`;
|
||||
rest = rest.slice(1);
|
||||
}
|
||||
if (verb !== "run" && rest.at(-1) === "github") return githubGrantsCommand(parsed, verb, rest, io, callFor);
|
||||
// Own keys only, so `constructor` is a usage error, not a verb.
|
||||
const shape = Object.hasOwn(SHAPES, verb ?? "") ? SHAPES[verb] : null;
|
||||
if (
|
||||
|
||||
@@ -528,7 +528,6 @@ export function createLauncher({
|
||||
contract: readFileSync(resolved.contract, "utf8"),
|
||||
arbiter: Object.values(b.arbiters).includes(instance),
|
||||
tracker,
|
||||
library: Object.values(b.projects ?? {}).some(project => project.kind === "library"),
|
||||
credentials,
|
||||
session: { run, launchedBy, workspace: inSandbox, toolSocket, turnMarker, checkouts: inSandbox === workspace ? checkouts : checkouts.map((c) => ({ ...c, path: join(inSandbox, relative(workspace, c.path)) })) },
|
||||
harness,
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
// Case 2 row C2-13b (#1727): scripts/case2-install.sh installs a hub as <user>, and
|
||||
// scripts/case2-precheck.sh's library checks and --library-status mode. Brief:
|
||||
// docs/plans/2026-10-20_case2-rows.md "C2-13b". Every case runs in a scratch home with its own
|
||||
// MOSAIC_CONFIG, a stub stack-copy and fake getent, sudo, id, node, bwrap and systemctl. The library
|
||||
// sync runs in-process against a local bare repo, as the bus would. Nothing here reads or writes
|
||||
// ~/.mosaic-dev, ~/.config/mosaic-dev, /etc or a network host, and no fixture value is a credential.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { businessDoc, writeJson } from "../../business/tests/helpers.mjs";
|
||||
import { BASE, MARKER, git, mappingEntry, repo } from "../../library/tests/helpers.mjs";
|
||||
import { ensureMirror, mirrorPath, sync } from "../../library/src/index.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { SAFE } from "../../project/src/git.mjs";
|
||||
import { tmp } from "./helpers.mjs";
|
||||
|
||||
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
const INSTALL = join(REPO, "scripts", "case2-install.sh");
|
||||
const PRECHECK = join(REPO, "scripts", "case2-precheck.sh");
|
||||
const COMMIT = "c".repeat(40);
|
||||
const UID = process.getuid();
|
||||
const GID = process.getgid();
|
||||
// Made-up values; no output may hold them.
|
||||
const FIXTURE_KEY = "fixture-zai-key-8d41a7";
|
||||
const PERSONA_TEXT = { bean: "Bean is warm and brief. PERSONA-TEXT-bean-61f0\n", chet: "Chet is dry and exact. PERSONA-TEXT-chet-2b9e\n" };
|
||||
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
||||
const mode = (p) => statSync(p).mode & 0o777;
|
||||
|
||||
function script(file, text) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, `#!/usr/bin/env bash\n${text}\n`);
|
||||
chmodSync(file, 0o755);
|
||||
return file;
|
||||
}
|
||||
|
||||
// A scratch <user>: a home, an input business file, a persona staging dir and fakes.
|
||||
function world(t, { profile = "library" } = {}) {
|
||||
const root = tmp(t, "case2-install-");
|
||||
const home = join(root, "home");
|
||||
const configDir = join(home, ".config", "mosaic-dev");
|
||||
// claude-session.env is in place before the install, as 2d would leave it on a re-run.
|
||||
const secrets = join(configDir, "secrets");
|
||||
mkdirSync(secrets, { recursive: true, mode: 0o700 });
|
||||
chmodSync(configDir, 0o700);
|
||||
writeFileSync(join(secrets, "claude-session.env"), `ZAI_API_KEY=${FIXTURE_KEY}\n`, { mode: 0o600 });
|
||||
const doc = businessDoc(root);
|
||||
for (const r of Object.values(doc.roles)) r.vars = { ...r.vars, harness: "pi" };
|
||||
if (profile === "library") {
|
||||
doc.projects.books = { ...mappingEntry(), user: "writer" };
|
||||
doc.roles.bean = { definition: "companion", vars: { harness: "pi" } };
|
||||
doc.roles.chet = { definition: "companion", vars: { harness: "pi" } };
|
||||
} else doc.vars["bus.tester"] = true;
|
||||
const input = writeJson(join(root, "input", "acme.json"), doc);
|
||||
const staging = join(root, "staging");
|
||||
mkdirSync(staging, { mode: 0o700 });
|
||||
const personas = [];
|
||||
for (const [instance, name] of [["bean", "Bean"], ["chet", "Chet"]]) {
|
||||
writeFileSync(join(staging, `${instance}.md`), PERSONA_TEXT[instance], { mode: 0o600 });
|
||||
personas.push({ instance, name, bytes: Buffer.byteLength(PERSONA_TEXT[instance]), sha256: sha256(PERSONA_TEXT[instance]) });
|
||||
}
|
||||
writeFileSync(join(staging, "manifest.json"), `${JSON.stringify({ version: 1, personas }, null, 2)}\n`, { mode: 0o600 });
|
||||
const bin = join(root, "bin");
|
||||
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"`);
|
||||
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'`);
|
||||
script(join(bin, "sudo"), "exit 1");
|
||||
script(join(bin, "id"), `if [ "$1" = -G ]; then echo '${GID}'; elif [ "$1" = -u ] && [ -n "\${CASE2_TEST_UID:-}" ]; then echo "$CASE2_TEST_UID"; else exec /usr/bin/id "$@"; fi`);
|
||||
script(join(bin, "node"), `if [ "$1" = --version ]; then echo v24.3.0; exit 0; fi\nexec '${process.execPath}' "$@"`);
|
||||
script(join(bin, "bwrap"), "exit 0");
|
||||
const units = join(root, "units");
|
||||
mkdirSync(units);
|
||||
writeFileSync(join(units, "mosaic-webui.service"), "[Service]\nExecStart=/usr/bin/node /x/cli.mjs serve --port 443 --business acme\n");
|
||||
writeFileSync(join(units, "[email protected]"), "[Service]\nEnvironment=PATH=/usr/bin\n");
|
||||
const sys = join(root, "sys");
|
||||
mkdirSync(join(sys, "net", "ipv4"), { recursive: true });
|
||||
mkdirSync(join(sys, "kernel"), { recursive: true });
|
||||
writeFileSync(join(sys, "net", "ipv4", "ip_unprivileged_port_start"), "1024\n");
|
||||
writeFileSync(join(sys, "kernel", "apparmor_restrict_unprivileged_userns"), "1\n");
|
||||
return {
|
||||
root, home, configDir, secrets, input, staging, bin, log, units, sys, doc,
|
||||
config: join(configDir, "config.json"),
|
||||
business: join(configDir, "businesses", "acme.json"),
|
||||
personaDir: join(configDir, "personas", "acme"),
|
||||
slot: join(secrets, "acme"),
|
||||
dataRoot: join(home, ".mosaic-dev"),
|
||||
};
|
||||
}
|
||||
|
||||
function env(x, extra = {}) {
|
||||
return { PATH: `${x.bin}:/usr/bin:/bin`, HOME: x.home, MOSAIC_CONFIG: x.config, CASE2_STACK_COPY: join(x.bin, "stack-copy"), ...extra };
|
||||
}
|
||||
|
||||
function clean(out) {
|
||||
assert.doesNotMatch(out, new RegExp(FIXTURE_KEY), "the output holds the fixture key");
|
||||
assert.doesNotMatch(out, /PERSONA-TEXT/, "the output holds persona text");
|
||||
assert.doesNotMatch(out, new RegExp(MARKER), "the output holds her text");
|
||||
}
|
||||
|
||||
function install(x, { profile = "library", args = null, extra = {} } = {}) {
|
||||
const argv = args ?? ["--business", "acme", "--business-file", x.input, "--commit", COMMIT, "--profile", profile,
|
||||
...(profile === "library" ? ["--personas", x.staging] : [])];
|
||||
const r = spawnSync(INSTALL, argv, { encoding: "utf8", timeout: 120000, env: env(x, extra) });
|
||||
const out = r.stdout + r.stderr;
|
||||
clean(out);
|
||||
return { code: r.status, out };
|
||||
}
|
||||
|
||||
function precheck(x, { args = ["--business", "acme", "--profile", "library"] } = {}) {
|
||||
const r = spawnSync(PRECHECK, args, {
|
||||
encoding: "utf8",
|
||||
timeout: 60000,
|
||||
env: env(x, { CASE2_UNIT_DIR: x.units, CASE2_PROC_SYS: x.sys, CASE2_GETENT: join(x.bin, "getent") }),
|
||||
});
|
||||
const out = r.stdout + r.stderr;
|
||||
clean(out);
|
||||
return { code: r.status, out };
|
||||
}
|
||||
const status = (x) => precheck(x, { args: ["--business", "acme", "--library-status"] });
|
||||
|
||||
function refused(r, pattern) {
|
||||
assert.equal(r.code, 1, r.out);
|
||||
assert.match(r.out, pattern);
|
||||
}
|
||||
|
||||
const calls = (x) => (existsSync(x.log) ? readFileSync(x.log, "utf8") : "");
|
||||
|
||||
// ---- case2-install.sh ----
|
||||
|
||||
test("the library install writes the business file, personas and slot dir, makes the copy and starts nothing", (t) => {
|
||||
const x = world(t);
|
||||
const r = install(x);
|
||||
assert.equal(r.code, 0, r.out);
|
||||
assert.equal(mode(x.business), 0o600);
|
||||
const doc = JSON.parse(readFileSync(x.business, "utf8"));
|
||||
for (const [instance, name] of [["bean", "Bean"], ["chet", "Chet"]]) {
|
||||
assert.equal(doc.roles[instance].vars["persona.name"], name);
|
||||
assert.equal(doc.roles[instance].vars["persona.sha256"], sha256(PERSONA_TEXT[instance]));
|
||||
assert.equal(doc.roles[instance].vars.harness, "pi");
|
||||
const file = join(x.personaDir, `${instance}.md`);
|
||||
assert.equal(readFileSync(file, "utf8"), PERSONA_TEXT[instance]);
|
||||
assert.equal(mode(file), 0o600);
|
||||
}
|
||||
assert.equal(mode(join(x.configDir, "personas")), 0o700);
|
||||
assert.equal(mode(x.personaDir), 0o700);
|
||||
assert.equal(mode(x.slot), 0o700);
|
||||
assert.deepEqual(readdirSync(x.slot), []);
|
||||
assert.equal(calls(x), `stack-copy copy ${COMMIT} --copies ${join(x.home, ".local", "share", "mosaic-stack", "copies")}\n`);
|
||||
assert.doesNotMatch(r.out, /release/);
|
||||
assert.match(r.out, /business acme validates/);
|
||||
assert.match(r.out, /--library-status/);
|
||||
assert.ok(existsSync(x.config));
|
||||
});
|
||||
|
||||
test("a library re-run passes and leaves every file as it was", (t) => {
|
||||
const x = world(t);
|
||||
assert.equal(install(x).code, 0);
|
||||
const before = readFileSync(x.business);
|
||||
const r = install(x);
|
||||
assert.equal(r.code, 0, r.out);
|
||||
assert.match(r.out, /already matches/);
|
||||
assert.match(r.out, /personas already installed/);
|
||||
assert.deepEqual(readFileSync(x.business), before);
|
||||
});
|
||||
|
||||
test("a re-run refuses business-file-differs and leaves the file unchanged", (t) => {
|
||||
const x = world(t);
|
||||
assert.equal(install(x).code, 0);
|
||||
const doc = JSON.parse(readFileSync(x.business, "utf8"));
|
||||
doc.vars["tracker.pollSeconds"] = 120;
|
||||
writeJson(x.business, doc);
|
||||
const before = readFileSync(x.business);
|
||||
refused(install(x), /business-file-differs/);
|
||||
assert.deepEqual(readFileSync(x.business), before);
|
||||
});
|
||||
|
||||
test("a re-run refuses persona-differs when an installed persona isn't the staged one, and writes nothing", (t) => {
|
||||
const x = world(t);
|
||||
assert.equal(install(x).code, 0);
|
||||
rmSync(x.business);
|
||||
writeFileSync(join(x.personaDir, "bean.md"), "Someone else. PERSONA-TEXT-other\n");
|
||||
refused(install(x), /persona-differs/);
|
||||
assert.equal(existsSync(x.business), false);
|
||||
});
|
||||
|
||||
test("the factory install writes no library mapping, persona or slot dir", (t) => {
|
||||
const x = world(t, { profile: "factory" });
|
||||
const r = install(x, { profile: "factory" });
|
||||
assert.equal(r.code, 0, r.out);
|
||||
const doc = JSON.parse(readFileSync(x.business, "utf8"));
|
||||
assert.equal(Object.values(doc.projects).some((p) => p.kind === "library"), false);
|
||||
assert.equal(existsSync(join(x.configDir, "personas")), false);
|
||||
assert.equal(existsSync(x.slot), false);
|
||||
assert.doesNotMatch(r.out, /--library-status/);
|
||||
});
|
||||
|
||||
test("the factory install refuses a library project and bus.tester off, before anything is written", (t) => {
|
||||
const x = world(t, { profile: "factory" });
|
||||
const doc = JSON.parse(readFileSync(x.input, "utf8"));
|
||||
doc.projects.books = { ...mappingEntry(), user: "writer" };
|
||||
writeJson(x.input, doc);
|
||||
refused(install(x, { profile: "factory" }), /profile-library-business/);
|
||||
delete doc.projects.books;
|
||||
doc.vars["bus.tester"] = false;
|
||||
writeJson(x.input, doc);
|
||||
refused(install(x, { profile: "factory" }), /bus-tester-off/);
|
||||
assert.equal(calls(x), "");
|
||||
assert.equal(existsSync(x.business), false);
|
||||
});
|
||||
|
||||
test("the library install refuses bus.tester on and a persona var in the input", (t) => {
|
||||
const x = world(t);
|
||||
const doc = JSON.parse(readFileSync(x.input, "utf8"));
|
||||
doc.vars["bus.tester"] = true;
|
||||
writeJson(x.input, doc);
|
||||
refused(install(x), /bus-tester-on/);
|
||||
doc.vars["bus.tester"] = false;
|
||||
doc.roles.bean.vars["persona.name"] = "Bean";
|
||||
writeJson(x.input, doc);
|
||||
refused(install(x), /persona-var-input/);
|
||||
assert.equal(calls(x), "");
|
||||
});
|
||||
|
||||
test("the install refuses root, a business id mismatch, another --home and a config dir with group bits", (t) => {
|
||||
const x = world(t);
|
||||
refused(install(x, { extra: { CASE2_TEST_UID: "0" } }), /run-as-root/);
|
||||
refused(install(x, { args: ["--business", "other", "--business-file", x.input, "--commit", COMMIT, "--personas", x.staging] }), /business-id-mismatch/);
|
||||
refused(install(x, { args: ["--business", "acme", "--business-file", x.input, "--commit", COMMIT, "--personas", x.staging, "--home", x.root] }), /home-mismatch/);
|
||||
chmodSync(x.configDir, 0o750);
|
||||
refused(install(x), /config-dir-mode/);
|
||||
assert.equal(calls(x), "");
|
||||
assert.equal(existsSync(x.business), false);
|
||||
});
|
||||
|
||||
test("the library install needs --personas", (t) => {
|
||||
const x = world(t);
|
||||
const r = install(x, { args: ["--business", "acme", "--business-file", x.input, "--commit", COMMIT] });
|
||||
assert.equal(r.code, 4, r.out);
|
||||
assert.match(r.out, /needs --personas/);
|
||||
});
|
||||
|
||||
test("a validate refusal removes the business file this run wrote", (t) => {
|
||||
const x = world(t);
|
||||
unlinkSync(x.doc.roles.pm.credentials.gitea.file);
|
||||
const r = install(x);
|
||||
refused(r, /validate: .*the business file this run wrote is removed/);
|
||||
assert.equal(existsSync(x.business), false);
|
||||
});
|
||||
|
||||
test("N4: a validate refusal on a re-run leaves the matching business file in place, byte for byte", (t) => {
|
||||
const x = world(t);
|
||||
assert.equal(install(x).code, 0);
|
||||
const before = readFileSync(x.business);
|
||||
unlinkSync(x.doc.roles.pm.credentials.gitea.file);
|
||||
const r = install(x);
|
||||
refused(r, /validate: /);
|
||||
assert.doesNotMatch(r.out, /is removed/);
|
||||
assert.deepEqual(readFileSync(x.business), before);
|
||||
});
|
||||
|
||||
// ---- case2-precheck.sh, the library profile (D6a) ----
|
||||
|
||||
test("a clean library host passes the precheck with no mirror yet", (t) => {
|
||||
const x = world(t);
|
||||
assert.equal(install(x).code, 0);
|
||||
const r = precheck(x);
|
||||
assert.equal(r.code, 0, r.out);
|
||||
assert.match(r.out, /ok {6}business file .* with no group or other bit/);
|
||||
assert.match(r.out, /ok {6}no history for acme yet/);
|
||||
assert.match(r.out, new RegExp(`ok {6}persona bean sha256 ${sha256(PERSONA_TEXT.bean)}`));
|
||||
assert.match(r.out, new RegExp(`ok {6}persona chet sha256 ${sha256(PERSONA_TEXT.chet)}`));
|
||||
assert.match(r.out, /ok {6}no Discord binding/);
|
||||
assert.equal(existsSync(mirrorPath({ business: { id: "acme", projects: { books: x.doc.projects.books } }, project: "books", dataRoot: x.dataRoot })), false);
|
||||
});
|
||||
|
||||
test("the library precheck refuses a persona file at 0644", (t) => {
|
||||
const x = world(t);
|
||||
assert.equal(install(x).code, 0);
|
||||
chmodSync(join(x.personaDir, "bean.md"), 0o644);
|
||||
refused(precheck(x), /REFUSED persona-mode: .*bean\.md must be .* 0600 file \(mode 644\)/);
|
||||
});
|
||||
|
||||
test("the library precheck refuses a persona whose digest isn't persona.sha256", (t) => {
|
||||
const x = world(t);
|
||||
assert.equal(install(x).code, 0);
|
||||
writeFileSync(join(x.personaDir, "chet.md"), "Not Chet. PERSONA-TEXT-other\n");
|
||||
refused(precheck(x), /REFUSED persona-mismatch: persona chet has sha256 [0-9a-f]{64}, and persona.sha256 is /);
|
||||
});
|
||||
|
||||
test("the library precheck refuses a persona dir with group bits", (t) => {
|
||||
const x = world(t);
|
||||
assert.equal(install(x).code, 0);
|
||||
chmodSync(x.personaDir, 0o750);
|
||||
refused(precheck(x), /REFUSED persona-mode: .*personas\/acme must be .* 0700 directory/);
|
||||
});
|
||||
|
||||
test("the library precheck refuses a business file at 0644", (t) => {
|
||||
const x = world(t);
|
||||
assert.equal(install(x).code, 0);
|
||||
chmodSync(x.business, 0o644);
|
||||
refused(precheck(x), /REFUSED business-file-mode: .*acme\.json .*\(mode 644\)/);
|
||||
});
|
||||
|
||||
test("the library precheck refuses a changes.jsonl at 0644", (t) => {
|
||||
const x = world(t);
|
||||
assert.equal(install(x).code, 0);
|
||||
const history = join(x.configDir, "businesses", ".history", "acme");
|
||||
mkdirSync(history, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(history, "changes.jsonl"), "", { mode: 0o644 });
|
||||
chmodSync(join(history, "changes.jsonl"), 0o644);
|
||||
refused(precheck(x), /REFUSED history-mode: .*changes\.jsonl must be .* 0600 file \(mode 644\)/);
|
||||
chmodSync(join(history, "changes.jsonl"), 0o600);
|
||||
assert.equal(precheck(x).code, 0);
|
||||
});
|
||||
|
||||
test("the library precheck refuses a Discord binding", (t) => {
|
||||
const x = world(t);
|
||||
assert.equal(install(x).code, 0);
|
||||
mkdirSync(join(x.dataRoot, "discord"), { recursive: true });
|
||||
writeFileSync(join(x.dataRoot, "discord", "her.json"), "{}\n", { mode: 0o600 });
|
||||
refused(precheck(x), /REFUSED discord-binding: .*her\.json exists/);
|
||||
});
|
||||
|
||||
// ---- case2-precheck.sh --library-status (D6b) ----
|
||||
|
||||
// The context the bus builds for books (runtime.mjs:161-164), and a sync the way the bus's timer
|
||||
// runs it, against a local bare clone through the command parameter.
|
||||
function library(x) {
|
||||
const context = { business: { id: "acme", projects: { books: x.doc.projects.books } }, project: "books", dataRoot: x.dataRoot };
|
||||
mkdirSync(x.dataRoot, { recursive: true, mode: 0o700 });
|
||||
chmodSync(x.dataRoot, 0o700);
|
||||
const run = (bare, slot) => sync(context, {
|
||||
slot,
|
||||
command: (kind, url, { mirror, branch }) => ({
|
||||
argv: [...SAFE, "-c", "protocol.file.allow=always", "-c", "core.createObject=rename", "-C", mirror, "fetch", "--no-tags",
|
||||
"--no-write-fetch-head", "--end-of-options", bare, `+refs/heads/${branch}:refs/remotes/library/${branch}`],
|
||||
env: { ...gitEnv(), LC_ALL: "C", GIT_CEILING_DIRECTORIES: dirname(mirror) },
|
||||
}),
|
||||
});
|
||||
return { context, run };
|
||||
}
|
||||
|
||||
test("--library-status refuses library-mirror-missing before the bus has made the mirror", (t) => {
|
||||
const x = world(t);
|
||||
assert.equal(install(x).code, 0);
|
||||
refused(status(x), /REFUSED library-mirror-missing: library books has no mirror at .*the bus makes it when it starts/);
|
||||
});
|
||||
|
||||
test("--library-status refuses library-never-synced on a fresh mirror", async (t) => {
|
||||
const x = world(t);
|
||||
assert.equal(install(x).code, 0);
|
||||
await ensureMirror(library(x).context);
|
||||
const r = status(x);
|
||||
refused(r, /REFUSED library-never-synced: library books has no fetch yet/);
|
||||
assert.doesNotMatch(r.out, /library-last-error/);
|
||||
});
|
||||
|
||||
test("--library-status names a recorded lastError before the null lastFetch it leaves (N3)", async (t) => {
|
||||
const x = world(t);
|
||||
assert.equal(install(x).code, 0);
|
||||
const lib = library(x);
|
||||
await lib.run("/nonexistent", join(x.slot, "library-gitea"));
|
||||
const r = status(x);
|
||||
refused(r, /REFUSED library-last-error: library books: library-credential-missing at \d{4}-\d\d-\d\dT/);
|
||||
assert.doesNotMatch(r.out, /library-never-synced/);
|
||||
});
|
||||
|
||||
test("--library-status prints each R-problem's path as an escaped JSON string, then the count, and passes", async (t) => {
|
||||
const x = world(t);
|
||||
assert.equal(install(x).code, 0);
|
||||
const odd = "OddName\u0085.md";
|
||||
const f = repo(t, { ...BASE, [odd]: `${MARKER}\n` });
|
||||
const bare = join(f.root, "remote.git");
|
||||
git(f.root, "clone", "-q", "--bare", "--no-local", f.repo, bare);
|
||||
writeFileSync(join(x.slot, "library-gitea"), "placeholder-not-a-token\n", { mode: 0o600 });
|
||||
const { state } = await library(x).run(bare, join(x.slot, "library-gitea"));
|
||||
assert.equal(state.lastError, null);
|
||||
const r = status(x);
|
||||
assert.equal(r.code, 0, r.out);
|
||||
assert.match(r.out, new RegExp(`ok {6}library books last fetched \\S+, serving ${f.commit}`));
|
||||
assert.match(r.out, /^problem {3}R2 "Inbox Note\.md"$/m);
|
||||
assert.match(r.out, /^problem {3}R\d+ "Odd\\u202eName\\u0085\.md"$/m);
|
||||
assert.doesNotMatch(r.out, /[\u0085]/);
|
||||
const lines = r.out.split("\n").filter((l) => l.startsWith("problem "));
|
||||
assert.equal(lines.length, state.problems.length);
|
||||
assert.match(r.out, new RegExp(`^problems ${state.problems.length}$`, "m"));
|
||||
assert.match(r.out, /case2-precheck: library status passed/);
|
||||
});
|
||||
|
||||
test("--library-status takes no --profile", (t) => {
|
||||
const x = world(t);
|
||||
const r = precheck(x, { args: ["--business", "acme", "--library-status", "--profile", "library"] });
|
||||
assert.equal(r.code, 4, r.out);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { main } from '../src/cli.mjs';
|
||||
import { io } from './helpers.mjs';
|
||||
const setup = () => {
|
||||
const calls = [];
|
||||
const transport = () => async (verb, args) => { calls.push({ verb, args }); return { grants: [], serviceLeft: [], admin: null }; };
|
||||
return { calls, deps: { system: { dataRoot: '/nonexistent' }, transport, testerTransport: transport } };
|
||||
};
|
||||
|
||||
test('GitHub admin set uses separate identity flags and bounded stdin', async () => {
|
||||
const f = setup(), x = io(); const fakeKey = '-----BEGIN PRIVATE KEY-----\nfake-only-not-a-real-key\n-----END PRIVATE KEY-----\n';
|
||||
x.stdin.end(fakeKey);
|
||||
await main(['grants', 'admin', 'set', 'github', '--business', 'demo', '--url', 'https://api.github.com',
|
||||
'--app-id', '10', '--installation-id', '20', '--account-id', '30', '--rotate-by', '2099-01-01'], x, f.deps);
|
||||
assert.equal(f.calls.length, 1);
|
||||
assert.deepEqual(f.calls[0], { verb: 'grants.admin.set', args: { service: 'github', url: 'https://api.github.com',
|
||||
appId: 10, installationId: 20, accountId: 30, rotateBy: '2099-01-01', token: fakeKey } });
|
||||
assert.ok(!x.out.text.includes(fakeKey) && !x.err.text.includes(fakeKey));
|
||||
});
|
||||
|
||||
test('GitHub rotate replaces the human-supplied key rather than Gitea token minting', async () => {
|
||||
const f = setup(), x = io(); x.stdin.end('fake-only-key-value');
|
||||
await main(['grants', 'admin', 'rotate', 'github', '--business', 'demo', '--rotate-by', '2099-01-01'], x, f.deps);
|
||||
assert.equal(f.calls[0].verb, 'grants.admin.rotate');
|
||||
assert.equal(f.calls[0].args.service, 'github');
|
||||
assert.equal(f.calls[0].args.rotateBy, '2099-01-01');
|
||||
});
|
||||
|
||||
test('GitHub management dispatch and malformed flag shapes', async () => {
|
||||
const f = setup();
|
||||
await main(['grants', 'status', 'github', '--business', 'demo', '--json'], io(), f.deps);
|
||||
await main(['grants', 'revoke', 'coder', 'github', '--business', 'demo', '--reason', 'test'], io(), f.deps);
|
||||
await main(['grants', 'sweep', 'github', '--business', 'demo'], io(), f.deps);
|
||||
assert.deepEqual(f.calls.map((c) => c.verb), ['grants.status', 'grants.revoke', 'grants.sweep']);
|
||||
for (const args of [
|
||||
['admin', 'set', 'github', '--account', '20'], ['admin', 'rotate', 'github', '--finish'],
|
||||
['status', 'github', '--app-id', '10'], ['admin', 'remove', 'github', '--tester'],
|
||||
['admin', 'set', 'github', '--url', 'https://api.github.com', '--app-id', 'NaN', '--installation-id', '20', '--account-id', '30', '--rotate-by', '2099-01-01'],
|
||||
]) await assert.rejects(main(['grants', ...args, '--business', 'demo'], io(), f.deps), (e) => e.exitCode === 4);
|
||||
assert.equal(f.calls.length, 3);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FAR_DATE, launchDoc } from "../../business/tests/helpers.mjs";
|
||||
import { launchDoc } from "../../business/tests/helpers.mjs";
|
||||
// The S6 launcher inside a real bus host: real broker child, real runners
|
||||
// in the bwrap sandbox, the fake adapter from packages/harness standing in for the
|
||||
// harness. The chief of staff launches a coder through its `launch` tool (row 119: the
|
||||
@@ -766,7 +766,7 @@ test("row 64 a role skill that isn't enabled refuses skill-not-enabled, and one
|
||||
test("row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason", { timeout: 30000 }, async (t) => {
|
||||
const text = "Warm. PERSONA-MARKER-d2\n";
|
||||
const given = prepare(t, (doc) => {
|
||||
doc.roles.bean = { definition: "companion", credentials: { gitea: { store: true, rotateBy: FAR_DATE } }, vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high", "persona.name": "Bean", "persona.sha256": createHash("sha256").update(text).digest("hex") } };
|
||||
doc.roles.bean = { definition: "companion", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high", "persona.name": "Bean", "persona.sha256": createHash("sha256").update(text).digest("hex") } };
|
||||
doc.launch.instances.push("bean");
|
||||
return doc;
|
||||
});
|
||||
|
||||
@@ -142,7 +142,7 @@ test('real pinned install loads bus and Console; same-copy mosaic talk passes pr
|
||||
host.stdout.on('data',c=>hostOut+=c);host.stderr.on('data',c=>hostErr+=c);
|
||||
try{await until(()=>hostOut.includes('bus host up')||host.exitCode!==null);assert.match(hostOut,/bus host up/,hostErr);}
|
||||
finally{if(host.exitCode===null&&host.signalCode===null){const done=once(host,'close');host.kill('SIGTERM');await done;}}
|
||||
const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr='';
|
||||
const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr='';
|
||||
web.stdout.on('data',c=>webOut+=c);web.stderr.on('data',c=>webErr+=c);
|
||||
try{await until(()=>/Mosaic Console: http:/.test(webOut)||web.exitCode!==null);const port=webOut.match(/127\.0\.0\.1:(\d+)/)?.[1];assert.ok(port,webErr);assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status,200);}
|
||||
finally{if(web.exitCode===null&&web.signalCode===null){const done=once(web,'close');web.kill();await done;}}
|
||||
|
||||
@@ -851,6 +851,64 @@ test('T44 row 180 item 2: --plan lists tester-port-invalid and tester-port-targe
|
||||
});
|
||||
});
|
||||
|
||||
// Row 198 (design item 8, B2; sage-2834 ruling a; sage-2837): --board is dropped for every op, because
|
||||
// webui-service.sh install refuses the flag. The reason follows the target's cli.mjs: a target that
|
||||
// parses --board is pre-198 (7331 retired); a target that names it only in its refusal runs the board
|
||||
// in the Console. The plan names each drop. setCli rewrites a shared copy's cli.mjs and restores it after.
|
||||
const BOARD='--board http://127.0.0.1:9';
|
||||
const REFUSES_BOARD=`// fixture Console, never run\n// serve accepts '--tester-port'\nif (flag === '--board') throw new Error('--board retired: the board runs in the Console (row 198)');\n`;
|
||||
const PARSES_BOARD=`// fixture Console, never run\n// serve accepts '--tester-port'\nelse if (flag === '--board') board = value;\n`;
|
||||
const setCli=(t,id,text)=>{const f=join(COPIES,id,'packages/webui/src/cli.mjs'),orig=readFileSync(f,'utf8');chmodSync(f,0o644);writeFileSync(f,text);t.after(()=>{chmodSync(f,0o644);writeFileSync(f,orig);chmodSync(f,0o444)})};
|
||||
const withBoard=(e)=>{const q=pointer(e);q.console=[...q.console,...BOARD.split(' ')];writeFileSync(e.pointer,JSON.stringify(q)+'\n')};
|
||||
|
||||
test('row 198 item 8 (M12): a re-pin plan drops a --board the target Console does not parse, and its Console line has none',async(t)=>{
|
||||
const e=await mk(t);await seed(e);setCli(t,C1,REFUSES_BOARD);withBoard(e);
|
||||
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
|
||||
const r=await run(e,'activate',C1,'acme','--plan');
|
||||
assert.equal(r.code,0,r.out+r.err);assert.equal(r.err,'');
|
||||
assert.ok(r.out.split('\n').includes(`drop ${BOARD}; the target Console does not parse it, so the board runs in the Console`),r.out);
|
||||
assert.doesNotMatch(r.out.split('\n').find((l)=>l.startsWith('Console: ')),/--board/);
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
});
|
||||
|
||||
test('row 198 item 8 (M13, activate): an activate to a pre-198 copy that parses --board drops the unit\'s --board, and the install and unit have none',async(t)=>{
|
||||
// Sage sage-2837: a target that parses --board is pre-198, and webui-service.sh install still refuses the flag.
|
||||
// The unit carries the flag before, as a pre-198 Console does. The plan is checked first, then a real run.
|
||||
const e=await mk(t);await seed(e);setCli(t,C1,PARSES_BOARD);
|
||||
edit(join(e.units,CON),'--business acme',`--business acme ${BOARD}`);withBoard(e);
|
||||
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
|
||||
let r=await run(e,'activate',C1,'acme','--plan');
|
||||
assert.equal(r.code,0,r.out+r.err);assert.equal(r.err,'');
|
||||
const lines=r.out.split('\n');
|
||||
assert.ok(lines.includes(`drop ${BOARD}; the 7331 board is retired, so this Console runs without the board`),r.out);
|
||||
assert.doesNotMatch(lines.find((l)=>l.startsWith('Console: ')),/--board/);
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.ok(r.out.split('\n').includes(`drop ${BOARD}; the 7331 board is retired, so this Console runs without the board`),r.out);
|
||||
assert.equal(pointer(e).copy,C1);assert.equal(pointer(e).console.includes('--board'),false);
|
||||
assert.doesNotMatch(consoleText(e),/--board/);
|
||||
});
|
||||
|
||||
test('row 198 item 8 (M13, rollback): a rollback drops the unit\'s --board even for a copy that parses it, and the install and unit have none',async(t)=>{
|
||||
// Sage sage-2834 ruling (a), sage-2837: webui-service.sh install refuses --board, so a rollback drops it and
|
||||
// names the drop with the retired-board reason. The activation runs without --board; the unit then carries it,
|
||||
// and the rollback target B parses it, which must not keep the flag. The plan is checked first, then a real run.
|
||||
const e=await mk(t);setCli(t,B,PARSES_BOARD);
|
||||
await seed(e);
|
||||
let r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
edit(join(e.units,CON),'--business acme',`--business acme ${BOARD}`);withBoard(e);
|
||||
const before=fp(e);
|
||||
r=await run(e,'rollback','acme','--plan');assert.equal(r.code,0,r.out+r.err);
|
||||
const lines=r.out.split('\n');
|
||||
assert.ok(lines.includes(`drop ${BOARD}; the 7331 board is retired, so this Console runs without the board`),r.out);
|
||||
assert.doesNotMatch(lines.find((l)=>l.startsWith('Console: ')),/--board/);
|
||||
assert.deepEqual(fp(e),before);
|
||||
r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(e).copy,B);assert.equal(pointer(e).console.includes('--board'),false);
|
||||
assert.doesNotMatch(consoleText(e),/--board/);
|
||||
assert.match(stepText(e),/drop --board http:\/\/127\.0\.0\.1:9; the 7331 board is retired/);
|
||||
});
|
||||
|
||||
// Row 181 (#1679, P19 390): COUNTED and STORE_JS as the script under test has them. The fixture
|
||||
// tables carry only seq and at, which is all the count reads.
|
||||
const srText=readFileSync(SR,'utf8');
|
||||
|
||||
@@ -27,7 +27,6 @@ import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startTimeOf } from "../src/host.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
import { mappingEntry } from "../../library/tests/helpers.mjs";
|
||||
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
|
||||
// legacy thread, with the thread's id.
|
||||
const DEFAULT_SESSION = [
|
||||
@@ -530,24 +529,3 @@ test('C2-09a: terminating one creative thread does not signal an unselected peer
|
||||
await until(() => !readSessions(f.dataRoot).some(row => row.run === runs[0]));
|
||||
assert.ok(readSessions(f.dataRoot).some(row => row.run === runs[1]));
|
||||
});
|
||||
|
||||
// Row224: the launcher must derive library presence from business projects, not
|
||||
// from session checkouts (a library never has an agent clone).
|
||||
test("C2-05b a real launched bundle gets lookups for a library project without a library checkout", { skip, timeout: 60000 }, async t => {
|
||||
const prepared = prepare(t, { idle: 10, edit: doc => {
|
||||
doc.projects.books = mappingEntry();
|
||||
return doc;
|
||||
} });
|
||||
const { f } = prepared;
|
||||
const host = await start(t, prepared);
|
||||
const project = host.call("project.create", { name: "library work", repo: "stack" }).id;
|
||||
const thread = host.call("thread.create", { project, name: "lookup" }).id;
|
||||
await ask(host.call, thread, "inspect bundle");
|
||||
const run = launchLog(f).find(e => e.event === "launch" && e.thread === thread).run;
|
||||
const tools = JSON.parse(readFileSync(join(f.dataRoot, "launches", "acme", run, "bundle", "tools.json"), "utf8"));
|
||||
assert.deepEqual(tools.filter(tool => tool.name.startsWith("library_")).map(tool => tool.name),
|
||||
["library_find", "library_get", "library_search", "library_manuscript", "library_universe"]);
|
||||
const [session] = threadRuns(f, thread);
|
||||
assert.ok(session);
|
||||
assert.ok(!session.checkouts?.some(checkout => checkout.project === "books"));
|
||||
});
|
||||
@@ -100,14 +100,22 @@ service transitions or authorize replacing a running board.
|
||||
|
||||
```
|
||||
node src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
|
||||
node src/cli.mjs serve [same flags] [--port N] [--host 127.0.0.1]
|
||||
```
|
||||
|
||||
`scan` runs once and writes the status files. `serve` starts a small local
|
||||
web server: open `http://127.0.0.1:7331/` in a browser. The page fetches
|
||||
`/api/board` every 10 seconds; each fetch re-runs the scan, so the page is
|
||||
never staler than that timer. There is no login, so the server refuses to
|
||||
bind to anything but a loopback address.
|
||||
`scan` runs once and writes the status files.
|
||||
|
||||
`serve` is retired (row 198). It exits 2 with a pointer to the Console. The
|
||||
board now runs in-process inside the Console (`packages/webui`), behind the
|
||||
Console's own sign-in. Start it with:
|
||||
|
||||
```
|
||||
node packages/webui/src/cli.mjs serve
|
||||
```
|
||||
|
||||
Then open `http://127.0.0.1:7330/` in a browser. The board's routes are
|
||||
`/api/board` and the two writes `/api/seen` and `/api/reply`, which need
|
||||
`--send` on the public name. The page fetches `/api/board` every 10 seconds;
|
||||
each fetch re-runs the scan, so the page is never staler than that timer.
|
||||
|
||||
- `--config PATH` — path to the system config file. Defaults to
|
||||
`~/.config/mosaic-dev/config.json`. This file must exist and name an
|
||||
@@ -124,11 +132,8 @@ bind to anything but a loopback address.
|
||||
first column is `*` for a row waiting on you, `s` for a seen row, or blank
|
||||
otherwise. The final summary line reads
|
||||
`board: <dir> (N sessions, N waiting on you, N seen)`.
|
||||
- `--port N` — (`serve`) port to listen on. Default `7331`; `0` picks a free port.
|
||||
- `--host ADDR` — (`serve`) loopback address to bind. Default `127.0.0.1`.
|
||||
Any non-loopback address is refused.
|
||||
|
||||
Routes served:
|
||||
Routes (row 198: served by the Console, `packages/webui`, not by a board port;
|
||||
the bodies and refusals below are unchanged):
|
||||
|
||||
- `GET /` — the page.
|
||||
- `GET /api/board` — rescan, returns `index.json`.
|
||||
@@ -170,7 +175,7 @@ so the seat's reaction shows without waiting for the next tick.
|
||||
|
||||
## Exit codes
|
||||
|
||||
- `0` — scan completed and status files were written, or the server stopped cleanly.
|
||||
- `0` — scan completed and status files were written.
|
||||
- `2` — refused: bad config, missing/invalid `dataRoot`, or bad arguments.
|
||||
The message on stderr says why.
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// The control board's routes as plain async functions (row 198). The Console
|
||||
// (packages/webui) calls these in-process behind its own auth. Nothing here
|
||||
// opens a socket or binds a port: the 7331 writer is retired.
|
||||
//
|
||||
// Every method returns a promise and uses the async child-process paths (B1), so
|
||||
// a slow tmux call never blocks the Console's event loop.
|
||||
//
|
||||
// Write counter (decision 10, Webby N1). A scan must not serve a pre-write view.
|
||||
// index() joins an in-flight scan only when that scan started at the current
|
||||
// counter value. markSeen() and reply() bump the counter and never join a scan.
|
||||
|
||||
import { scanAsync, markSeen as writeMark, loadRegistrations, runAsync } from "./scan.mjs";
|
||||
import { replyToRowAsync, conversationResponse, DEFAULT_AGENT_SEND } from "./serve.mjs";
|
||||
import { createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs";
|
||||
|
||||
// onEvent(name) reports "write", "bump", "send" and "scan" in order. Production
|
||||
// passes nothing; the order test (board.test.mjs) reads it to pin the sequence.
|
||||
export function createBoard({ specs, boardDir, seatsDir = null, discordDataRoot = null, isAlive, isPidAlive, now = () => new Date(), agentSend = DEFAULT_AGENT_SEND, exec = runAsync, conversationReader = null, host, onEvent = () => {} }) {
|
||||
let writes = 0;
|
||||
let inflight = null;
|
||||
const scanFresh = () => {
|
||||
onEvent("scan");
|
||||
return scanAsync(specs, { isAlive, isPidAlive, exec, boardDir, now, seatsDir, discordDataRoot });
|
||||
};
|
||||
const reader = conversationReader ?? createReader({ roots: () => rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations) });
|
||||
|
||||
// Joins a scan only when it started after the last write and is still running
|
||||
// (see header). A settled scan, fulfilled or rejected, is cleared so the next
|
||||
// index() starts a fresh one; the clear is on both branches, so a rejection is
|
||||
// not left unhandled.
|
||||
function index() {
|
||||
if (inflight && inflight.generation === writes) return inflight.promise;
|
||||
const generation = writes;
|
||||
const promise = scanFresh();
|
||||
const current = { generation, promise };
|
||||
inflight = current;
|
||||
const clear = () => { if (inflight === current) inflight = null; };
|
||||
promise.then(clear, clear);
|
||||
return promise;
|
||||
}
|
||||
|
||||
// Pinned order (Webby, Mos mos-1052): write, then bump the counter, then scan.
|
||||
// The scan sees the mark, and no scan started before the bump can be joined.
|
||||
async function markSeenRoute(body) {
|
||||
writeMark(boardDir, { project: body.project, agent: body.agent, lastActivity: body.lastActivity, seen: body.seen ?? true });
|
||||
onEvent("write");
|
||||
writes++;
|
||||
onEvent("bump");
|
||||
return scanFresh();
|
||||
}
|
||||
|
||||
// Pinned order: send, then bump the counter, then scan. The planning scan
|
||||
// before the send is a read and is not part of the pinned sequence. A refusal
|
||||
// runs no send, so it writes nothing: no bump and no post-send scan. Only a
|
||||
// send that reached the exec runs the rest. The post-send scan rewrites the
|
||||
// derived board files, so a reply's effects reach them the same way a mark does.
|
||||
async function reply(body) {
|
||||
const planned = await scanFresh();
|
||||
let sent = false;
|
||||
const sendExec = (...args) => {
|
||||
sent = true;
|
||||
return exec(...args);
|
||||
};
|
||||
const out = await replyToRowAsync({ index: planned, key: body.agent, text: body.text, agentSend, exec: sendExec, now, host });
|
||||
if (!sent) return out;
|
||||
onEvent("send");
|
||||
writes++;
|
||||
onEvent("bump");
|
||||
await scanFresh();
|
||||
return out;
|
||||
}
|
||||
|
||||
function conversation(url) {
|
||||
return conversationResponse(reader, url);
|
||||
}
|
||||
|
||||
return { index, markSeen: markSeenRoute, reply, conversation };
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
// Usage:
|
||||
// node packages/control-board/src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
|
||||
// node packages/control-board/src/cli.mjs serve [same flags] [--port N] [--host 127.0.0.1]
|
||||
// `serve` is retired (row 198): the board runs in-process inside the Console.
|
||||
// Exit 0 on success, 2 on a config refusal or bad usage.
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { loadConfig, defaultConfigPath, discoverRepoAgents, discoverFleetAgents, scan, tmuxInspect, ConfigError } from "./scan.mjs";
|
||||
import { startServer } from "./serve.mjs";
|
||||
|
||||
const USAGE = "usage: mosaic-board scan|serve [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print] [--port N] [--host 127.0.0.1]";
|
||||
const USAGE = "usage: mosaic-board scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]";
|
||||
const RETIRED = "the control board no longer serves a port (retired on 7331, row 198). Open the board in the Console (webui, port 7332) instead.";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = { command: argv[0], config: defaultConfigPath(), repo: process.cwd(), fleet: join(homedir(), ".mosaic", "fleet", "agents"), liveness: "tmux", print: false, port: 7331, host: "127.0.0.1" };
|
||||
if (argv[0] === "serve") throw new ConfigError(RETIRED);
|
||||
const opts = { command: argv[0], config: defaultConfigPath(), repo: process.cwd(), fleet: join(homedir(), ".mosaic", "fleet", "agents"), liveness: "tmux", print: false };
|
||||
for (let i = 1; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
const next = () => {
|
||||
@@ -23,13 +24,9 @@ function parseArgs(argv) {
|
||||
else if (a === "--fleet") opts.fleet = next();
|
||||
else if (a === "--liveness") opts.liveness = next();
|
||||
else if (a === "--print") opts.print = true;
|
||||
else if (a === "--port") {
|
||||
opts.port = Number(next());
|
||||
if (!Number.isInteger(opts.port) || opts.port < 0 || opts.port > 65535) throw new ConfigError("--port must be an integer 0..65535");
|
||||
} else if (a === "--host") opts.host = next();
|
||||
else throw new ConfigError(`unknown argument: ${a}`);
|
||||
}
|
||||
if (opts.command !== "scan" && opts.command !== "serve") throw new ConfigError(USAGE);
|
||||
if (opts.command !== "scan") throw new ConfigError(USAGE);
|
||||
if (!["tmux", "assume-alive"].includes(opts.liveness)) throw new ConfigError(`unknown liveness mode: ${opts.liveness}`);
|
||||
return opts;
|
||||
}
|
||||
@@ -41,15 +38,6 @@ async function main() {
|
||||
const isAlive = opts.liveness === "tmux" ? tmuxInspect : () => true;
|
||||
const boardDir = join(dataRoot, "board");
|
||||
const seatsDir = join(dataRoot, "seats");
|
||||
if (opts.command === "serve") {
|
||||
const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive, seatsDir, discordDataRoot: dataRoot });
|
||||
const addr = server.address();
|
||||
process.stdout.write(`control board: http://${opts.host}:${addr.port}/ (${specs.length} agents; board files in ${boardDir}; Ctrl-C to stop)\n`);
|
||||
const stop = () => server.close(() => process.exit(0));
|
||||
process.on("SIGINT", stop);
|
||||
process.on("SIGTERM", stop);
|
||||
return;
|
||||
}
|
||||
const index = scan(specs, { boardDir, isAlive, seatsDir, discordDataRoot: dataRoot });
|
||||
for (const line of index.discoveryErrors) process.stderr.write(`${line}\n`);
|
||||
for (const line of index.registrationErrors) process.stderr.write(`registration skipped: ${line}\n`);
|
||||
|
||||
@@ -1,605 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Control board</title>
|
||||
<style>
|
||||
:root{
|
||||
--canvas:#f4f6f8;--surface:#fff;--raised:#e9edf1;--text:#16202a;--muted:#5a6774;
|
||||
--line:#e2e7ec;--action:#1f5f8b;--onAction:#fff;--accent:#c96a2b;
|
||||
--success:#2e7d4f;--warning:#b26a00;--danger:#b3261e;
|
||||
--font:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
|
||||
--radius:8px;--radius-lg:14px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root{
|
||||
--canvas:#11161c;--surface:#182029;--raised:#1f2833;--text:#e7edf3;--muted:#94a3b0;
|
||||
--line:#2a343f;--action:#6ba6d6;--onAction:#0b1116;--accent:#e08a4c;
|
||||
--success:#57b586;--warning:#e0a63c;--danger:#e58077;
|
||||
}
|
||||
}
|
||||
*,*::before,*::after{box-sizing:border-box}
|
||||
html{font-family:var(--font);background:var(--canvas);color:var(--text);line-height:1.45}
|
||||
body{margin:0 auto;max-width:1100px;padding:16px}
|
||||
h1,h2,h3{margin:0 0 .4em;line-height:1.2;font-weight:600}
|
||||
h1{font-size:1.5rem}h2{font-size:1.1rem;margin-top:1.4em}h3{font-size:.95rem}
|
||||
:focus-visible{outline:3px solid var(--action);outline-offset:2px;border-radius:4px}
|
||||
.page-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 16px}
|
||||
.head-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.status-line{margin:0;color:var(--muted);font-size:.9rem}
|
||||
.btn{font:inherit;font-weight:600;font-size:.85rem;min-height:34px;padding:6px 12px;border-radius:var(--radius);
|
||||
border:1px solid var(--line);background:var(--surface);color:var(--text);cursor:pointer}
|
||||
.btn:hover{background:var(--raised)}
|
||||
.error-banner{margin:12px 0;padding:10px 14px;border-radius:var(--radius);border:1px solid var(--danger);
|
||||
background:color-mix(in srgb,var(--danger) 10%,var(--surface));color:var(--danger);font-size:.9rem}
|
||||
.empty{color:var(--muted);font-style:italic}
|
||||
#seenDetails summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:8px}
|
||||
#seenDetails summary::-webkit-details-marker{display:none}
|
||||
#seenDetails summary::before{content:"\25B8";color:var(--muted);font-size:.9em}
|
||||
#seenDetails[open] summary::before{content:"\25BE"}
|
||||
#seenDetails summary h2{display:inline;margin:0}
|
||||
.table-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:var(--radius-lg);background:var(--surface)}
|
||||
table{width:100%;border-collapse:collapse;font-size:.88rem}
|
||||
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
th{font-size:.72rem;text-transform:uppercase;letter-spacing:.03em;color:var(--muted);font-weight:600;white-space:nowrap}
|
||||
tbody tr:last-child td{border-bottom:none}
|
||||
tr.is-offline{opacity:.55}
|
||||
.badge{display:inline-flex;align-items:center;gap:5px;font-size:.76rem;font-weight:600;padding:2px 9px;
|
||||
border-radius:99px;border:1px solid transparent;white-space:nowrap}
|
||||
.badge::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}
|
||||
.badge-ok{color:var(--success);background:color-mix(in srgb,var(--success) 12%,var(--surface))}
|
||||
.badge-warn{color:var(--warning);background:color-mix(in srgb,var(--warning) 12%,var(--surface))}
|
||||
.badge-danger{color:var(--danger);background:color-mix(in srgb,var(--danger) 12%,var(--surface))}
|
||||
.badge-muted{color:var(--muted);background:var(--raised)}
|
||||
.badge-accent{color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,var(--surface))}
|
||||
.row-toggle{font:inherit;font-weight:600;background:none;border:0;padding:2px 0;color:var(--action);
|
||||
cursor:pointer;text-align:left}
|
||||
.row-toggle:hover{text-decoration:underline}
|
||||
.seen-toggle{font:inherit;font-size:.76rem;background:none;border:1px solid var(--border);border-radius:6px;
|
||||
padding:1px 8px;color:var(--action);cursor:pointer;margin-left:6px;vertical-align:middle}
|
||||
.seen-toggle:hover{border-color:var(--action)}
|
||||
.seen-tag{font-size:.72rem;color:var(--muted);margin-left:6px;vertical-align:middle}
|
||||
.msg-error{color:var(--danger)}
|
||||
.msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.relaunch-notice{display:block;max-width:36ch;overflow-wrap:anywhere;white-space:normal}
|
||||
.model{display:block;color:var(--muted);font-size:.75em;white-space:nowrap}
|
||||
.task-text{display:block;max-width:28ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.unknown{color:var(--muted);font-style:italic}
|
||||
.reply-form{display:flex;gap:.4em;align-items:center;max-width:60ch}
|
||||
.reply-form input{flex:1;min-width:12ch;font:inherit;padding:.2em .4em}
|
||||
.reply-receipt{display:block;margin-top:.3em;font-size:.85em}
|
||||
.reply-ok{color:var(--muted)}
|
||||
.source{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
|
||||
.set-by{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
|
||||
.detail-row td{background:var(--raised)}
|
||||
.detail-list{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:.85rem;font-family:var(--mono)}
|
||||
.detail-list dt{color:var(--muted);font-family:var(--font)}
|
||||
.detail-list dd{margin:0;overflow-wrap:anywhere}
|
||||
.project-group{margin-bottom:20px}
|
||||
.project-group-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px}
|
||||
.group-toggles{display:flex;flex-wrap:wrap;gap:14px}
|
||||
.project-group-head label{font-size:.85rem;color:var(--muted);display:flex;align-items:center;gap:6px}
|
||||
.offline-note{margin:6px 0 0;font-size:.82rem;color:var(--muted)}
|
||||
.page-footer{margin-top:24px;padding-top:12px;border-top:1px solid var(--line);color:var(--muted);font-size:.85rem}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="page-head">
|
||||
<h1>Control board</h1>
|
||||
<div class="head-controls">
|
||||
<p id="headerStatus" class="status-line" aria-live="polite">Updated never · next refresh in 10s</p>
|
||||
<button id="refreshBtn" class="btn" type="button">Refresh</button>
|
||||
<button id="pauseBtn" class="btn" type="button" aria-pressed="false">Pause</button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="errorBanner" class="error-banner" role="alert" hidden></div>
|
||||
<main id="main">
|
||||
<section aria-labelledby="waiting-h">
|
||||
<h2 id="waiting-h">Waiting on you</h2>
|
||||
<div id="waitingBody"></div>
|
||||
</section>
|
||||
<section aria-labelledby="seen-h">
|
||||
<details id="seenDetails">
|
||||
<summary><h2 id="seen-h">Seen</h2></summary>
|
||||
<div id="seenBody"></div>
|
||||
</details>
|
||||
</section>
|
||||
<section aria-labelledby="projects-h">
|
||||
<h2 id="projects-h">By project</h2>
|
||||
<div id="projectsBody"></div>
|
||||
</section>
|
||||
</main>
|
||||
<footer id="footer" class="page-footer"></footer>
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
var STATES = ["working", "waiting", "error", "offline", "idle", "unknown"];
|
||||
var BADGE_CLASS = { waiting: "badge-warn", error: "badge-danger", working: "badge-ok", offline: "badge-muted", idle: "badge-muted", unknown: "badge-accent" };
|
||||
var REFRESH_MS = 10000;
|
||||
|
||||
var headerStatus = document.getElementById("headerStatus");
|
||||
var refreshBtn = document.getElementById("refreshBtn");
|
||||
var pauseBtn = document.getElementById("pauseBtn");
|
||||
var errorBanner = document.getElementById("errorBanner");
|
||||
var waitingBody = document.getElementById("waitingBody");
|
||||
var seenBody = document.getElementById("seenBody");
|
||||
var seenHeading = document.getElementById("seen-h");
|
||||
var projectsBody = document.getElementById("projectsBody");
|
||||
var footer = document.getElementById("footer");
|
||||
var main = document.getElementById("main");
|
||||
|
||||
var lastData = null;
|
||||
var paused = false;
|
||||
var fetching = false;
|
||||
var timerId = null;
|
||||
var secondsLeft = REFRESH_MS / 1000;
|
||||
var hideOfflineState = {};
|
||||
var hideSeenState = {};
|
||||
// Open detail panels survive a refresh. Keyed by section, project and agent
|
||||
// because the same agent can appear in both the waiting list and its group.
|
||||
var openDetails = {};
|
||||
var rowIdx = 0;
|
||||
|
||||
function esc(v) {
|
||||
if (v === null || v === undefined) return "";
|
||||
return String(v).replace(/[&<>"']/g, function (c) {
|
||||
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
||||
});
|
||||
}
|
||||
|
||||
function humanAge(s) {
|
||||
if (s === null || s === undefined) return "—";
|
||||
if (s < 60) return s + "s";
|
||||
var m = Math.floor(s / 60);
|
||||
if (m < 60) return m + "m";
|
||||
var h = Math.floor(m / 60);
|
||||
if (h < 24) return h + "h";
|
||||
return Math.floor(h / 24) + "d";
|
||||
}
|
||||
|
||||
function timeAgo(iso) {
|
||||
if (!iso) return "never";
|
||||
var ms = Date.now() - Date.parse(iso);
|
||||
if (Number.isNaN(ms)) return "never";
|
||||
var s = Math.floor(ms / 1000);
|
||||
if (s < 5) return "just now";
|
||||
if (s < 60) return s + "s ago";
|
||||
var m = Math.floor(s / 60);
|
||||
if (m < 60) return m + "m ago";
|
||||
var h = Math.floor(m / 60);
|
||||
if (h < 24) return h + "h ago";
|
||||
return Math.floor(h / 24) + "d ago";
|
||||
}
|
||||
|
||||
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
|
||||
|
||||
function badge(state) {
|
||||
var cls = BADGE_CLASS[state] || "badge-muted";
|
||||
return '<span class="badge ' + cls + '">' + esc(state) + "</span>";
|
||||
}
|
||||
|
||||
// A "Seen" button on rows that need you; a "seen" tag plus "Unsee" on rows
|
||||
// you have already marked. The mark clears itself when the agent writes again.
|
||||
function seenControl(rec) {
|
||||
var needsYou = rec.state === "waiting" || rec.state === "error";
|
||||
if (!needsYou || !rec.lastActivity) return "";
|
||||
var attrs = ' data-project="' + esc(rec.project) + '" data-agent="' + esc(rec.agent) + '" data-last="' + esc(rec.lastActivity) + '"';
|
||||
if (rec.seen) {
|
||||
return '<span class="seen-tag">seen</span><button type="button" class="seen-toggle" data-seen="false"' + attrs + ' title="Put this row back under Waiting on you">Unsee</button>';
|
||||
}
|
||||
return '<button type="button" class="seen-toggle" data-seen="true"' + attrs + ' title="I have read this; hide it from Waiting on you until the agent writes again">Seen</button>';
|
||||
}
|
||||
|
||||
var SOURCE_LABEL = { "registration": "registered", "first-user-message": "first message", "tmux-pane": "tmux pane", "session-cwd": "session cwd", "workspace-git-root": "git root" };
|
||||
function sourceLabel(source) { return SOURCE_LABEL[source] || source; }
|
||||
function sourceTag(source) { return source ? '<span class="source" title="source: ' + esc(source) + '">' + esc(sourceLabel(source)) + "</span>" : ""; }
|
||||
function fromSource(source) { return source ? " (from " + esc(sourceLabel(source)) + ")" : ""; }
|
||||
// Who set a registered task (#1511): shown only when the task on the row is
|
||||
// the registered one (the scan gives null otherwise). It is what the
|
||||
// caller claimed when it ran `mosaic seat task`, not a verified identity,
|
||||
// and it never changes what the row lets you do.
|
||||
function setByTag(rec) { return rec.taskSetBy ? '<span class="set-by" title="set by (as claimed by the caller, not verified): ' + esc(rec.taskSetBy) + '">set by ' + esc(rec.taskSetBy) + "</span>" : ""; }
|
||||
function setByText(rec) { return rec.taskSetBy ? esc(rec.taskSetBy) + " (as claimed by the caller, not verified)" : "—"; }
|
||||
// The model a seat is running, e.g. "gpt-6-astra" with the provider in the
|
||||
// hover title. modelText() already escapes, so the title takes it as is.
|
||||
// Empty when the log has not named one yet.
|
||||
function modelTag(rec) { return rec.model ? '<span class="model" title="' + modelText(rec) + '">' + esc(rec.model) + "</span>" : ""; }
|
||||
function modelText(rec) { return rec.model ? (rec.provider ? esc(rec.provider) + "/" : "") + esc(rec.model) : "unknown"; }
|
||||
// Reply-from-board (#1505): a box and Send only where the board has an
|
||||
// address, i.e. a live registration naming a tmux session. Receipts and
|
||||
// unsent drafts are kept per row id so a refresh does not lose them.
|
||||
var replyReceipts = {};
|
||||
var replyDrafts = {};
|
||||
function canReply(rec) {
|
||||
if (rec.connector) return false;
|
||||
var reg = rec.registered;
|
||||
return !!(reg && reg.alive !== false && reg.tmux && reg.tmux.session);
|
||||
}
|
||||
function replyReceipt(id) {
|
||||
var r = replyReceipts[id];
|
||||
if (!r) return "";
|
||||
if (r.delivered) return '<span class="reply-receipt reply-ok">delivered ' + esc(r.sentAt) + " to tmux " + esc(r.session) + "</span>";
|
||||
return '<span class="reply-receipt msg-error" style="white-space:normal;max-width:none">failed' + (r.exitCode === null || r.exitCode === undefined ? "" : " (exit " + esc(r.exitCode) + ")") + ": " + esc(r.stderr || r.error || "no output") + "</span>";
|
||||
}
|
||||
function replyControl(rec) {
|
||||
var id = [rec.project, rec.agent].join("/");
|
||||
if (rec.connector) return '<span class="unknown">Board replies disabled for Discord connectors</span>';
|
||||
if (!canReply(rec)) return '<span class="unknown">reply needs a registered seat</span>' + replyReceipt(id);
|
||||
return '<form class="reply-form" data-id="' + esc(id) + '">' +
|
||||
'<input type="text" class="reply-text" maxlength="2000" autocomplete="off" aria-label="Reply to ' + esc(rec.agent) + '" placeholder="Reply to ' + esc(rec.agent) + '" value="' + esc(replyDrafts[id] || "") + '">' +
|
||||
'<button type="submit">Send</button></form>' + replyReceipt(id);
|
||||
}
|
||||
function registeredText(reg) {
|
||||
if (!reg) return "no (not started through mosaic launch)";
|
||||
var parts = ["started " + esc(reg.startedAt || "—")];
|
||||
if (reg.updatedAt) parts.push("task updated " + esc(reg.updatedAt));
|
||||
if (reg.harness) parts.push("harness " + esc(reg.harness));
|
||||
if (reg.pid) parts.push("pid " + esc(reg.pid));
|
||||
if (reg.tmux && reg.tmux.session) parts.push("tmux " + esc(reg.tmux.session) + (reg.tmux.socket ? " (socket " + esc(reg.tmux.socket) + ")" : ""));
|
||||
if (reg.layout) parts.push(esc(reg.layout) + " layout");
|
||||
if (reg.alive === false) parts.push("stale: pid " + esc(reg.pid) + " is gone, so the derived values are shown");
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
function relaunchNotice(rec) { return rec.relaunchedAt ? 'relaunched at ' + esc(rec.relaunchedAt) + ', no messages since' : ''; }
|
||||
function buildRowPair(rec, showProject) {
|
||||
var idx = rowIdx++;
|
||||
var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/");
|
||||
var open = !!openDetails[key];
|
||||
var cls = "session-row" + (rec.state === "offline" ? " is-offline" : "");
|
||||
var notice = relaunchNotice(rec);
|
||||
var msg = notice ? '<span class="relaunch-notice">' + notice + '</span>' : rec.state === "error" && rec.lastError
|
||||
? '<span class="msg-error" title="' + esc(rec.lastError) + '">' + esc(rec.lastError) + "</span>"
|
||||
: '<span class="msg-text" title="' + esc(rec.lastAssistantText || "") + '">' + esc(rec.lastAssistantText || "—") + "</span>";
|
||||
var projectCell = showProject ? "<td>" + esc(rec.project) + "</td>" : "";
|
||||
// Gate A fields: from the seat's registration (mosaic launch), else derived
|
||||
// from the log and tmux, else "unknown". Never guessed. The source tag on
|
||||
// the row says which one is shown.
|
||||
var task = rec.task
|
||||
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>" + sourceTag(rec.taskSource) + setByTag(rec)
|
||||
: '<span class="unknown">unknown</span>';
|
||||
var activeProject = rec.activeProject
|
||||
? '<span title="' + esc(rec.workspace || "") + '">' + esc(rec.activeProject) + "</span>" + sourceTag(rec.activeProjectSource)
|
||||
: '<span class="unknown" title="' + esc(rec.workspace || "") + '">unknown</span>';
|
||||
var main =
|
||||
'<tr class="' + cls + '">' +
|
||||
projectCell +
|
||||
'<td><button type="button" class="row-toggle" data-idx="' + idx + '" data-key="' + esc(key) + '" aria-expanded="' + (open ? "true" : "false") + '" aria-controls="detail-' + idx + '">' + esc(rec.agent) + "</button>" + modelTag(rec) + "</td>" +
|
||||
"<td>" + badge(rec.state) + (rec.connector ? '<span class="unknown"> · ' + (rec.connector.braked === true ? 'braked (STOP)' : rec.connector.braked === false ? 'not braked' : 'brake unknown') + ' · owner ' + esc(rec.connector.ownerState) + '</span>' : '') + seenControl(rec) + "</td>" +
|
||||
"<td>" + (notice ? '<span class="relaunch-notice">' + notice + '</span>' : esc(humanAge(rec.ageSeconds))) + "</td>" +
|
||||
"<td>" + task + "</td>" +
|
||||
"<td>" + activeProject + "</td>" +
|
||||
"<td>" + msg + "</td>" +
|
||||
"</tr>";
|
||||
var span = showProject ? 7 : 6;
|
||||
var tmux = rec.tmux && rec.tmux.session
|
||||
? esc(rec.tmux.session) + (rec.tmux.socket ? " (socket " + esc(rec.tmux.socket) + ")" : "")
|
||||
: "—";
|
||||
var detail =
|
||||
'<tr class="detail-row" id="detail-' + idx + '"' + (open ? "" : " hidden") + '><td colspan="' + span + '">' +
|
||||
'<dl class="detail-list">' +
|
||||
"<dt>Session ID</dt><dd>" + esc(rec.sessionId || "—") + "</dd>" +
|
||||
"<dt>Session file</dt><dd>" + esc(rec.sessionFile || "—") + "</dd>" +
|
||||
"<dt>Task</dt><dd>" + (rec.task ? esc(rec.task) : "unknown") + fromSource(rec.taskSource) + "</dd>" +
|
||||
"<dt>Task set by</dt><dd>" + setByText(rec) + "</dd>" +
|
||||
"<dt>Active project</dt><dd>" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + fromSource(rec.activeProjectSource) + "</dd>" +
|
||||
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + fromSource(rec.workspaceSource) + "</dd>" +
|
||||
"<dt>Model</dt><dd>" + modelText(rec) + "</dd>" +
|
||||
"<dt>Registered</dt><dd>" + registeredText(rec.registered) + "</dd>" +
|
||||
"<dt>Reply</dt><dd>" + replyControl(rec) + "</dd>" +
|
||||
"<dt>Session cwd</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
|
||||
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
|
||||
(notice ? "<dt>Current activity</dt><dd>" + notice + "</dd>" : "") +
|
||||
"<dt>" + (notice ? "Historical last activity" : "Last activity") + "</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
|
||||
"<dt>Scanned at</dt><dd>" + esc(rec.scannedAt || "—") + "</dd>" +
|
||||
"<dt>Skipped lines</dt><dd>" + esc(rec.skippedLines) + "</dd>" +
|
||||
"<dt>" + (notice ? "Historical last message" : "Last message") + "</dt><dd>" + esc(rec.lastAssistantText || "—") + "</dd>" +
|
||||
"<dt>" + (notice ? "Historical last error" : "Last error") + "</dt><dd>" + esc(rec.lastError || "—") + "</dd>" +
|
||||
"</dl></td></tr>";
|
||||
return main + detail;
|
||||
}
|
||||
|
||||
// Rows you marked Seen, in one place, so they can be found and put back.
|
||||
// Collapsed by default; the open/closed choice survives a refresh.
|
||||
function renderSeen(data) {
|
||||
var list = (data.sessions || []).filter(function (r) { return r.seen; });
|
||||
list.sort(function (a, b) {
|
||||
var aa = a.ageSeconds === null || a.ageSeconds === undefined ? Infinity : a.ageSeconds;
|
||||
var bb = b.ageSeconds === null || b.ageSeconds === undefined ? Infinity : b.ageSeconds;
|
||||
return aa - bb;
|
||||
});
|
||||
seenHeading.textContent = "Seen (" + list.length + ")";
|
||||
if (list.length === 0) {
|
||||
seenBody.innerHTML = '<p class="empty">Nothing is marked seen. Rows you mark come back here, and return to "Waiting on you" on their own when the agent writes again.</p>';
|
||||
return;
|
||||
}
|
||||
var rows = list.map(function (r) { return buildRowPair(r, true); }).join("");
|
||||
seenBody.innerHTML =
|
||||
'<div class="table-wrap"><table><thead><tr>' +
|
||||
"<th scope=\"col\">Project</th><th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
|
||||
"</tr></thead><tbody>" + rows + "</tbody></table></div>";
|
||||
}
|
||||
|
||||
function renderWaiting(data) {
|
||||
var list = (data.sessions || []).filter(function (r) { return r.waitingOnYou; });
|
||||
list.sort(function (a, b) {
|
||||
var ae = a.state === "error" ? 0 : 1, be = b.state === "error" ? 0 : 1;
|
||||
if (ae !== be) return ae - be;
|
||||
var aa = a.ageSeconds === null || a.ageSeconds === undefined ? Infinity : a.ageSeconds;
|
||||
var bb = b.ageSeconds === null || b.ageSeconds === undefined ? Infinity : b.ageSeconds;
|
||||
return aa - bb;
|
||||
});
|
||||
if (list.length === 0) {
|
||||
waitingBody.innerHTML = '<p class="empty">Nothing is waiting on you.</p>';
|
||||
return;
|
||||
}
|
||||
var rows = list.map(function (r) { return buildRowPair(r, true); }).join("");
|
||||
waitingBody.innerHTML =
|
||||
'<div class="table-wrap"><table><thead><tr>' +
|
||||
"<th scope=\"col\">Project</th><th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
|
||||
"</tr></thead><tbody>" + rows + "</tbody></table></div>";
|
||||
}
|
||||
|
||||
function projectOrder(a, b) {
|
||||
if (a === "fleet" && b === "fleet") return 0;
|
||||
if (a === "fleet") return 1;
|
||||
if (b === "fleet") return -1;
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
function renderProjects(data) {
|
||||
var sessions = data.sessions || [];
|
||||
var byProject = {};
|
||||
sessions.forEach(function (r) {
|
||||
(byProject[r.project] = byProject[r.project] || []).push(r);
|
||||
});
|
||||
var projects = Object.keys(byProject).sort(projectOrder);
|
||||
if (projects.length === 0) {
|
||||
projectsBody.innerHTML = '<p class="empty">No agents are configured.</p>';
|
||||
return;
|
||||
}
|
||||
projectsBody.innerHTML = projects.map(function (project) {
|
||||
var group = byProject[project].slice().sort(function (a, b) { return a.agent.localeCompare(b.agent); });
|
||||
if (!(project in hideOfflineState)) hideOfflineState[project] = true;
|
||||
if (!(project in hideSeenState)) hideSeenState[project] = true;
|
||||
var hideOffline = hideOfflineState[project];
|
||||
var hideSeen = hideSeenState[project];
|
||||
var offlineHidden = 0, seenHidden = 0;
|
||||
var visible = group.filter(function (r) {
|
||||
if (hideOffline && r.state === "offline") { offlineHidden += 1; return false; }
|
||||
if (hideSeen && r.seen) { seenHidden += 1; return false; }
|
||||
return true;
|
||||
});
|
||||
var rows = visible.map(function (r) { return buildRowPair(r, false); }).join("");
|
||||
var noteParts = [];
|
||||
if (offlineHidden > 0) noteParts.push(offlineHidden + " offline hidden");
|
||||
if (seenHidden > 0) noteParts.push(seenHidden + " seen hidden");
|
||||
var note = noteParts.length > 0 ? '<p class="offline-note">' + noteParts.join(" \u00b7 ") + "</p>" : "";
|
||||
var headCount = visible.length < group.length ? visible.length + " of " + group.length : String(group.length);
|
||||
return (
|
||||
'<div class="project-group">' +
|
||||
'<div class="project-group-head"><h3>' + esc(project) + ' <span>(' + headCount + ")</span></h3>" +
|
||||
'<div class="group-toggles">' +
|
||||
'<label><input type="checkbox" class="hide-offline-toggle" data-project="' + esc(project) + '" ' + (hideOffline ? "checked" : "") + "> Hide offline</label>" +
|
||||
'<label><input type="checkbox" class="hide-seen-toggle" data-project="' + esc(project) + '" ' + (hideSeen ? "checked" : "") + "> Hide seen</label>" +
|
||||
"</div></div>" +
|
||||
'<div class="table-wrap"><table><thead><tr>' +
|
||||
"<th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
|
||||
"</tr></thead><tbody>" + (rows || '<tr><td colspan="6" class="empty">No agents.</td></tr>') + "</tbody></table></div>" +
|
||||
note + "</div>"
|
||||
);
|
||||
}).join("");
|
||||
}
|
||||
|
||||
|
||||
function renderFooter(data) {
|
||||
var counts = data.counts || {};
|
||||
var parts = STATES.map(function (s) { return cap(s) + " " + (counts[s] || 0); }).join(" · ");
|
||||
parts += " · Seen " + ((data.seen || []).length);
|
||||
footer.innerHTML = "<p>" + esc(parts) + " — generated " + esc(timeAgo(data.generatedAt)) + " (" + esc(data.generatedAt || "") + ")</p>";
|
||||
}
|
||||
|
||||
// A refresh rebuilds the tables. If Jason is typing a reply, put the
|
||||
// caret back where it was so the periodic refresh does not steal it.
|
||||
function withReplyFocus(render) {
|
||||
var active = document.activeElement;
|
||||
var focus = active && active.classList && active.classList.contains("reply-text")
|
||||
? { id: active.form.dataset.id, pos: active.selectionStart }
|
||||
: null;
|
||||
render();
|
||||
if (!focus) return;
|
||||
var forms = main.querySelectorAll('.reply-form[data-id="' + focus.id.replace(/"/g, '\\"') + '"]');
|
||||
var input = forms.length ? forms[0].querySelector(".reply-text") : null;
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
try { input.setSelectionRange(focus.pos, focus.pos); } catch (e) {}
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
withReplyFocus(function () {
|
||||
rowIdx = 0;
|
||||
renderWaiting(lastData);
|
||||
renderSeen(lastData);
|
||||
renderProjects(lastData);
|
||||
renderFooter(lastData);
|
||||
});
|
||||
}
|
||||
|
||||
function renderHeaderStatus() {
|
||||
var updated = lastData ? timeAgo(lastData.generatedAt) : "never";
|
||||
var refresh = paused ? "auto-refresh paused" : "next refresh in " + secondsLeft + "s";
|
||||
headerStatus.textContent = "Updated " + updated + " · " + refresh;
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
errorBanner.hidden = false;
|
||||
errorBanner.textContent = "Could not refresh: " + msg + ". Showing the last known data.";
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
errorBanner.hidden = true;
|
||||
errorBanner.textContent = "";
|
||||
}
|
||||
|
||||
function scheduleNextFetch() {
|
||||
clearTimeout(timerId);
|
||||
if (paused) return;
|
||||
secondsLeft = REFRESH_MS / 1000;
|
||||
timerId = setTimeout(runFetch, REFRESH_MS);
|
||||
}
|
||||
|
||||
function runFetch() {
|
||||
if (fetching) return;
|
||||
fetching = true;
|
||||
fetch("/api/board", { cache: "no-store" })
|
||||
.then(function (res) {
|
||||
return res.json().catch(function () {
|
||||
throw new Error("the server sent a response that was not valid JSON");
|
||||
}).then(function (body) {
|
||||
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
|
||||
return body;
|
||||
});
|
||||
})
|
||||
.then(function (body) {
|
||||
lastData = body;
|
||||
hideError();
|
||||
renderAll();
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error("control board refresh failed", err);
|
||||
showError(err && err.message ? err.message : "unknown error");
|
||||
})
|
||||
.then(function () {
|
||||
fetching = false;
|
||||
renderHeaderStatus();
|
||||
scheduleNextFetch();
|
||||
});
|
||||
}
|
||||
|
||||
function setPaused(p) {
|
||||
paused = p;
|
||||
pauseBtn.textContent = paused ? "Resume" : "Pause";
|
||||
pauseBtn.setAttribute("aria-pressed", String(paused));
|
||||
if (paused) clearTimeout(timerId);
|
||||
else scheduleNextFetch();
|
||||
renderHeaderStatus();
|
||||
}
|
||||
|
||||
function postSeen(btn) {
|
||||
if (fetching) return;
|
||||
fetching = true;
|
||||
btn.disabled = true;
|
||||
fetch("/api/seen", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ project: btn.dataset.project, agent: btn.dataset.agent, lastActivity: btn.dataset.last, seen: btn.dataset.seen === "true" })
|
||||
})
|
||||
.then(function (res) {
|
||||
return res.json().catch(function () {
|
||||
throw new Error("the server sent a response that was not valid JSON");
|
||||
}).then(function (body) {
|
||||
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
|
||||
return body;
|
||||
});
|
||||
})
|
||||
.then(function (body) {
|
||||
lastData = body;
|
||||
hideError();
|
||||
renderAll();
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error("marking seen failed", err);
|
||||
showError(err && err.message ? err.message : "unknown error");
|
||||
btn.disabled = false;
|
||||
})
|
||||
.then(function () {
|
||||
fetching = false;
|
||||
renderHeaderStatus();
|
||||
scheduleNextFetch();
|
||||
});
|
||||
}
|
||||
|
||||
function postReply(form) {
|
||||
var id = form.dataset.id;
|
||||
var input = form.querySelector(".reply-text");
|
||||
var text = input.value;
|
||||
if (!text.trim()) return;
|
||||
var sendBtn = form.querySelector("button");
|
||||
sendBtn.disabled = true;
|
||||
fetch("/api/reply", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ agent: id, text: text })
|
||||
})
|
||||
.then(function (res) {
|
||||
return res.json().catch(function () {
|
||||
throw new Error("the server sent a response that was not valid JSON");
|
||||
}).then(function (body) {
|
||||
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
|
||||
return body;
|
||||
});
|
||||
})
|
||||
.then(function (body) {
|
||||
replyReceipts[id] = body;
|
||||
if (body.delivered) delete replyDrafts[id];
|
||||
renderAll();
|
||||
// The seat's log shows the effect; fetch now instead of waiting a tick.
|
||||
if (body.delivered && !paused) runFetch();
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error("reply failed", err);
|
||||
replyReceipts[id] = { delivered: false, exitCode: null, error: err && err.message ? err.message : "unknown error" };
|
||||
renderAll();
|
||||
});
|
||||
}
|
||||
|
||||
main.addEventListener("submit", function (e) {
|
||||
var form = e.target.closest(".reply-form");
|
||||
if (!form) return;
|
||||
e.preventDefault();
|
||||
postReply(form);
|
||||
});
|
||||
|
||||
main.addEventListener("input", function (e) {
|
||||
var input = e.target.closest(".reply-text");
|
||||
if (!input) return;
|
||||
replyDrafts[input.form.dataset.id] = input.value;
|
||||
});
|
||||
|
||||
main.addEventListener("click", function (e) {
|
||||
var seenBtn = e.target.closest(".seen-toggle");
|
||||
if (seenBtn) return postSeen(seenBtn);
|
||||
var btn = e.target.closest(".row-toggle");
|
||||
if (!btn) return;
|
||||
var detail = document.getElementById("detail-" + btn.dataset.idx);
|
||||
var expanded = btn.getAttribute("aria-expanded") === "true";
|
||||
btn.setAttribute("aria-expanded", String(!expanded));
|
||||
detail.hidden = expanded;
|
||||
if (expanded) delete openDetails[btn.dataset.key];
|
||||
else openDetails[btn.dataset.key] = true;
|
||||
});
|
||||
|
||||
main.addEventListener("change", function (e) {
|
||||
var cb = e.target.closest(".hide-offline-toggle, .hide-seen-toggle");
|
||||
if (!cb) return;
|
||||
var stateMap = cb.classList.contains("hide-seen-toggle") ? hideSeenState : hideOfflineState;
|
||||
stateMap[cb.dataset.project] = cb.checked;
|
||||
renderProjects(lastData);
|
||||
});
|
||||
|
||||
refreshBtn.addEventListener("click", runFetch);
|
||||
pauseBtn.addEventListener("click", function () { setPaused(!paused); });
|
||||
|
||||
setInterval(function () {
|
||||
if (!paused && secondsLeft > 0) secondsLeft -= 1;
|
||||
renderHeaderStatus();
|
||||
}, 1000);
|
||||
|
||||
renderHeaderStatus();
|
||||
runFetch();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -16,7 +16,7 @@
|
||||
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, renameSync } from "node:fs";
|
||||
import { join, basename, isAbsolute, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { spawnSync, execFile } from "node:child_process";
|
||||
import { readRegistration, samePath, SeatError, LAYOUTS } from "../../seat/src/seat.mjs";
|
||||
|
||||
import { discoverDiscordAgents, inspectDiscord, newestDiscordSession } from './discord.mjs';
|
||||
@@ -193,17 +193,46 @@ export function parsePanes(listPanesOutput) {
|
||||
// "unknown", never assumed alive).
|
||||
// workspace the current path of the first pane running pi, else null.
|
||||
// `exec` is injectable for tests.
|
||||
export function tmuxInspect({ socket, session }, { exec = spawnSync } = {}) {
|
||||
export function tmuxInspect(tmux, { exec = spawnSync } = {}) {
|
||||
return inspectFromResult(exec("tmux", tmuxArgs(tmux), { encoding: "utf8", timeout: TMUX_TIMEOUT_MS }));
|
||||
}
|
||||
|
||||
const TMUX_TIMEOUT_MS = 5000;
|
||||
|
||||
function tmuxArgs({ socket, session }) {
|
||||
const args = [];
|
||||
if (socket) args.push("-L", socket);
|
||||
args.push("list-panes", "-s", "-t", `=${session}`, "-F", "#{pane_current_command}\t#{pane_current_path}");
|
||||
const r = exec("tmux", args, { encoding: "utf8", timeout: 5000 });
|
||||
return args;
|
||||
}
|
||||
|
||||
// Shared by the sync and async paths, so they cannot drift.
|
||||
function inspectFromResult(r) {
|
||||
if (r.error) return { alive: null, workspace: null };
|
||||
if (r.status !== 0) return { alive: false, workspace: null };
|
||||
const pane = parsePanes(r.stdout ?? "").find((p) => PI_COMMANDS.includes(p.command));
|
||||
return { alive: Boolean(pane), workspace: pane?.path ?? null };
|
||||
}
|
||||
|
||||
// Async child run for the Console's in-process board (B1). Same result shape as
|
||||
// spawnSync: { status, signal, stdout, stderr, error }. A timeout or a failure to
|
||||
// start is an error, never a status. The Console's event loop must not block here.
|
||||
export function runAsync(file, args, { encoding = "utf8", timeout, env } = {}) {
|
||||
return new Promise((resolvePromise) => {
|
||||
execFile(file, args, { encoding, timeout, env, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
|
||||
if (!err) return resolvePromise({ status: 0, signal: null, stdout, stderr, error: undefined });
|
||||
if (err.killed) return resolvePromise({ status: null, signal: err.signal ?? null, stdout, stderr, error: Object.assign(new Error(`timed out after ${timeout} ms`), { code: "ETIMEDOUT" }) });
|
||||
if (typeof err.code === "number") return resolvePromise({ status: err.code, signal: null, stdout, stderr, error: undefined });
|
||||
return resolvePromise({ status: null, signal: null, stdout, stderr, error: err });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Async twin of tmuxInspect. `exec` is injectable for tests.
|
||||
export async function tmuxInspectAsync(tmux, { exec = runAsync } = {}) {
|
||||
return inspectFromResult(await exec("tmux", tmuxArgs(tmux), { encoding: "utf8", timeout: TMUX_TIMEOUT_MS }));
|
||||
}
|
||||
|
||||
// Liveness only, for callers that do not need the pane path.
|
||||
export function tmuxIsAlive(tmux, opts) {
|
||||
return tmuxInspect(tmux, opts).alive;
|
||||
@@ -462,3 +491,16 @@ export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAliv
|
||||
writeAtomic(join(boardDir, "index.json"), index);
|
||||
return index;
|
||||
}
|
||||
|
||||
// Async scan for the Console's in-process board (B1). With no isAlive, every tmux
|
||||
// check runs through the async exec first, in parallel, and the sync scan body
|
||||
// then reads those answers. A caller's sync isAlive (the CLI, tests) is used as is.
|
||||
// One scan body serves both paths, so the records cannot drift.
|
||||
export async function scanAsync(specs, { isAlive, exec = runAsync, ...rest } = {}) {
|
||||
if (isAlive) return scan(specs, { ...rest, isAlive });
|
||||
const checks = new Map();
|
||||
const keyOf = (tmux) => JSON.stringify([tmux.socket ?? null, tmux.session]);
|
||||
const pending = [...new Map(specs.filter((s) => !s.connector).map((s) => [keyOf(s.tmux), s.tmux])).values()];
|
||||
await Promise.all(pending.map(async (tmux) => checks.set(keyOf(tmux), await tmuxInspectAsync(tmux, { exec }))));
|
||||
return scan(specs, { ...rest, isAlive: (tmux) => checks.get(keyOf(tmux)) ?? { alive: null, workspace: null } });
|
||||
}
|
||||
@@ -1,55 +1,30 @@
|
||||
// Control board step 2: a tiny local web server. No dependencies, no auth.
|
||||
// It only ever binds to a loopback address (fail closed otherwise).
|
||||
// Control board: the pure board logic the Console calls in-process (row 198).
|
||||
// No listener. The writer on 7331 is retired; the Console serves the board
|
||||
// routes behind its own auth (packages/webui/src/serve.mjs). The board opens no
|
||||
// socket and never binds a port.
|
||||
//
|
||||
// GET / the page (src/page.html)
|
||||
// GET /api/board re-runs the scanner and returns index.json as JSON
|
||||
// POST /api/seen {project, agent, lastActivity, seen?} marks a row as seen
|
||||
// (or clears the mark with seen:false); rescans, returns index
|
||||
// POST /api/reply {agent: "<project>/<agent>", text} delivers text to the
|
||||
// seat's tmux pane through tools/tmux/agent-send.sh (#1505);
|
||||
// answers {delivered, exitCode, stdout, stderr, ...}
|
||||
// GET /healthz {"ok":true}
|
||||
// GET /api/conversations
|
||||
// read-only Pi conversation catalogue (#1507, CHAT-02)
|
||||
// GET /api/conversation?id=<conversation>[&branch=<branch>][&cursor=<cursor>]
|
||||
// one CHAT-01 history page: the first page of a branch (the
|
||||
// default one without branch), or the next page (or a
|
||||
// follow) for a cursor. A cursor call repeats the page's
|
||||
// branch; without it the answer is 400. Refusals carry
|
||||
// {error, refusal: {code, reconcile}}.
|
||||
//
|
||||
// POST requires Content-Type: application/json. A plain form post from another
|
||||
// site in the browser cannot set that header without a CORS preflight, and this
|
||||
// server answers no preflight, so a stray page cannot flip marks.
|
||||
// Route logic lives in src/board.mjs. This module keeps the pure helpers the
|
||||
// Console and the tests share: reply planning and shaping, the Host/Origin check
|
||||
// (foreignRequest), and the conversation query and refusal map.
|
||||
//
|
||||
// Every route first checks Host and Origin (#1507). Binding to loopback does
|
||||
// not stop DNS rebinding: a page whose name now resolves to 127.0.0.1 reaches
|
||||
// this server as its own origin, with its own name as Host, and could read
|
||||
// /api/board or post /api/reply into a live pane. A Host that is not a loopback
|
||||
// name on this server's port, or any Origin other than this server's own, gets
|
||||
// 403 before anything else runs. Same check as packages/webui/src/serve.mjs.
|
||||
// a Console on this host as its own origin, with its own name as Host, and could
|
||||
// read the board or post /api/reply into a live pane. A Host that is not a
|
||||
// loopback name on the listening port, or any Origin other than the server's
|
||||
// own, gets 403 before anything else runs. The Console applies the same check.
|
||||
// No CORS headers are ever sent.
|
||||
//
|
||||
// Every /api/board request rescans, so the page is never staler than its
|
||||
// refresh timer. The scan rewrites the derived board files as a side effect.
|
||||
//
|
||||
// The conversation routes read only: packages/conversation lists the repository
|
||||
// specs' Pi session roots (never fleet or connector ones), opens files
|
||||
// O_NOFOLLOW and writes nothing. Registrations are hints there too. The
|
||||
// conversation id is opaque and no path comes from the request. Cursors live
|
||||
// in this server's memory, bound to the one actor this unauthenticated
|
||||
// loopback route has, local-operator.
|
||||
// conversation id is opaque and no path comes from the request.
|
||||
|
||||
import { createServer as createHttpServer } from "node:http";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { isIP } from "node:net";
|
||||
import { hostname } from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { scan, markSeen, seenKey, ConfigError, loadRegistrations } from "./scan.mjs";
|
||||
import { createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs";
|
||||
|
||||
const MAX_BODY = 4096;
|
||||
import { resolve } from "node:path";
|
||||
import { seenKey, runAsync } from "./scan.mjs";
|
||||
|
||||
// Reply-from-board (#1505). The board owns no transport: a reply is handed to
|
||||
// the repository's own inter-agent channel, tools/tmux/agent-send.sh, which
|
||||
@@ -71,27 +46,48 @@ const REPLY_TIMEOUT_MS = 15000;
|
||||
// Refusals (4xx) happen before the tool runs and carry { error }. Once the
|
||||
// tool has run the answer is 200 with delivered true/false, the exit code and
|
||||
// both output streams verbatim, whatever the code was.
|
||||
export function replyToRow({ index, key, text, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync, now = () => new Date(), host = hostname().split(".")[0] }) {
|
||||
if (typeof key !== "string" || !key) return { status: 400, body: { error: "agent must be the row id <project>/<agent>" } };
|
||||
if (typeof text !== "string" || !text.trim()) return { status: 400, body: { error: "text must be a non-empty string" } };
|
||||
if (text.length > REPLY_LIMIT) return { status: 400, body: { error: `text is longer than ${REPLY_LIMIT} characters` } };
|
||||
export function replyToRow(opts) {
|
||||
return replyWith(opts, (exec, args, env, agentSend) => exec(agentSend, args, { encoding: "utf8", timeout: REPLY_TIMEOUT_MS, env }), { exec: spawnSync });
|
||||
}
|
||||
|
||||
// Async twin of replyToRow for the in-process board (B1). `exec` is async.
|
||||
export async function replyToRowAsync(opts) {
|
||||
return replyWith(opts, async (exec, args, env, agentSend) => exec(agentSend, args, { encoding: "utf8", timeout: REPLY_TIMEOUT_MS, env }), { exec: runAsync, async: true });
|
||||
}
|
||||
|
||||
function replyWith({ index, key, text, agentSend = DEFAULT_AGENT_SEND, exec, now = () => new Date(), host = hostname().split(".")[0] }, send, { exec: defaultExec, async = false }) {
|
||||
const plan = planReply({ index, key, text, host });
|
||||
if (plan.refusal) return async ? Promise.resolve(plan.refusal) : plan.refusal;
|
||||
// The registration says which socket the seat is on. A launcher-exported
|
||||
// MOSAIC_TMUX_SOCKET in this server's own environment must not override it.
|
||||
const env = { ...process.env };
|
||||
delete env.MOSAIC_TMUX_SOCKET;
|
||||
const finish = (r) => replyBody({ r, key, agentSend, session: plan.session, socket: plan.socket, now });
|
||||
const out = send(exec ?? defaultExec, plan.args, env, agentSend);
|
||||
return async ? Promise.resolve(out).then(finish) : finish(out);
|
||||
}
|
||||
|
||||
function planReply({ index, key, text, host }) {
|
||||
const refuse = (status, error) => ({ refusal: { status, body: { error } } });
|
||||
if (typeof key !== "string" || !key) return refuse(400, "agent must be the row id <project>/<agent>");
|
||||
if (typeof text !== "string" || !text.trim()) return refuse(400, "text must be a non-empty string");
|
||||
if (text.length > REPLY_LIMIT) return refuse(400, `text is longer than ${REPLY_LIMIT} characters`);
|
||||
const rec = index.sessions.find((r) => seenKey(r) === key);
|
||||
if (!rec) return { status: 404, body: { error: `unknown row: ${key}` } };
|
||||
if (rec.connector || / \(discord: [a-z0-9][a-z0-9._-]{0,63}\)$/.test(rec.agent)) return { status: 409, body: { error: "board replies are disabled for Discord connectors" } };
|
||||
if (!rec) return refuse(404, `unknown row: ${key}`);
|
||||
if (rec.connector || / \(discord: [a-z0-9][a-z0-9._-]{0,63}\)$/.test(rec.agent)) return refuse(409, "board replies are disabled for Discord connectors");
|
||||
const reg = rec.registered;
|
||||
if (!reg) return { status: 409, body: { error: "reply needs a registered seat (start it through scripts/mosaic launch)" } };
|
||||
if (reg.alive === false) return { status: 409, body: { error: `registration is stale: pid ${reg.pid} is gone` } };
|
||||
if (!reg.tmux || !reg.tmux.session) return { status: 409, body: { error: "registration has no tmux session to address" } };
|
||||
if (!reg) return refuse(409, "reply needs a registered seat (start it through scripts/mosaic launch)");
|
||||
if (reg.alive === false) return refuse(409, `registration is stale: pid ${reg.pid} is gone`);
|
||||
if (!reg.tmux || !reg.tmux.session) return refuse(409, "registration has no tmux session to address");
|
||||
const session = reg.tmux.session;
|
||||
const socket = reg.tmux.socket || null;
|
||||
const args = ["-s", session, "-S", `${host}:${REPLY_SENDER}`];
|
||||
if (socket) args.push("-L", socket);
|
||||
args.push("-m", `${text}\n${REPLY_TRAILER}`);
|
||||
// The registration says which socket the seat is on. A launcher-exported
|
||||
// MOSAIC_TMUX_SOCKET in this server's own environment must not override it.
|
||||
const env = { ...process.env };
|
||||
delete env.MOSAIC_TMUX_SOCKET;
|
||||
const r = exec(agentSend, args, { encoding: "utf8", timeout: REPLY_TIMEOUT_MS, env });
|
||||
return { args, session, socket };
|
||||
}
|
||||
|
||||
function replyBody({ r, key, agentSend, session, socket, now }) {
|
||||
if (r.error) return { status: 500, body: { error: `could not run ${agentSend}: ${r.error.message}` } };
|
||||
const exitCode = r.status;
|
||||
return {
|
||||
@@ -110,39 +106,6 @@ export function replyToRow({ index, key, text, agentSend = DEFAULT_AGENT_SEND, e
|
||||
};
|
||||
}
|
||||
|
||||
function sendJson(res, status, body) {
|
||||
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
||||
res.end(JSON.stringify(body) + "\n");
|
||||
}
|
||||
|
||||
function readJsonBody(req) {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const type = String(req.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
|
||||
if (type !== "application/json") return reject(new Error("Content-Type must be application/json"));
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
req.on("data", (c) => {
|
||||
size += c.length;
|
||||
if (size > MAX_BODY) {
|
||||
req.destroy();
|
||||
reject(new Error(`body larger than ${MAX_BODY} bytes`));
|
||||
return;
|
||||
}
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("body must be a JSON object");
|
||||
resolvePromise(parsed);
|
||||
} catch (err) {
|
||||
reject(new Error(`invalid JSON body: ${err.message}`));
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
|
||||
|
||||
export function isLoopbackHost(host) {
|
||||
@@ -187,11 +150,6 @@ export const REFUSAL_STATUS = {
|
||||
};
|
||||
const QUERY_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
function sendConversationJson(res, status, body) {
|
||||
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
||||
res.end(JSON.stringify(body) + "\n");
|
||||
}
|
||||
|
||||
// GET /api/conversation: id is required; branch and cursor are optional; any
|
||||
// other, repeated or malformed parameter is a 400.
|
||||
function conversationQuery(url) {
|
||||
@@ -220,103 +178,3 @@ export function conversationResponse(reader, url) {
|
||||
if (out.ok) return { status: 200, body: out };
|
||||
return { status: REFUSAL_STATUS[out.refusal.code] ?? 422, body: { error: out.refusal.message, refusal: { code: out.refusal.code, reconcile: out.refusal.reconcile } } };
|
||||
}
|
||||
|
||||
export function loadPage(path = join(import.meta.dirname, "page.html")) {
|
||||
return readFileSync(path, "utf8");
|
||||
}
|
||||
|
||||
// specs: agent specs to scan on each request. boardDir: where scan writes.
|
||||
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync, conversationReader = null }) {
|
||||
const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive, discordDataRoot });
|
||||
const reader = conversationReader ?? createReader({ roots: () => rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations) });
|
||||
return createHttpServer((req, res) => {
|
||||
const refused = foreignRequest(req);
|
||||
if (refused) {
|
||||
req.resume();
|
||||
return sendJson(res, 403, { error: refused });
|
||||
}
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
if (req.method === "POST" && url.pathname === "/api/reply") {
|
||||
return readJsonBody(req)
|
||||
.then((body) => {
|
||||
let index;
|
||||
try {
|
||||
index = rescan();
|
||||
} catch (err) {
|
||||
return sendJson(res, 500, { error: err.message });
|
||||
}
|
||||
const out = replyToRow({ index, key: body.agent, text: body.text, agentSend, exec, now });
|
||||
sendJson(res, out.status, out.body);
|
||||
})
|
||||
.catch((err) => sendJson(res, 400, { error: err.message }));
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/seen") {
|
||||
return readJsonBody(req)
|
||||
.then((body) => {
|
||||
try {
|
||||
markSeen(boardDir, { project: body.project, agent: body.agent, lastActivity: body.lastActivity, seen: body.seen ?? true });
|
||||
} catch (err) {
|
||||
return sendJson(res, 400, { error: err.message });
|
||||
}
|
||||
try {
|
||||
sendJson(res, 200, rescan());
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { error: err.message });
|
||||
}
|
||||
})
|
||||
.catch((err) => sendJson(res, 400, { error: err.message }));
|
||||
}
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
res.writeHead(405, { "content-type": "text/plain" });
|
||||
return res.end("method not allowed\n");
|
||||
}
|
||||
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
||||
return res.end(page);
|
||||
}
|
||||
if (url.pathname === "/api/board") {
|
||||
let index;
|
||||
try {
|
||||
index = rescan();
|
||||
} catch (err) {
|
||||
res.writeHead(500, { "content-type": "application/json", "cache-control": "no-store" });
|
||||
return res.end(JSON.stringify({ error: err.message }) + "\n");
|
||||
}
|
||||
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
||||
return res.end(JSON.stringify(index) + "\n");
|
||||
}
|
||||
if (url.pathname === "/api/conversations" || url.pathname === "/api/conversation") {
|
||||
let out;
|
||||
try {
|
||||
out = conversationResponse(reader, url);
|
||||
} catch (err) {
|
||||
process.stderr.write(`conversation read failed: ${err.message}\n`);
|
||||
out = { status: 500, body: { error: "conversation read failed" } };
|
||||
}
|
||||
return sendConversationJson(res, out.status, out.body);
|
||||
}
|
||||
if (url.pathname === "/favicon.ico") {
|
||||
res.writeHead(204);
|
||||
return res.end();
|
||||
}
|
||||
if (url.pathname === "/healthz") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end('{"ok":true}\n');
|
||||
}
|
||||
res.writeHead(404, { "content-type": "text/plain" });
|
||||
res.end("not found\n");
|
||||
});
|
||||
}
|
||||
|
||||
// Resolves to the listening server. Refuses any non-loopback host.
|
||||
export async function startServer({ host = "127.0.0.1", port = 7331, ...rest }) {
|
||||
if (!isLoopbackHost(host)) throw new ConfigError(`refusing to bind to non-loopback host: ${host} (no auth in the MVP)`);
|
||||
const server = createServer(rest);
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, host, () => {
|
||||
server.off("error", reject);
|
||||
resolvePromise(server);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, appendFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createBoard } from "../src/board.mjs";
|
||||
|
||||
const pkgRoot = resolve(import.meta.dirname, "..");
|
||||
const cli = join(pkgRoot, "src", "cli.mjs");
|
||||
|
||||
// Track every tmpdir we create so a stray failure never leaves fixtures behind.
|
||||
const roots = [];
|
||||
function makeRoot() {
|
||||
const root = mkdtempSync(join(tmpdir(), "control-board-board-test-"));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
after(() => {
|
||||
for (const root of roots) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// One agent whose last message is an assistant reply (waiting on the user), so
|
||||
// a mark on it is visible in the index. No registration; liveness stubbed so no tmux runs.
|
||||
function fixture() {
|
||||
const root = makeRoot();
|
||||
const sessionsDir = join(root, "sessions");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(sessionsDir, "session.jsonl"),
|
||||
[
|
||||
JSON.stringify({ type: "session", id: "s1", timestamp: "2026-10-20T18:59:00Z", cwd: root }),
|
||||
JSON.stringify({ type: "message", timestamp: "2026-10-20T19:00:00Z", message: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "Input needed: which branch should I use?" }] } }),
|
||||
].join("\n") + "\n",
|
||||
);
|
||||
const boardDir = join(root, "board");
|
||||
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
|
||||
const isAlive = () => ({ alive: true, workspace: null });
|
||||
return { root, boardDir, specs, isAlive };
|
||||
}
|
||||
|
||||
// Pinned order (Webby, Mos mos-1052): markSeen writes, bumps the counter, then scans.
|
||||
test("markSeen runs write, bump, scan in that order", async () => {
|
||||
const { boardDir, specs, isAlive } = fixture();
|
||||
const events = [];
|
||||
const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name) });
|
||||
await board.markSeen({ project: "proj", agent: "agent1", lastActivity: "2026-10-20T19:00:00Z" });
|
||||
assert.deepEqual(events, ["write", "bump", "scan"]);
|
||||
});
|
||||
|
||||
// Mutant target: a scan run before the write, or a join of a pre-write scan,
|
||||
// would let the answer omit the mark it was asked to show.
|
||||
test("markSeen answer includes the mark it just wrote", async () => {
|
||||
const { boardDir, specs, isAlive } = fixture();
|
||||
const board = createBoard({ specs, boardDir, isAlive });
|
||||
const before = await board.index();
|
||||
assert.deepEqual(before.seen, []);
|
||||
const rec = before.sessions[0];
|
||||
const index = await board.markSeen({ project: "proj", agent: "agent1", lastActivity: rec.lastActivity });
|
||||
assert.deepEqual(index.seen, ["proj/agent1"]);
|
||||
});
|
||||
|
||||
// A reply to a row that is not registered is refused before any send, so the
|
||||
// send and bump events must not appear.
|
||||
test("reply refuses an unregistered row and never sends", async () => {
|
||||
const { boardDir, specs, isAlive } = fixture();
|
||||
const events = [];
|
||||
const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name), exec: async () => assert.fail("no send on refusal") });
|
||||
const out = await board.reply({ agent: "proj/agent1", text: "hello" });
|
||||
assert.equal(out.status, 409);
|
||||
assert.equal(events.includes("send"), false);
|
||||
assert.equal(events.includes("bump"), false);
|
||||
});
|
||||
|
||||
// The retired writer refuses to start. Exit 2 with a pointer to the Console.
|
||||
test("cli serve refuses and points to the Console", () => {
|
||||
const r = spawnSync(process.execPath, [cli, "serve"], { encoding: "utf8" });
|
||||
assert.equal(r.status, 2);
|
||||
assert.match(r.stderr, /retired on 7331/);
|
||||
assert.match(r.stderr, /webui/);
|
||||
});
|
||||
|
||||
// M15 (Webby N1): index() must not join a scan that started before the last write.
|
||||
// A scan already answered before the mark would hide the mark; the join is what
|
||||
// this kills, so the event log must show a fresh scan after the write.
|
||||
test("index after a write starts a fresh scan and sees the mark (M15)", async () => {
|
||||
const { boardDir, specs, isAlive } = fixture();
|
||||
const events = [];
|
||||
const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name) });
|
||||
await board.index();
|
||||
const before = events.length;
|
||||
await board.markSeen({ project: "proj", agent: "agent1", lastActivity: "2026-10-20T19:00:00Z" });
|
||||
const after = await board.index();
|
||||
assert.deepEqual(events.slice(before), ["write", "bump", "scan", "scan"]);
|
||||
assert.deepEqual(after.seen, ["proj/agent1"]);
|
||||
});
|
||||
|
||||
// B4 (Webby r3): a settled scan is not in flight. With no write between two index()
|
||||
// calls, the second must start a fresh scan and show activity appended in between.
|
||||
// The mutant: drop the clear on settle in index(); the second call then joins the
|
||||
// settled promise, logs one scan, and shows the first view.
|
||||
test("index after a settled scan starts a fresh scan and shows new activity (B4)", async () => {
|
||||
const { boardDir, specs, isAlive } = fixture();
|
||||
const events = [];
|
||||
const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name) });
|
||||
await board.index();
|
||||
appendFileSync(join(specs[0].sessionsDir, "session.jsonl"), JSON.stringify({ type: "message", timestamp: "2026-10-20T19:05:00Z", message: { role: "user", content: [{ type: "text", text: "new activity" }] } }) + "\n");
|
||||
const second = await board.index();
|
||||
assert.deepEqual(events, ["scan", "scan"]);
|
||||
assert.equal(second.sessions[0].lastActivity, "2026-10-20T19:05:00Z");
|
||||
});
|
||||
|
||||
// B4 (Webby r3): a scan that rejects is cleared too. The next index() runs fresh
|
||||
// and answers, rather than replaying the rejection on every poll.
|
||||
test("index after a rejected scan starts a fresh scan (B4)", async () => {
|
||||
const { boardDir, specs, isAlive } = fixture();
|
||||
const events = [];
|
||||
let fail = true;
|
||||
const flaky = (tmux) => {
|
||||
if (fail) throw new Error("boom");
|
||||
return isAlive(tmux);
|
||||
};
|
||||
const board = createBoard({ specs, boardDir, isAlive: flaky, onEvent: (name) => events.push(name) });
|
||||
await assert.rejects(board.index(), /boom/);
|
||||
fail = false;
|
||||
const answer = await board.index();
|
||||
assert.deepEqual(events, ["scan", "scan"]);
|
||||
assert.equal(answer.sessions[0].agent, "agent1");
|
||||
});
|
||||
@@ -6,7 +6,8 @@ import { spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { discoverDiscordAgents, inspectDiscord } from '../src/discord.mjs';
|
||||
import { scan, scanAgent } from '../src/scan.mjs';
|
||||
import { replyToRow, startServer } from '../src/serve.mjs';
|
||||
import { replyToRow } from '../src/serve.mjs';
|
||||
import { createBoard } from '../src/board.mjs';
|
||||
import { identityOf } from '../../discord/src/journal.mjs';
|
||||
import { envelope } from '../../discord/src/context.mjs';
|
||||
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
|
||||
@@ -115,21 +116,20 @@ test('connector reply refusal precedes forged live tmux registration; ordinary a
|
||||
r.agent = 'ordinary'; assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/ordinary', text: 'hello', exec }).status, 200); assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('server rescans connector discovery and refuses HTTP reply without transport', async () => {
|
||||
const f = fixture(); let server;
|
||||
test('board rescans connector discovery and refuses reply without transport', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const sessionsDir = join(f.root, 'sessions', 'discord-demo'), seatsDir = join(f.root, 'seats');
|
||||
writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'forged', project: 'fleet', sessionsDir, seatDir: sessionsDir, launchScript: join(f.root, 'unused.sh'), layout: 'repo', defaultWorkspace: f.root }, task: 'forged', tmux: { session: 'forged', socket: null }, pid: process.pid }));
|
||||
server = await startServer({ port: 0, specs: [], seatsDir, boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } });
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
let response = await fetch(base + '/api/board'); const board = await response.json();
|
||||
assert.equal(board.sessions[0].state, 'idle'); assert.equal(board.sessions[0].connector.alive, true);
|
||||
assert.equal(board.sessions[0].registered, null); assert.notEqual(board.sessions[0].task, 'forged');
|
||||
response = await fetch(base + '/api/reply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'fleet/researcher (discord: demo)', text: 'hello' }) });
|
||||
assert.equal(response.status, 409);
|
||||
const board = createBoard({ specs: [], seatsDir, boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } });
|
||||
const payload = await board.index();
|
||||
assert.equal(payload.sessions[0].state, 'idle'); assert.equal(payload.sessions[0].connector.alive, true);
|
||||
assert.equal(payload.sessions[0].registered, null); assert.notEqual(payload.sessions[0].task, 'forged');
|
||||
const { status } = await board.reply({ agent: 'fleet/researcher (discord: demo)', text: 'hello' });
|
||||
assert.equal(status, 409);
|
||||
rmSync(join(f.root, 'discord', 'demo.json'));
|
||||
assert.equal((await (await fetch(base + '/api/board')).json()).sessions.length, 0);
|
||||
} finally { if (server) await new Promise(r => server.close(r)); f.cleanup(); }
|
||||
assert.equal((await board.index()).sessions.length, 0);
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
|
||||
test('connector session links and linked directories are not read', () => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
// Row 198: the 7331 writer is retired and the board runs in the Console. These
|
||||
// are source and doc checks; each names the design mutant it kills (M1, M2, M9).
|
||||
const repo = resolve(import.meta.dirname, "..", "..", "..");
|
||||
const read = (path) => readFileSync(join(repo, path), "utf8");
|
||||
const srcDir = join(repo, "packages", "control-board", "src");
|
||||
const srcFiles = readdirSync(srcDir).filter((f) => f.endsWith(".mjs"));
|
||||
|
||||
// M1: cli.mjs starts no server, not even on the retired port.
|
||||
test("retired: cli.mjs starts no server (M1)", () => {
|
||||
assert.doesNotMatch(read("packages/control-board/src/cli.mjs"), /startServer|createServer|\.listen\(/);
|
||||
});
|
||||
|
||||
// M2: nothing in the board's source opens a socket or exports a server.
|
||||
test("retired: no board source imports http or exports a server (M2)", () => {
|
||||
for (const file of srcFiles) {
|
||||
const text = read(join("packages", "control-board", "src", file));
|
||||
assert.doesNotMatch(text, /from ["']node:http["']|createServer\(|\.listen\(/, file);
|
||||
assert.doesNotMatch(text, /export (async )?function (startServer|createServer)\b/, file);
|
||||
}
|
||||
});
|
||||
|
||||
// M9: a live doc or script must not tell anyone to run the retired serve or open 7331.
|
||||
// A line that says it is retired is allowed. Append-only logs and plans are not scanned.
|
||||
test("retired: live docs and scripts do not tell anyone to run control-board serve (M9)", () => {
|
||||
const liveDocs = ["README.md", "AGENTS.md"];
|
||||
for (const pkg of readdirSync(join(repo, "packages"))) {
|
||||
if (existsSync(join(repo, "packages", pkg, "README.md"))) liveDocs.push(join("packages", pkg, "README.md"));
|
||||
}
|
||||
for (const file of readdirSync(join(repo, "scripts"))) {
|
||||
if (file.endsWith(".sh")) liveDocs.push(join("scripts", file));
|
||||
}
|
||||
const retiredUse = /control-board\/src\/cli\.mjs serve|control-board serve\b|127\.0\.0\.1:7331/;
|
||||
const retiredNote = /retired|no longer|exits 2/i;
|
||||
for (const file of liveDocs) {
|
||||
read(file).split("\n").forEach((line, i) => {
|
||||
if (retiredUse.test(line) && !retiredNote.test(line)) assert.fail(`${file}:${i + 1} tells people to run the retired board: ${line.trim()}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -292,7 +292,6 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona
|
||||
// resolved resolveInstance(...) output
|
||||
// contract the role contract's text
|
||||
// arbiter true when the business names this instance as an arbiter
|
||||
// library true when the trusted business configuration has a library project
|
||||
// tracker true when the broker has task verbs for the business
|
||||
// credentials the broker's credentialStatus(...) for the instance (metadata only)
|
||||
// session { run, launchedBy, workspace, toolSocket, turnMarker, checkouts }
|
||||
@@ -310,11 +309,11 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona
|
||||
// (row 213); an instance with persona.sha256 and no personaDir refuses
|
||||
// Returns { manifest, files, policy, tools, env } with absolute paths; env is the module's
|
||||
// adapter variables, which the host writes into session.json.
|
||||
export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, library = false, credentials = [], session, harness, route = "primary", routeReason = null, harnessVersion = null, models = null, land = false, skillsDir = null, personaDir = null }) {
|
||||
export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, credentials = [], session, harness, route = "primary", routeReason = null, harnessVersion = null, models = null, land = false, skillsDir = null, personaDir = null }) {
|
||||
if (route !== "primary" && route !== "fallback") throw new BundleError(`route ${JSON.stringify(route)} isn't primary or fallback`);
|
||||
const model = sessionModel(resolved, models, route);
|
||||
const web = webPlan(resolved, model.harness, harness.builtinWeb());
|
||||
const tools = typedTools({ resolved, arbiter, tracker, library, projects: (session.checkouts ?? []).map((c) => c.project), webTyped: !web.builtin, land });
|
||||
const tools = typedTools({ resolved, arbiter, tracker, projects: (session.checkouts ?? []).map((c) => c.project), webTyped: !web.builtin, land });
|
||||
const untyped = untypedActions(resolved, tools);
|
||||
const persona = readPersona(resolved, personaDir);
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
|
||||
@@ -78,10 +78,9 @@ const TASK_TOOLS = {
|
||||
|
||||
const MESSAGE_CLASSES = ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESULT", "RESULT", "INFO", "DECISION", "REACTION", "BLOCK"];
|
||||
|
||||
// typedTools({ resolved, arbiter, tracker, projects, land, library }) -> [{ name, verb, action, description, parameters }]
|
||||
// typedTools({ resolved, arbiter, tracker, projects, land }) -> [{ name, verb, action, description, parameters }]
|
||||
// resolved resolveInstance(...) output for the session's instance
|
||||
// arbiter true when the business names this instance as an arbiter
|
||||
// library true when the trusted business configuration has a library project
|
||||
// tracker true when the broker has task verbs for the business
|
||||
// projects the project ids the session has a clone of
|
||||
// land true when the role and thread hold a merge decision a landing could use (landHeld)
|
||||
@@ -89,7 +88,7 @@ const MESSAGE_CLASSES = ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESU
|
||||
// `verb` is the broker verb; the `launch` tool has verb "launch" and goes
|
||||
// to the host instead, and "working.push", "working.fetch" and
|
||||
// "working.land" go to the host's git socket.
|
||||
export function typedTools({ resolved, arbiter = false, tracker = false, projects = [], webTyped = true, land = false, library = false }) {
|
||||
export function typedTools({ resolved, arbiter = false, tracker = false, projects = [], webTyped = true, land = false }) {
|
||||
const held = new Set([...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole]);
|
||||
const tools = [];
|
||||
const add = (name, verb, action, description, parameters) => tools.push({ name, verb, action, description, parameters });
|
||||
@@ -223,48 +222,6 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
||||
url: str("The https url to fetch", { maxLength: 2048 }),
|
||||
}, ["url"]));
|
||||
}
|
||||
// Row224: no library clone or token reaches a session. The broker chooses the served
|
||||
// commit and requires a universe on every call; cursors page without cutting replies.
|
||||
if (library) {
|
||||
const universe = str("Universe ID in the configured library", { minLength: 1 });
|
||||
const project = str("Library project ID; omit only when the business has one library");
|
||||
const scope = str("canon by default; playground and supporting are opt-in", { enum: ["canon", "playground", "supporting"] });
|
||||
const manuscriptScope = str("canon by default; playground is opt-in", { enum: ["canon", "playground"] });
|
||||
const book = str("Book folder from library_universe");
|
||||
const offset = { type: "integer", minimum: 0, description: "next from the previous page" };
|
||||
const line = { type: "integer", minimum: 1, description: "One-based line number" };
|
||||
const base = { universe, project };
|
||||
add("library_find", "library.find", null, "Find names in one universe, at the served commit. At most 50 results; continue with next as offset. Playground and supporting are opt-in.", obj({
|
||||
...base, query: str("Case-insensitive name or filename substring; empty matches all"),
|
||||
type: str("Mapped type", { enum: ["character", "place", "research", "tracker", "summary"] }),
|
||||
book, scope, offset,
|
||||
}, ["universe", "query"]));
|
||||
add("library_get", "library.get", null, "Read a note and its blob at the served commit, canon by default. Playground/supporting paths need matching scope; mismatch returns null. next pages lines; links_next pages visible links in supporting/playground/canon order.", obj({
|
||||
...base, path: str("Mapped note path relative to the library"), scope, from: line, links_from: offset,
|
||||
}, ["universe", "path"]));
|
||||
add("library_search", "library.search", null, "Search text in one universe at the served commit. At most 50 hits; use next as offset. Playground and supporting are opt-in.", obj({
|
||||
...base, text: str("Nonempty case-insensitive substring", { minLength: 1 }), book, scope, offset,
|
||||
}, ["universe", "text"]));
|
||||
add("library_manuscript", "library.manuscript", null, "Read one chapter of a book at the served commit, at most 400 lines. Omit chapter for the first; continue with next or next_chapter.", obj({
|
||||
...base, book, scope: manuscriptScope, chapter: str("Chapter filename without extension"), from: line, to: line,
|
||||
}, ["universe", "book"]));
|
||||
add("library_universe", "library.universe", null, "Read a universe's mapping at the served commit: books, types, playground prefixes, and hybrid sources/constraints/notes or draft metadata.", obj(base, ["universe"]));
|
||||
// C2-02: write tools carry only the note/base/content. The broker supplies the writer,
|
||||
// credential and fixed commit message; a creative within-role grant is required.
|
||||
if (CREATIVE_DEFINITIONS.includes(resolved.definition) && resolved.limits.authority.withinRole.includes("library.write")) {
|
||||
const writeBase = {
|
||||
universe,
|
||||
path: str("A .md path under this universe's human-marked supporting folder"),
|
||||
base: { type: ["string", "null"], pattern: "^[a-f0-9]{40}$", description: "The blob from library_get, or null to create; never the commit id" },
|
||||
};
|
||||
add("library_doc_write", "library.doc.write", "library.write", "Create or edit one supporting .md note using the blob from library_get. One commit by your identity, with a fixed human notice. A stale base refuses; agent requests retain the 64 KiB envelope limit.", obj({
|
||||
...writeBase, content: str("UTF-8 note text without NUL; at most 1 MiB and bounded by the agent socket envelope"),
|
||||
}, ["universe", "path", "base", "content"]));
|
||||
add("library_doc_delete", "library.doc.delete", "library.write", "Delete one existing regular supporting .md note using the blob from library_get. One commit and a fixed human notice. A stale base or nonregular/missing target refuses.", obj({ ...writeBase,
|
||||
base: { ...writeBase.base, description: "The existing file's blob from library_get; a missing file cannot be deleted" },
|
||||
}, ["universe", "path", "base"]));
|
||||
}
|
||||
}
|
||||
add("read_spec", "spec.read", null, "Read a project's committed north-star spec and its file history. commit is an optional full reachable commit ID. The file commit, rather than HEAD, is what a decision cites. confirmed says the file commit was authored by the business's human.", obj({
|
||||
project: str("Project ID from the business file"),
|
||||
commit: str("Optional full historical commit ID", { pattern: "^[0-9a-f]{40}$" }),
|
||||
@@ -320,11 +277,6 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
||||
if (held.has("task.create"))
|
||||
add("list_requirements", "requirements", null, "List the approved PRD's requirement ids, each with a one-line title. task_create takes only these. When none fits a request, raise a prd.approve decision instead of creating the task.", obj({}));
|
||||
}
|
||||
// mos-1308 / M3: no generic spec, evidence, tracker, decision or write tools. The
|
||||
// C2-08 web grant still supplies broker web reads to companion and research only.
|
||||
if (CREATIVE_DEFINITIONS.includes(resolved.definition))
|
||||
return tools.filter(tool => tool.name === "send_message" || tool.name.startsWith("library_") ||
|
||||
tool.name === "web_search" || tool.name === "web_fetch");
|
||||
return tools;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
// Row224 M3: exact creative tool sets preserve the existing C2-08 web grants.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { typedTools } from '../src/tools.mjs';
|
||||
import { buildBundle } from '../src/bundle.mjs';
|
||||
import { TYPES } from '../../library/src/mapping.mjs';
|
||||
import { HARNESS, REPO_SKILLS, resolvedFor, scratch } from './helpers.mjs';
|
||||
|
||||
const LOOKUPS = ['library_find', 'library_get', 'library_search', 'library_manuscript', 'library_universe'];
|
||||
const CREATIVE = ['companion', 'research', 'ideas', 'proofreader', 'fact-checker', 'continuity'];
|
||||
const role = (t, definition, harness = 'pi') => resolvedFor(t, 'bean', d => {
|
||||
d.roles.bean = { definition, vars: { harness, model: 'claude-sonnet-5-5', thinking: 'high' } };
|
||||
});
|
||||
const expected = definition => ['send_message', ...(['companion', 'research'].includes(definition)
|
||||
? ['web_search', 'web_fetch'] : []), ...LOOKUPS, 'library_doc_write', 'library_doc_delete'];
|
||||
|
||||
test('M3: per-role snapshots contain eight names, or ten with the C2-08 web grant', t => {
|
||||
for (const definition of CREATIVE) {
|
||||
const tools = typedTools({ resolved: role(t, definition), library: true, tracker: true, arbiter: true });
|
||||
assert.deepEqual(tools.map(t => t.name), expected(definition), definition);
|
||||
assert.ok(tools.every(t => t.parameters.additionalProperties === false));
|
||||
}
|
||||
});
|
||||
|
||||
test('every noncreative role gets lookups when a library is configured, not otherwise', t => {
|
||||
for (const instance of ['coder', 'reviewer', 'pm', 'chief']) {
|
||||
const resolved = resolvedFor(t, instance);
|
||||
const ordinary = typedTools({ resolved }).map(t => t.name);
|
||||
assert.ok(!ordinary.some(name => name.startsWith('library_')));
|
||||
const tools = typedTools({ resolved, library: true });
|
||||
assert.deepEqual(tools.filter(t => t.name.startsWith('library_')).map(t => t.name), LOOKUPS);
|
||||
assert.deepEqual(tools.filter(t => !t.name.startsWith('library_')).map(t => t.name), ordinary);
|
||||
}
|
||||
});
|
||||
|
||||
test('lookup schemas require a universe even for get, include paging, and cannot select repo or commit', t => {
|
||||
const tools = typedTools({ resolved: role(t, 'ideas'), library: true });
|
||||
const required = [ ['universe', 'query'], ['universe', 'path'], ['universe', 'text'], ['universe', 'book'], ['universe'] ];
|
||||
for (const [i, tool] of tools.filter(t => LOOKUPS.includes(t.name)).entries()) {
|
||||
assert.deepEqual(tool.parameters.required, required[i]);
|
||||
assert.equal(tool.verb, LOOKUPS[i].replace('_', '.'));
|
||||
assert.equal(tool.action, null);
|
||||
assert.equal(tool.parameters.additionalProperties, false);
|
||||
for (const key of ['repo', 'commit', 'business', 'accept']) assert.equal(Object.hasOwn(tool.parameters.properties, key), false);
|
||||
}
|
||||
const byName = Object.fromEntries(tools.map(t => [t.name, t]));
|
||||
assert.deepEqual(byName.library_find.parameters.properties.scope.enum, ['canon', 'playground', 'supporting']);
|
||||
assert.deepEqual(byName.library_find.parameters.properties.type.enum, [...TYPES]);
|
||||
assert.equal(byName.library_find.parameters.properties.offset.minimum, 0);
|
||||
assert.equal(byName.library_get.parameters.properties.from.minimum, 1);
|
||||
assert.equal(byName.library_get.parameters.properties.links_from.minimum, 0);
|
||||
assert.equal(byName.library_manuscript.parameters.properties.chapter.type, 'string');
|
||||
});
|
||||
|
||||
test('bundle flag reaches both harness tool manifests and creative prompt without generic tools', t => {
|
||||
for (const definition of CREATIVE) for (const harness of ['pi', 'claude-code']) {
|
||||
const dir = scratch(t), workspace = join(dir, 'ws'); mkdirSync(workspace);
|
||||
const b = buildBundle({ dir: join(dir, 'bundle'), resolved: role(t, definition, harness), library: true,
|
||||
contract: '# Creative\n', tracker: true, arbiter: true, skillsDir: REPO_SKILLS,
|
||||
session: { run: 'r224', launchedBy: 'chief', workspace, toolSocket: join(dir, 't.sock'), turnMarker: join(dir, 'turn.done') },
|
||||
harness: HARNESS[harness] });
|
||||
assert.deepEqual(b.tools.map(t => t.name), expected(definition), `${definition} ${harness}`);
|
||||
const serialized = JSON.parse(readFileSync(b.files.tools, 'utf8'));
|
||||
assert.deepEqual(serialized.map(t => t.name), expected(definition));
|
||||
const prompt = readFileSync(b.files.prompt, 'utf8');
|
||||
assert.match(prompt, /library_find/);
|
||||
assert.doesNotMatch(prompt, /Bus tools: [^\n]*(read_spec|list_agents|inbox|trail|write_record|raise_decision|list_tasks)/);
|
||||
}
|
||||
});
|
||||
|
||||
test('C2-05c scope schemas expand only find/search/get, keeping manuscript and tool names unchanged', t => {
|
||||
const tools = typedTools({ resolved: role(t, 'ideas'), library: true });
|
||||
assert.deepEqual(tools.map(t => t.name), ['send_message', ...LOOKUPS, 'library_doc_write', 'library_doc_delete']);
|
||||
for (const name of ['library_find', 'library_search', 'library_get']) {
|
||||
const tool = tools.find(t => t.name === name);
|
||||
assert.deepEqual(tool.parameters.properties.scope.enum, ['canon', 'playground', 'supporting']);
|
||||
assert.ok(!tool.parameters.required.includes('scope'));
|
||||
}
|
||||
assert.deepEqual(tools.find(t => t.name === 'library_manuscript').parameters.properties.scope.enum, ['canon', 'playground']);
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
// Row 288 M3: creative write tools and independent closed-schema coverage.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { typedTools, untypedActions } from '../src/tools.mjs';
|
||||
import { resolvedFor } from './helpers.mjs';
|
||||
|
||||
const LOOKUPS = ['library_find', 'library_get', 'library_search', 'library_manuscript', 'library_universe'];
|
||||
const WRITES = ['library_doc_write', 'library_doc_delete'];
|
||||
const CREATIVE = ['companion', 'research', 'ideas', 'proofreader', 'fact-checker', 'continuity'];
|
||||
const role = (t, definition) => resolvedFor(t, 'bean', doc => {
|
||||
doc.roles.bean = { definition, vars: { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high' } };
|
||||
});
|
||||
|
||||
test('C2-02 M3: each creative role has eight tools, or ten with its web grant', t => {
|
||||
for (const definition of CREATIVE) {
|
||||
const resolved = role(t, definition), tools = typedTools({ resolved, library: true, tracker: true, arbiter: true });
|
||||
const expected = ['send_message', ...(['companion', 'research'].includes(definition) ? ['web_search', 'web_fetch'] : []), ...LOOKUPS, ...WRITES];
|
||||
assert.deepEqual(tools.map(tool => tool.name), expected, definition);
|
||||
assert.equal(tools.length, ['companion', 'research'].includes(definition) ? 10 : 8);
|
||||
assert.equal(untypedActions(resolved, tools).includes('library.write'), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('C2-02 schemas: both write tools require universe/path/base, close keys, map verb and action', t => {
|
||||
const tools = typedTools({ resolved: role(t, 'ideas'), library: true });
|
||||
for (const [name, verb, required] of [
|
||||
['library_doc_write', 'library.doc.write', ['universe', 'path', 'base', 'content']],
|
||||
['library_doc_delete', 'library.doc.delete', ['universe', 'path', 'base']],
|
||||
]) {
|
||||
const tool = tools.find(tool => tool.name === name);
|
||||
assert.ok(tool, name); assert.equal(tool.verb, verb); assert.equal(tool.action, 'library.write');
|
||||
assert.equal(tool.parameters.type, 'object'); assert.equal(tool.parameters.additionalProperties, false);
|
||||
assert.deepEqual(tool.parameters.required, required);
|
||||
assert.deepEqual(Object.keys(tool.parameters.properties).sort(), [...required].sort());
|
||||
assert.equal(tool.parameters.properties.universe.type, 'string');
|
||||
assert.equal(tool.parameters.properties.path.type, 'string');
|
||||
assert.deepEqual(tool.parameters.properties.base.type, ['string', 'null']);
|
||||
assert.equal(tool.parameters.properties.base.pattern, '^[a-f0-9]{40}$');
|
||||
if (name === 'library_doc_write') {
|
||||
assert.equal(tool.parameters.properties.content.type, 'string');
|
||||
assert.match(tool.parameters.properties.base.description, /null to create/);
|
||||
} else {
|
||||
assert.match(tool.parameters.properties.base.description, /existing file/);
|
||||
assert.doesNotMatch(tool.parameters.properties.base.description, /create/);
|
||||
}
|
||||
for (const field of ['repo', 'commit', 'business', 'accept', 'project', 'identity', 'author', 'credential', 'agent', 'message'])
|
||||
assert.equal(Object.hasOwn(tool.parameters.properties, field), false, `${name} ${field}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('C2-02 tools: no library, noncreative or no within-role write grant exposes neither write tool', t => {
|
||||
const creative = role(t, 'ideas');
|
||||
assert.equal(typedTools({ resolved: creative }).some(tool => WRITES.includes(tool.name)), false);
|
||||
for (const instance of ['coder', 'reviewer', 'pm', 'chief'])
|
||||
assert.equal(typedTools({ resolved: resolvedFor(t, instance), library: true }).some(tool => WRITES.includes(tool.name)), false);
|
||||
const narrowed = structuredClone(creative);
|
||||
narrowed.limits.authority.withinRole = narrowed.limits.authority.withinRole.filter(action => action !== 'library.write');
|
||||
narrowed.limits.authority.crossRole.push('library.write');
|
||||
assert.deepEqual(typedTools({ resolved: narrowed, library: true }).map(tool => tool.name), ['send_message', ...LOOKUPS]);
|
||||
});
|
||||
+6
-141
@@ -31,72 +31,33 @@ deep-frozen copy. `where` names the entry in messages. `fail(code, message)` def
|
||||
- `user` is the bot account name from design step 3, required for sync. It is optional in the
|
||||
mapping and checked with row 199's account name pattern, `[A-Za-z0-9_.-]`, 1 to 100 characters.
|
||||
- `vaultPrefix` is optional, one folder name.
|
||||
- `editor` is optional, `{ "name", "email" }` plus an optional `"user"`: the ident her Console saves commit under
|
||||
(C2-01d). Each value is text that is non-empty after trim, with no control character
|
||||
(`U+0000`-`U+001F`, `U+007F`-`U+009F`), `<` or `>`, and no whitespace or git-stripped character at
|
||||
either end. `user` is her Gitea login for the `library-human` slot (sage-2925), checked as the
|
||||
entry's `user` is. `editor` and its `user` come back only when given; a save without them refuses
|
||||
in C2-02.
|
||||
- `types` maps a folder name to `character`, `place`, `research`, `tracker` or `summary`.
|
||||
`manuscript` lists folder names and shares none with `types`.
|
||||
- A universe has `name`, `books` and `playground`. A hybrid adds `kind: "hybrid"`, two or more
|
||||
plain-universe `sources`, `constraints` (each one `{ "exclude": ... }` or
|
||||
`{ "pin": { "entity", "field", "value" } }`) and `notes`, and may have `books: []`. A draft
|
||||
universe has only `kind: "draft"`, `name` and one or more `playground` prefixes, and comes back as
|
||||
`{ kind: "draft", name, books: [], playground, supporting }`. `kind` is absent, `"hybrid"` or `"draft"`.
|
||||
`{ kind: "draft", name, books: [], playground }`. `kind` is absent, `"hybrid"` or `"draft"`.
|
||||
- A playground prefix ends in `/` and has one of three levels (C2-01c). Book level lies inside one of
|
||||
its own entry's book folders. Universe level, in a plain universe or a hybrid, lies outside every
|
||||
book folder. A draft's prefixes all lie outside every book folder. No prefix holds another or is
|
||||
listed twice, across every entry.
|
||||
- Every universe kind may also have `supporting`, a list of prefixes where agents may write (C2-01d,
|
||||
M12). It comes back on every universe, `[]` when absent. A supporting prefix follows the playground's
|
||||
rules: it ends in `/`, has the same three levels, holds no other supporting prefix and is listed
|
||||
once, across every entry.
|
||||
- Between the two lists: a supporting prefix may equal a playground prefix of its own entry, and a
|
||||
playground prefix may hold one of its own entry's supporting prefixes. A supporting prefix may not
|
||||
hold a playground prefix, which scope would then never reach, and may not equal, hold or lie inside
|
||||
another entry's playground prefix.
|
||||
|
||||
Refusals:
|
||||
|
||||
| Code | When |
|
||||
|---|---|
|
||||
| `library-playground-other-universe` | a playground prefix lies inside, or holds, a book folder of another entry |
|
||||
| `library-supporting-other-universe` | a supporting prefix lies inside, or holds, a book folder of another entry |
|
||||
| `library-mapping-invalid` | any other shape fault: unknown key, bad remote or branch, an unknown `kind`, a book folder in two universes, a book folder that holds another, a playground or supporting prefix that holds another of its list or is listed twice, a prefix equal to a book folder or holding its own entry's book, a supporting prefix that holds a playground prefix or overlaps another entry's, a bad `editor`, a draft with `books` or with no prefix, a hybrid source that is unknown, a hybrid or a draft |
|
||||
| `library-mapping-invalid` | any other shape fault: unknown key, bad remote or branch, an unknown `kind`, a book folder in two universes, a book folder that holds another, a playground prefix that holds another or is listed twice, a prefix equal to a book folder or holding its own entry's book, a draft with `books` or with no prefix, a hybrid source that is unknown, a hybrid or a draft |
|
||||
|
||||
## Scope
|
||||
|
||||
`classify(mapping, path)` decides scope from the path alone. Only paths ending in lowercase `.md` are
|
||||
mapped. It checks prefixes first, supporting before playground. A path under any entry's supporting
|
||||
prefix is `supporting`, and otherwise one under a playground prefix is `playground`. Either way that
|
||||
entry is `universe` and the book folder holding the prefix is `book`, or `null` at universe level and
|
||||
mapped. It checks prefixes first. A path under any entry's playground prefix is `playground`, with that
|
||||
entry as `universe` and the book folder holding the prefix as `book`, or `null` at universe level and
|
||||
in a draft. Any other path under a book folder is `canon`. Everything else is `unmapped`. The type is
|
||||
the first folder after the prefix, or after the book: a `types` name, `manuscript`, or `null`.
|
||||
|
||||
## Agent write paths
|
||||
|
||||
`supportingPath(mapping, universe, path, tree)` returns `path` when an agent may write it in that
|
||||
universe, else it refuses `library-write-story-path` (C2-01d, M12). `tree` is the array `listTree`
|
||||
returns for the commit the write lands on; anything else is a TypeError. It reads no git. The checks
|
||||
run in this order, so a path with two faults always names the same one:
|
||||
|
||||
1. a string of at most 1,024 UTF-8 bytes;
|
||||
2. no control character (the `editor` class), and the message leaves the path out;
|
||||
3. no backslash;
|
||||
4. not absolute;
|
||||
5. no empty, `.` or `..` segment, which also refuses `//` and a trailing `/`;
|
||||
6. `classify` gives `supporting` in that same universe, which refuses unmapped, `.canvas`, `.base`
|
||||
and attachment paths, canon, a playground-only folder and another universe's prefix;
|
||||
7. no folder on the way, and not the path itself, is a symlink (`120000`) or gitlink (`160000`) in
|
||||
`tree`;
|
||||
8. no folder on the way, and not the path itself, matches a tree path or tree folder after
|
||||
`normalize('NFC').toLowerCase()` unless it is that exact name. APFS folds case and Unicode form,
|
||||
so her checkout would put the file somewhere else. A tree that already holds case twins refuses a
|
||||
write to either (R7).
|
||||
|
||||
A delete's further rule, that the path exists at the tip as a `100644` blob, belongs to the C2-02
|
||||
broker.
|
||||
the first folder after the playground prefix, or after the book: a `types` name, `manuscript`, or
|
||||
`null`.
|
||||
|
||||
## Reader
|
||||
|
||||
@@ -219,45 +180,6 @@ and `library.status` shows the result once it ends. A sync that outlasts the 120
|
||||
`request-timeout` from the server and still finishes the same way. The Console has no library route yet: `cli.mjs`
|
||||
passes `librarySync` to `startServer`, and `serve.mjs` doesn't read it.
|
||||
|
||||
### Push and credentials (C2-03b)
|
||||
|
||||
`push(context, { commit, credential, retried })` sends a full commit id, already in the mirror, to the
|
||||
entry's `branch` as `push --porcelain --no-verify --no-follow-tags --end-of-options <url>
|
||||
<commit>:refs/heads/<branch>`, through the same prefix, env and helper as the fetch. The refspec has no
|
||||
`+`, so the remote takes a fast-forward only.
|
||||
|
||||
| git says | Answer |
|
||||
|---|---|
|
||||
| the push landed | `{ pushed: true }` |
|
||||
| `[rejected] (fetch first)` or `(non-fast-forward)` | one fetch with the same credential, then `{ pushed: false, raced: true, tip }` |
|
||||
| the same, with `retried: true` | `stale-base` |
|
||||
| the same, while a sync of the mirror runs | `library-sync-busy`, with no fetch |
|
||||
| anything else, `[remote rejected]` (a hook) included | `library-push-failed` |
|
||||
|
||||
Push moves no local ref; C2-02 re-applies and moves `served`. git's output is never kept.
|
||||
|
||||
A credential is `{ kind, user, file }`, with `kind` one of `slot`, `chief`, `seat` or `human`, or the
|
||||
bus's `{ refused: code }`. The helper reads only `file`. It opens the file on fd 3 and refuses unless
|
||||
the open file and the path have the same device and inode, and the file is regular, the bus's uid's,
|
||||
mode 0600 and at most 16384 bytes, as `readSecretFile` in `packages/bus/src/gitea-grant.mjs` checks a
|
||||
grant store file. A FIFO swapped in after `test -f` blocks the open, the same residual
|
||||
`readSecretFile` has.
|
||||
|
||||
- A seat writes with its own Gitea user and grant store file, `<storeRoot>/<business>/<agent>/gitea`.
|
||||
- `humanCredential(context, { user, configDir })` gives her save its credential: her Gitea login and
|
||||
`<configDir>/secrets/<business>/library-human`. A missing login, or a slot that is missing, a link,
|
||||
another uid's or not 0600, refuses `library-human-missing` before any git call.
|
||||
- The fetch takes the broker's choice, `libraryFetchCredential(business)`. The chief is a `roles`
|
||||
entry whose definition is `chief-of-staff`; a fire keeps it there. With no Gitea grant evidence for
|
||||
a chief, the fetch uses slot `library-gitea` and the entry's `user`. Once there is some, only the one
|
||||
listed chief's live, unheld grant answers, with that grant's user and store file. Anything else
|
||||
refuses `library-fetch-no-grant`, and so does any Gitea grant evidence in a business with no chief
|
||||
entry. That refusal opens no file and runs no git. The bus answers its code; the timer's log line
|
||||
adds `fix: "add the chief of staff, or grant the library through C2-21"`.
|
||||
|
||||
The sync state adds `credential`, `{ kind, user }` of the last fetch, or `null` when none ran. It is
|
||||
optional, so a state written before C2-03b still reads.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
@@ -266,60 +188,3 @@ node --test packages/library/tests/
|
||||
|
||||
Fixtures are made-up repos built per test in a temp dir. A marker string stands in for her text, and
|
||||
the survey tests check that it never appears in output.
|
||||
|
||||
### C2-02 supporting writes and human saves
|
||||
|
||||
The host exposes `library.doc.write {universe,path,base,content}` and
|
||||
`library.doc.delete {universe,path,base}` only to a creative session holding
|
||||
`library.write` and a live, unheld Gitea grant for the library project. The
|
||||
broker chooses the project from the universe mapping, the author/committer
|
||||
from the instance identity, and the credential from its grant store. `base`
|
||||
is the served-tree blob id, or `null` to create. Supporting-path checks run
|
||||
before remote effects and against each fetched tip; deletes require an
|
||||
existing `100644` blob. Content is UTF-8 without NUL and at most 1 MiB;
|
||||
agent requests still have the ordinary 64 KiB socket envelope limit.
|
||||
|
||||
Each operation is one plumbing commit with one parent, a fixed
|
||||
`Mosaic: write <path>` or `Mosaic: delete <path>` message, and the trailer
|
||||
`Mosaic-Agent: <business>/<instance>`. Pushes are fast-forward only. A moved
|
||||
remote tip retries once when the target blob still equals `base`; otherwise
|
||||
it refuses `stale-base`. A failed push leaves no queued work or local ref
|
||||
change. A successful push updates served/tracking, rebuilds the index and
|
||||
records `lastPush: {commit,at}`. The broker inserts the exact fixed notice
|
||||
`{notice:"library-write",op,path,commit}` from the writer to the human in
|
||||
that thread, independently of the agent-message citation guard.
|
||||
|
||||
`library.file.save {project,path,base,content}` and
|
||||
`library.revert {project,commit}` are human-only. Project is the business
|
||||
project key. A save reaches any mapped `.md` file under the structural path
|
||||
rules, uses the mapping's `editor` as author and committer, and pushes with
|
||||
slot `library-human`. Revert accepts only a reachable, one-parent,
|
||||
one-path commit with this business's agent trailer, and restores its
|
||||
parent's blob with the agent commit's blob as `base`. The result is
|
||||
`{project,path,reverted,commit}`. Revoking/firing the original writer does
|
||||
not prevent a human revert. Missing editor refuses
|
||||
`library-mapping-invalid`; missing/unsafe human slot refuses
|
||||
`library-human-missing`, before Git. An agent calling either human verb
|
||||
gets `human-required`. Only `library.file.save` joins the larger Console
|
||||
save envelope exception; agent writes retain 64 KiB.
|
||||
|
||||
The write guard excludes concurrent sync and admits a race fetch only for
|
||||
its opaque owner token. The library flock spans fetch/build/push as the
|
||||
brief requires, so readers can receive `library-sync-busy` after the
|
||||
existing five-second lock wait while network Git runs (MVP tradeoff,
|
||||
sage-3628). Publication errors return `outcome-unknown`: served is restored
|
||||
to match old state unless the new state was already published. The remote
|
||||
commit remains; callers must inspect status rather than retry blindly.
|
||||
|
||||
The human push login is `editor.user` (optional in the mapping, required
|
||||
for save/revert), distinct from `user`, which names the fetch bot.
|
||||
An absent editor login refuses `library-human-missing`; no fallback uses
|
||||
the bot's login. Once an agent push succeeds, the broker stores the exact
|
||||
write notice with that pushed commit id before local publication. If
|
||||
publication then fails, the agent receives `outcome-unknown` and the
|
||||
notice remains. Revert can refuse `library-revert-not-agent` until a sync
|
||||
makes the commit reachable from served. No notice outbox or retry is
|
||||
needed. If the Console store itself refuses the notice insert, the caller
|
||||
still receives `outcome-unknown` and a separate metadata-only host log
|
||||
records `library-notice-store-failed` with the business, instance, operation
|
||||
and pushed commit id; it contains no file text or credential.
|
||||
@@ -2,11 +2,6 @@
|
||||
# C2-03a: git's GIT_ASKPASS for library sync (packages/library/src/sync.mjs). It answers only the
|
||||
# Username and Password prompts for the configured host and user, and exits 1 on anything else.
|
||||
# The env names the slot path, never the token. printf is a shell builtin, so the token is in no argv.
|
||||
# C2-03b: the slot is whichever file the bus chose (a grant store file, library-gitea or library-human),
|
||||
# read with readSecretFile's checks (gitea-grant.mjs): a regular file the bus's uid owns, mode 0600, at
|
||||
# most 16384 bytes. The checks run on the open fd 3, and its dev:inode must match the path's own
|
||||
# (an lstat), so a link or a swap before the open refuses and the token comes from the checked file
|
||||
# (dewey-0860). Residual, as in readSecretFile: a FIFO swapped in after test -f blocks the open.
|
||||
host=$MOSAIC_LIBRARY_HOST user=$MOSAIC_LIBRARY_USER slot=$MOSAIC_LIBRARY_SLOT
|
||||
test -n "$host" && test -n "$user" && test -n "$slot" || exit 1
|
||||
case $host in *[!a-z0-9.:-]*) exit 1 ;; esac
|
||||
@@ -15,14 +10,7 @@ case $1 in
|
||||
"Username for 'https://$host': ") printf '%s\n' "$user" ;;
|
||||
"Password for 'https://$user@$host': ")
|
||||
test -f "$slot" && test ! -L "$slot" || exit 1
|
||||
exec 3<"$slot" || exit 1
|
||||
open=$(stat -L -c '%d:%i %u %a %s %F' /dev/fd/3 2>/dev/null) || exit 1
|
||||
path=$(stat -c '%d:%i' -- "$slot" 2>/dev/null) || exit 1
|
||||
set -- $open
|
||||
test "$1" = "$path" && test "$2" = "$(id -u)" && test "$3" = 600 && test "$4" -le 16384 \
|
||||
&& test "$5 $6" = "regular file" && test $# -eq 6 || exit 1
|
||||
IFS= read -r token <&3 || test -n "$token" || exit 1
|
||||
exec 3<&-
|
||||
IFS= read -r token < "$slot" || test -n "$token" || exit 1
|
||||
test -n "$token" || exit 1
|
||||
printf '%s\n' "$token" ;;
|
||||
*) exit 1 ;;
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
// The lookup index of design 3 (C2-05a): types, names, links and backlinks for one commit of her repo.
|
||||
// It is built from the reader's output, so it holds only mapped, readable files: R1, R2 and R7 leave a
|
||||
// file out, and an unresolved link is not in it. It is cached in memory by commit id. The key also
|
||||
// carries the repo path and a digest of the mapping, because one commit reads differently under another
|
||||
// mapping. The index is not universe-aware; query.mjs decides what a universe may see.
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readLibrary } from './reader.mjs';
|
||||
import { raise } from './problems.mjs';
|
||||
|
||||
const CACHE_LIMIT = 8;
|
||||
const cache = new Map();
|
||||
|
||||
const push = (map, key, value) => {
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key).push(value);
|
||||
};
|
||||
// A file's name for lookups: its front matter `name` when it is text, else its file name.
|
||||
const displayName = file => {
|
||||
const name = file.frontmatter?.name;
|
||||
return typeof name === 'string' && name.trim() ? name.trim() : file.name;
|
||||
};
|
||||
// A link as the index keeps it: the file at the other end, its line, and the field when front matter holds it.
|
||||
const linkAt = (path, link) => ({ path, line: link.line, ...(link.field === undefined ? {} : { field: link.field }) });
|
||||
|
||||
// Builds the index from readLibrary's output. Pure: no git, no clock.
|
||||
export function buildIndex(library, mapping) {
|
||||
const files = new Map(), types = new Map(), names = new Map(), linksOut = new Map(), linksIn = new Map();
|
||||
for (const file of library.files) {
|
||||
const name = displayName(file);
|
||||
files.set(file.path, {
|
||||
path: file.path, scope: file.scope, universe: file.universe, book: file.book, type: file.type,
|
||||
folder: file.folder, stem: file.name, name, frontmatter: file.frontmatter, lines: file.lines,
|
||||
});
|
||||
if (file.type !== null) push(types, file.type, file.path);
|
||||
push(names, name.toLowerCase(), file.path);
|
||||
linksOut.set(file.path, file.links.map(link => linkAt(link.path, link)));
|
||||
for (const link of file.links) push(linksIn, link.path, linkAt(file.path, link));
|
||||
}
|
||||
return Object.freeze({
|
||||
commit: library.commit, mapping, files, paths: [...files.keys()], types, names, linksOut, linksIn,
|
||||
problems: library.problems,
|
||||
});
|
||||
}
|
||||
|
||||
const digest = mapping => createHash('sha256').update(JSON.stringify(mapping)).digest('hex');
|
||||
|
||||
// The index for one commit under one mapping, read once and kept in memory. A repeat call returns the same
|
||||
// object. Past CACHE_LIMIT entries the oldest goes. A failed read is not kept.
|
||||
export async function indexFor({ repo, commit, mapping }) {
|
||||
if (!mapping || mapping.kind !== 'library') raise('library-mapping-invalid', 'expected a checked library mapping');
|
||||
const key = JSON.stringify([repo, commit, digest(mapping)]);
|
||||
if (cache.has(key)) {
|
||||
const hit = cache.get(key);
|
||||
cache.delete(key);
|
||||
cache.set(key, hit);
|
||||
return hit;
|
||||
}
|
||||
const pending = readLibrary({ repo, commit, mapping }).then(library => buildIndex(library, mapping));
|
||||
cache.set(key, pending);
|
||||
while (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value);
|
||||
try {
|
||||
return await pending;
|
||||
} catch (error) {
|
||||
if (cache.get(key) === pending) cache.delete(key);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearIndexCache() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
export function indexCacheSize() {
|
||||
return cache.size;
|
||||
}
|
||||
@@ -2,14 +2,9 @@
|
||||
export { LibraryError, RULES, problem, sortProblems } from './problems.mjs';
|
||||
export { checkMapping, TYPES } from './mapping.mjs';
|
||||
export { classify } from './scope.mjs';
|
||||
export { supportingPath } from './supporting.mjs';
|
||||
export { extractLinks, parseWikilink, resolver, formOf, isAttachment } from './links.mjs';
|
||||
export { readLibrary, listTree, readTexts, parseFile, parseFrontMatter, headCommit, MAX_BLOB, LINK_FIELDS } from './reader.mjs';
|
||||
export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs';
|
||||
export { survey, formatSurvey, surveyMain } from './survey.mjs';
|
||||
export { SYNC_STATE_FILE, mirrorPath, ensureMirror, withLibraryLock, servedCommit, readSyncState, libraryStatus, readLibraryFile } from './mirror.mjs';
|
||||
export { syncCommand, sync, push, humanCredential, ASKPASS, SLOT, HUMAN_SLOT, CREDENTIAL_KINDS, SYNC_INTERVAL_MS } from './sync.mjs';
|
||||
|
||||
export { writeLibraryDoc, saveLibraryFile, revertLibraryCommit } from './write.mjs';
|
||||
export { buildIndex, indexFor, clearIndexCache, indexCacheSize } from './index-build.mjs';
|
||||
export { sees, find, get, search, manuscript, universeInfo, QUERY_REPLY_CAP, LOOKUP_LIMIT, MANUSCRIPT_LINES } from './query.mjs';
|
||||
export { syncCommand, sync, ASKPASS, SLOT, SYNC_INTERVAL_MS } from './sync.mjs';
|
||||
@@ -1,9 +1,8 @@
|
||||
// The library project entry of a business file (design 1.1): her folders mapped onto universes,
|
||||
// books, type folders, manuscript folders and playground prefixes at three levels (ruling after r11,
|
||||
// C2-01c): inside one of the entry's books, outside every book, or in a draft universe. Supporting
|
||||
// prefixes (C2-01d) follow the same rules and are where agents may write (M12). A pure validator.
|
||||
// Business file loading wires it in C2-04, which passes its own `fail` so the refusal reads as a
|
||||
// business file problem. Agents can't write this entry (M9, C2-04).
|
||||
// C2-01c): inside one of the entry's books, outside every book, or in a draft universe. A pure validator. Business file
|
||||
// loading wires it in C2-04, which passes its own `fail` so the refusal reads as a business file
|
||||
// problem. Agents can't write this entry (M9, C2-04).
|
||||
import { raise } from './problems.mjs';
|
||||
|
||||
export const TYPES = Object.freeze(['character', 'place', 'research', 'tracker', 'summary']);
|
||||
@@ -13,12 +12,6 @@ const BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/;
|
||||
const USER = /^[A-Za-z0-9_.-]{1,100}$/;
|
||||
const CONSTRAINT_KEYS = ['exclude', 'pin'];
|
||||
const KINDS = ['hybrid', 'draft'];
|
||||
// C2-01d (F41): the editor ident her Console saves commit under. Git drops `<` and `>` from an ident
|
||||
// and commits under a different name, so they refuse here (webby-0169 R1).
|
||||
const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f<>]/;
|
||||
// Git's ident.c crud() strips any byte <= 0x20 and , : ; < > " \ ' from both ends of an ident, so a
|
||||
// value that starts or ends with one would save one name and commit another (sage-3601, sage-3606).
|
||||
const IDENT_CRUD_END = /^[\u0000-\u0020,:;<>"\\']|[\u0000-\u0020,:;<>"\\']$/;
|
||||
|
||||
// A folder segment from her vault: spaces and capitals are fine, separators, control characters
|
||||
// and dot names are not.
|
||||
@@ -47,7 +40,7 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
};
|
||||
|
||||
object(entry, '');
|
||||
only(entry, ['kind', 'remote', 'branch', 'user', 'vaultPrefix', 'editor', 'universes', 'types', 'manuscript'], 'entry');
|
||||
only(entry, ['kind', 'remote', 'branch', 'user', 'vaultPrefix', 'universes', 'types', 'manuscript'], 'entry');
|
||||
if (entry.kind !== 'library') bad('kind must be "library"');
|
||||
let url;
|
||||
try { url = new URL(entry.remote); } catch { bad('remote must be an https:// URL'); }
|
||||
@@ -59,20 +52,6 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
if (Object.hasOwn(entry, 'user') && (typeof entry.user !== 'string' || !USER.test(entry.user)))
|
||||
bad('user must be a plain account name');
|
||||
if (Object.hasOwn(entry, 'vaultPrefix') && !segment(entry.vaultPrefix)) bad('vaultPrefix must be one folder name');
|
||||
if (Object.hasOwn(entry, 'editor')) {
|
||||
object(entry.editor, 'editor');
|
||||
only(entry.editor, ['name', 'email', 'user'], 'editor');
|
||||
// Her Gitea login for the library-human slot, which C2-02 passes in (sage-2925). Optional here.
|
||||
if (Object.hasOwn(entry.editor, 'user') && (typeof entry.editor.user !== 'string' || !USER.test(entry.editor.user)))
|
||||
bad('editor.user must be a plain account name');
|
||||
for (const key of ['name', 'email']) {
|
||||
const value = entry.editor[key];
|
||||
if (typeof value !== 'string' || !value.trim() || IDENT_BAD.test(value))
|
||||
bad(`editor.${key} must be non-empty text with no control character, < or >`);
|
||||
if (value.trim() !== value || IDENT_CRUD_END.test(value))
|
||||
bad(`editor.${key} must not start or end with whitespace or any of , : ; " ' \\`);
|
||||
}
|
||||
}
|
||||
|
||||
object(entry.types, 'types');
|
||||
for (const [name, type] of Object.entries(entry.types)) {
|
||||
@@ -85,7 +64,6 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
object(entry.universes, 'universes');
|
||||
if (!Object.keys(entry.universes).length) bad('universes must name at least one universe');
|
||||
const universes = {}, bookOwner = new Map(), prefixes = [], prefixOwner = new Map();
|
||||
const supports = [], supportOwner = new Map();
|
||||
for (const [id, universe] of Object.entries(entry.universes)) {
|
||||
const name = `universes.${id}`;
|
||||
if (!ID.test(id)) bad(`universe id ${JSON.stringify(id)} is not a plain id`);
|
||||
@@ -93,8 +71,8 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
if (Object.hasOwn(universe, 'kind') && !KINDS.includes(universe.kind)) bad(`${name}.kind must be "hybrid" or "draft"`);
|
||||
const hybrid = universe.kind === 'hybrid', draft = universe.kind === 'draft';
|
||||
if (draft && Object.hasOwn(universe, 'books')) bad(`${name} is a draft and takes no books`);
|
||||
only(universe, hybrid ? ['kind', 'name', 'sources', 'books', 'playground', 'supporting', 'constraints', 'notes']
|
||||
: draft ? ['kind', 'name', 'playground', 'supporting'] : ['name', 'books', 'playground', 'supporting'], name);
|
||||
only(universe, hybrid ? ['kind', 'name', 'sources', 'books', 'playground', 'constraints', 'notes']
|
||||
: draft ? ['kind', 'name', 'playground'] : ['name', 'books', 'playground'], name);
|
||||
if (typeof universe.name !== 'string' || !universe.name.trim() || universe.name.length > 128 || /[\u0000-\u001f]/.test(universe.name))
|
||||
bad(`${name}.name must be a short display name`);
|
||||
const books = draft ? [] : list(universe.books, `${name}.books`, folder, 'a folder path');
|
||||
@@ -103,14 +81,12 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
if (bookOwner.has(book)) bad(`book folder ${JSON.stringify(book)} is in two universes`);
|
||||
bookOwner.set(book, id);
|
||||
}
|
||||
const prefix = value => typeof value === 'string' && value.endsWith('/') && folder(value.slice(0, -1));
|
||||
const playground = list(universe.playground ?? [], `${name}.playground`, prefix, 'a folder path ending in /');
|
||||
const playground = list(universe.playground ?? [], `${name}.playground`,
|
||||
value => typeof value === 'string' && value.endsWith('/') && folder(value.slice(0, -1)), 'a folder path ending in /');
|
||||
if (draft && !playground.length) bad(`${name}.playground must name at least one prefix`);
|
||||
for (const p of playground) { prefixes.push(p); prefixOwner.set(p, id); }
|
||||
const supporting = list(universe.supporting ?? [], `${name}.supporting`, prefix, 'a folder path ending in /');
|
||||
for (const p of supporting) { supports.push(p); supportOwner.set(p, id); }
|
||||
if (draft) { universes[id] = { kind: 'draft', name: universe.name, books, playground, supporting }; continue; }
|
||||
const out = { name: universe.name, books, playground, supporting };
|
||||
for (const prefix of playground) { prefixes.push(prefix); prefixOwner.set(prefix, id); }
|
||||
if (draft) { universes[id] = { kind: 'draft', name: universe.name, books, playground }; continue; }
|
||||
const out = { name: universe.name, books, playground };
|
||||
if (hybrid) {
|
||||
out.kind = 'hybrid';
|
||||
out.sources = list(universe.sources, `${name}.sources`, value => typeof value === 'string' && ID.test(value), 'a universe id');
|
||||
@@ -148,36 +124,18 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
for (const a of books) for (const b of books) if (a !== b && holds(a, b)) bad(`book folder ${JSON.stringify(a)} holds book folder ${JSON.stringify(b)}`);
|
||||
for (const a of prefixes) for (const b of prefixes) if (a !== b && holds(a, b)) bad(`playground ${JSON.stringify(a)} holds playground ${JSON.stringify(b)}`);
|
||||
if (new Set(prefixes).size !== prefixes.length) bad('a playground prefix is listed twice');
|
||||
for (const a of supports) for (const b of supports) if (a !== b && holds(a, b)) bad(`supporting ${JSON.stringify(a)} holds supporting ${JSON.stringify(b)}`);
|
||||
if (new Set(supports).size !== supports.length) bad('a supporting prefix is listed twice');
|
||||
// A prefix's level comes from where it sits (ruling after r11). Inside one of its own entry's books
|
||||
// is book level, outside every book is universe level, and a draft's prefixes are all outside. Scope
|
||||
// checks prefixes first (scope.mjs), so a prefix equal to or holding a book would turn that book
|
||||
// into playground or supporting.
|
||||
const againstBooks = (owners, list, code) => {
|
||||
for (const [prefix, id] of owners) {
|
||||
for (const [book, owner] of bookOwner) {
|
||||
const at = `universes.${id}.${list} ${JSON.stringify(prefix)}`;
|
||||
if (prefix === `${book}/`) bad(`${at} is the whole book folder ${JSON.stringify(book)}`);
|
||||
const inside = holds(book, prefix), holding = holds(prefix, book);
|
||||
if ((inside || holding) && owner !== id)
|
||||
fail(code, `${where} ${at} ${inside ? 'lies inside' : 'holds'} book folder ${JSON.stringify(book)} of universes.${owner}`);
|
||||
if (holding) bad(`${at} holds its own book folder ${JSON.stringify(book)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
againstBooks(prefixOwner, 'playground', 'library-playground-other-universe');
|
||||
againstBooks(supportOwner, 'supporting', 'library-supporting-other-universe');
|
||||
// C2-01d, between the lists. A supporting prefix may equal a playground prefix of its own entry, and
|
||||
// a playground may hold one of its own entry's supporting prefixes. A supporting prefix that holds a
|
||||
// playground would hide it, since scope checks supporting first. Any overlap across entries would
|
||||
// give one path two universes.
|
||||
for (const [s, sid] of supportOwner) {
|
||||
for (const [p, pid] of prefixOwner) {
|
||||
const at = `universes.${sid}.supporting ${JSON.stringify(s)}`;
|
||||
if (sid !== pid && (s === p || holds(s, p) || holds(p, s)))
|
||||
bad(`${at} overlaps playground ${JSON.stringify(p)} of universes.${pid}`);
|
||||
if (sid === pid && s !== p && holds(s, p)) bad(`${at} holds playground ${JSON.stringify(p)}`);
|
||||
// into playground.
|
||||
for (const [prefix, id] of prefixOwner) {
|
||||
for (const [book, owner] of bookOwner) {
|
||||
const at = `universes.${id}.playground ${JSON.stringify(prefix)}`;
|
||||
if (prefix === `${book}/`) bad(`${at} is the whole book folder ${JSON.stringify(book)}`);
|
||||
const inside = holds(book, prefix), holding = holds(prefix, book);
|
||||
if ((inside || holding) && owner !== id)
|
||||
fail('library-playground-other-universe', `${where} ${at} ${inside ? 'lies inside' : 'holds'} book folder ${JSON.stringify(book)} of universes.${owner}`);
|
||||
if (holding) bad(`${at} holds its own book folder ${JSON.stringify(book)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,8 +143,6 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
kind: 'library', remote: entry.remote, branch: entry.branch,
|
||||
...(Object.hasOwn(entry, 'user') ? { user: entry.user } : {}),
|
||||
...(Object.hasOwn(entry, 'vaultPrefix') ? { vaultPrefix: entry.vaultPrefix } : {}),
|
||||
...(Object.hasOwn(entry, 'editor') ? { editor: { name: entry.editor.name, email: entry.editor.email,
|
||||
...(Object.hasOwn(entry.editor, 'user') ? { user: entry.editor.user } : {}) } } : {}),
|
||||
universes, types: { ...entry.types }, manuscript,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -219,13 +219,7 @@ async function readStateHeld(mirror, { branch } = {}) {
|
||||
value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, used)));
|
||||
} catch { fail('library-state-invalid', 'invalid sync state'); }
|
||||
finally { if (fd !== undefined) closeSync(fd); }
|
||||
// C2-03b adds `credential`, { kind, user } or null. It is optional, so a state from before it reads.
|
||||
const listed = ['version', 'branch', 'served', 'lastFetch', 'lastError', 'diverged', 'problems'];
|
||||
const expected = [...listed, ...['credential', 'lastPush'].filter(key => Object.hasOwn(value ?? {}, key))];
|
||||
if (!keys(value, expected)
|
||||
|| !optional(value.lastPush ?? null, v => keys(v, ['commit', 'at']) && SHA.test(v.commit) && utc(v.at))
|
||||
|| !optional(value.credential ?? null, v => keys(v, ['kind', 'user']) && ['slot', 'chief', 'seat', 'human'].includes(v.kind)
|
||||
&& typeof v.user === 'string' && /^[A-Za-z0-9_.-]{1,100}$/.test(v.user))
|
||||
if (!keys(value, ['version', 'branch', 'served', 'lastFetch', 'lastError', 'diverged', 'problems'])
|
||||
|| value.version !== 1 || value.branch !== branch || value.served !== served
|
||||
|| !optional(value.served, v => typeof v === 'string' && SHA.test(v)) || !optional(value.lastFetch, utc)
|
||||
|| !optional(value.lastError, v => keys(v, ['code', 'at']) && typeof v.code === 'string' && /^[a-z-]{1,64}$/.test(v.code) && utc(v.at))
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
// The lookup queries of design 3 (C2-05a): find, get, search, manuscript and universeInfo. Each takes a
|
||||
// source {repo, commit, mapping} and an argument object, and returns a reply that fits QUERY_REPLY_CAP.
|
||||
// Universe isolation is here, in sees(): a file reaches a reply only when sees() allows it. Scope comes
|
||||
// from the path (D2). Unmapped files are not in the index, so they never appear. Lists page by position
|
||||
// and never cut an item. A search line too long to fit alone comes back as a marked hit with no excerpt;
|
||||
// a find item, a file's front matter or one of its lines that cannot fit alone is refused.
|
||||
import { raise } from './problems.mjs';
|
||||
import { TYPES } from './mapping.mjs';
|
||||
import { indexFor } from './index-build.mjs';
|
||||
import { listTree } from './reader.mjs';
|
||||
|
||||
// The same cap as packages/harness/src/tools.mjs REPLY_CAP. The query tests assert the two stay equal.
|
||||
export const QUERY_REPLY_CAP = 40_000;
|
||||
export const LOOKUP_LIMIT = 50;
|
||||
export const MANUSCRIPT_LINES = 400;
|
||||
const SCOPES = Object.freeze(['canon', 'playground', 'supporting']);
|
||||
const MANUSCRIPT_SCOPES = Object.freeze(['canon', 'playground']);
|
||||
const SCOPE_RANK = Object.freeze({ supporting: 0, playground: 1, canon: 2 });
|
||||
|
||||
const bytes = value => JSON.stringify(value).length;
|
||||
const invalid = message => raise('query-argument-invalid', message);
|
||||
const inside = (folder, path) => path.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
|
||||
|
||||
function textArg(value, name) {
|
||||
if (typeof value !== 'string') invalid(`${name} must be text`);
|
||||
return value;
|
||||
}
|
||||
function scopeArg(value, allowed = SCOPES) {
|
||||
if (value === undefined) return 'canon';
|
||||
if (!allowed.includes(value)) invalid(`scope must be one of ${allowed.join(", ")}`);
|
||||
return value;
|
||||
}
|
||||
function positionArg(value, name, fallback) {
|
||||
if (value === undefined) return fallback;
|
||||
if (!Number.isSafeInteger(value) || value < 1) invalid(`${name} must be a line number of 1 or more`);
|
||||
return value;
|
||||
}
|
||||
function offsetArg(value) {
|
||||
if (value === undefined) return 0;
|
||||
if (!Number.isSafeInteger(value) || value < 0) invalid('offset must be a whole number of 0 or more');
|
||||
return value;
|
||||
}
|
||||
function universeArg(mapping, value) {
|
||||
if (typeof value !== 'string' || !value) invalid('universe is required');
|
||||
if (!Object.hasOwn(mapping.universes, value)) raise('query-universe-unknown', `this library has no universe ${JSON.stringify(value)}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
// Whether universe U may see a file. Its own files, in either scope. A hybrid also sees the canon of
|
||||
// its declared sources, minus the folders its exclude constraints name. A hybrid never sees a source's
|
||||
// playground. Pin constraints do not hide anything; they are reported by universeInfo only.
|
||||
export function sees(mapping, universe, file) {
|
||||
if (file.universe === universe) return true;
|
||||
const entry = mapping.universes[universe];
|
||||
if (entry.kind !== 'hybrid' || !entry.sources.includes(file.universe) || file.scope !== 'canon') return false;
|
||||
return !entry.constraints.some(c => c.exclude !== undefined && inside(c.exclude, file.path));
|
||||
}
|
||||
|
||||
// The files one universe may see in one scope, with optional book and type filters, in path order.
|
||||
async function visible(source, universe, { scope, book, type }) {
|
||||
const index = await indexFor(source);
|
||||
const files = index.paths
|
||||
.map(path => index.files.get(path))
|
||||
.filter(file => file.scope === scope && sees(index.mapping, universe, file)
|
||||
&& (book === undefined || file.book === book) && (type === undefined || file.type === type));
|
||||
return { index, files };
|
||||
}
|
||||
|
||||
// A reply whose size is over the cap is refused, never cut.
|
||||
function checked(reply) {
|
||||
if (bytes(reply) > QUERY_REPLY_CAP) raise('query-item-too-large', `the reply is over the ${QUERY_REPLY_CAP}-character cap`);
|
||||
return reply;
|
||||
}
|
||||
|
||||
// Picks items from `offset`, at most `limit`, while the reply still fits. build(picked, next) returns
|
||||
// the reply; `next` is the position of the first item not picked, or null when none is left. An item
|
||||
// that cannot fit even alone is refused: skipping it would hide it, and cutting it would break it.
|
||||
function fitPage(items, { offset = 0, limit = LOOKUP_LIMIT, build }) {
|
||||
const picked = [];
|
||||
let at = offset;
|
||||
while (at < items.length && picked.length < limit) {
|
||||
// The size check uses items.length for `next`, the widest number a cursor can take, so a page never runs over.
|
||||
if (bytes(build([...picked, items[at]], items.length)) > QUERY_REPLY_CAP) {
|
||||
if (!picked.length) raise('query-item-too-large', `the item at position ${at} alone is over the ${QUERY_REPLY_CAP}-character cap`);
|
||||
break;
|
||||
}
|
||||
picked.push(items[at]);
|
||||
at += 1;
|
||||
}
|
||||
return checked(build(picked, at < items.length ? at : null));
|
||||
}
|
||||
|
||||
// find: the files whose name or file name holds `query`, case-insensitive. Up to 50 per page.
|
||||
export async function find(source, { universe: name, query, type, book, scope, offset } = {}) {
|
||||
const universe = universeArg(source.mapping, name);
|
||||
const needle = textArg(query, 'query').toLowerCase();
|
||||
if (type !== undefined && !TYPES.includes(type)) invalid(`type must be one of ${TYPES.join(', ')}`);
|
||||
const { index, files } = await visible(source, universe, {
|
||||
scope: scopeArg(scope), book: book === undefined ? undefined : textArg(book, 'book'), type,
|
||||
});
|
||||
const hits = files.filter(file => file.name.toLowerCase().includes(needle) || file.stem.toLowerCase().includes(needle));
|
||||
return fitPage(hits, {
|
||||
offset: offsetArg(offset),
|
||||
build: (picked, next) => ({
|
||||
commit: index.commit,
|
||||
results: picked.map(file => ({
|
||||
path: file.path, type: file.type, name: file.name, appears_in: file.frontmatter?.appears_in ?? null,
|
||||
scope: file.scope, universe: file.universe, book: file.book,
|
||||
})),
|
||||
next,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// get: one file in the requested scope (canon default), its served-tree blob, front matter,
|
||||
// lines and visible links both ways. Links keep all visible scopes, ordered supporting/playground/canon.
|
||||
// Returns null when the file is missing or not visible, so a caller cannot tell the two apart. Lines page
|
||||
// by `from` (reply `next`), links page together by `links_from` (reply `links_next`); `links_out` come
|
||||
// before `links_in`. Every page carries the front matter. Links take at most half the cap, so a hub note
|
||||
// with many backlinks stays readable and still leaves room for lines. A page with lines left carries at
|
||||
// least one line, so links shrink when a line needs the room; read on with `from: next` and
|
||||
// `links_from: links_next`. A `from` past the last line returns links alone. Only front matter, or a
|
||||
// line that cannot fit with no links beside it, is refused.
|
||||
export async function get(source, { universe: name, path, scope, from, links_from: linksFrom } = {}) {
|
||||
const universe = universeArg(source.mapping, name);
|
||||
const requestedScope = scopeArg(scope);
|
||||
const startLine = positionArg(from, 'from', 1);
|
||||
const linkStart = offsetArg(linksFrom);
|
||||
if (typeof path !== 'string') invalid('path must be text');
|
||||
const index = await indexFor(source);
|
||||
const file = index.files.get(path);
|
||||
if (!file || file.scope !== requestedScope || !sees(index.mapping, universe, file)) return null;
|
||||
const reachable = links => links.filter(link => {
|
||||
const other = index.files.get(link.path);
|
||||
return other !== undefined && sees(index.mapping, universe, other);
|
||||
}).sort((a, b) => SCOPE_RANK[index.files.get(a.path).scope] - SCOPE_RANK[index.files.get(b.path).scope]);
|
||||
const outs = reachable(index.linksOut.get(file.path) ?? []);
|
||||
const ins = reachable(index.linksIn.get(file.path) ?? []);
|
||||
const links = [...outs.map(link => ({ out: true, link })), ...ins.map(link => ({ out: false, link }))];
|
||||
// The reader/index does not keep tree oids. Resolve only in this immutable served
|
||||
// snapshot, never HEAD; listTree already checks/bounds the tree and its object IDs.
|
||||
const entry = (await listTree(source.repo, index.commit)).find(entry => entry.path === file.path);
|
||||
if (!entry || entry.mode !== '100644' || entry.type !== 'blob') raise('library-commit', 'indexed file is absent from the served tree');
|
||||
const blob = entry.oid;
|
||||
const lines = file.lines.slice(startLine - 1);
|
||||
const linkRest = Math.max(0, links.length - linkStart);
|
||||
const page = (nLines, nLinks) => {
|
||||
const taken = links.slice(linkStart, linkStart + nLinks);
|
||||
return {
|
||||
path: file.path, commit: index.commit, blob, scope: file.scope, frontmatter: file.frontmatter,
|
||||
lines: lines.slice(0, nLines),
|
||||
links_out: taken.filter(item => item.out).map(item => item.link),
|
||||
links_in: taken.filter(item => !item.out).map(item => item.link),
|
||||
next: nLines < lines.length ? startLine + nLines : null,
|
||||
links_next: nLinks < linkRest ? linkStart + nLinks : null,
|
||||
};
|
||||
};
|
||||
// How many leading items fit in `room`, by each item's own size.
|
||||
const count = (items, room) => {
|
||||
let used = 0, n = 0;
|
||||
while (n < items.length && used + bytes(items[n]) + 1 <= room) used += bytes(items[n++]) + 1;
|
||||
return n;
|
||||
};
|
||||
const base = bytes(page(0, 0));
|
||||
if (base > QUERY_REPLY_CAP) raise('query-item-too-large', `the front matter of ${file.path} alone is over the ${QUERY_REPLY_CAP}-character cap`);
|
||||
if (lines.length && bytes(page(1, 0)) > QUERY_REPLY_CAP) {
|
||||
raise('query-item-too-large', `line ${lines[0][0]} of ${file.path} alone is over the ${QUERY_REPLY_CAP}-character cap`);
|
||||
}
|
||||
const rest = links.slice(linkStart);
|
||||
let nLinks = count(rest, (lines.length ? Math.floor(QUERY_REPLY_CAP / 2) : QUERY_REPLY_CAP) - base);
|
||||
if (!nLinks && rest.length) nLinks = count(rest, QUERY_REPLY_CAP - base);
|
||||
if (!nLinks && rest.length) raise('query-item-too-large', `a link of ${file.path} alone is over the ${QUERY_REPLY_CAP}-character cap`);
|
||||
while (lines.length && nLinks > 0 && bytes(page(1, nLinks)) > QUERY_REPLY_CAP) nLinks = Math.floor(nLinks / 2);
|
||||
let nLines = Math.max(lines.length ? 1 : 0, count(lines, QUERY_REPLY_CAP - bytes(page(0, nLinks))));
|
||||
while (nLines > 1 && bytes(page(nLines, nLinks)) > QUERY_REPLY_CAP) nLines -= 1;
|
||||
return checked(page(nLines, nLinks));
|
||||
}
|
||||
|
||||
// search: lines whose text holds `text`, case-insensitive. The excerpt is the whole line. Up to 50 per page.
|
||||
export async function search(source, { universe: name, text, book, scope, offset } = {}) {
|
||||
const universe = universeArg(source.mapping, name);
|
||||
const needle = textArg(text, 'text').toLowerCase();
|
||||
if (!needle) invalid('text must not be empty');
|
||||
const { index, files } = await visible(source, universe, {
|
||||
scope: scopeArg(scope), book: book === undefined ? undefined : textArg(book, 'book'),
|
||||
});
|
||||
const hits = [];
|
||||
for (const file of files) {
|
||||
for (const [line, body] of file.lines) {
|
||||
if (!body.toLowerCase().includes(needle)) continue;
|
||||
const hit = { path: file.path, line, excerpt: body, scope: file.scope };
|
||||
// The excerpt is the whole line. A line too long to fit alone is marked, not cut and not refused.
|
||||
hits.push(bytes({ commit: index.commit, results: [hit], next: Number.MAX_SAFE_INTEGER }) > QUERY_REPLY_CAP
|
||||
? { path: file.path, line, excerpt: null, excerpt_too_long: true, scope: file.scope } : hit);
|
||||
}
|
||||
}
|
||||
return fitPage(hits, {
|
||||
offset: offsetArg(offset),
|
||||
build: (picked, next) => ({ commit: index.commit, results: picked, next }),
|
||||
});
|
||||
}
|
||||
|
||||
// manuscript: one chapter file of a book, by its file name, up to 400 lines per call from `from` to `to`.
|
||||
// Without a chapter it returns the book's first chapter file in path order. Every reply carries
|
||||
// `next_chapter`, the next chapter file's name in path order or null. A name that matches two visible
|
||||
// files is refused. Files outside every book (book null) are never chapters of a book.
|
||||
export async function manuscript(source, { universe: name, book, scope, chapter, from, to } = {}) {
|
||||
const universe = universeArg(source.mapping, name);
|
||||
const bookName = textArg(book, 'book');
|
||||
const chapterName = chapter === undefined ? undefined : textArg(chapter, 'chapter');
|
||||
const start = positionArg(from, 'from', 1);
|
||||
const end = positionArg(to, 'to', Infinity);
|
||||
if (end < start) invalid('to must not be before from');
|
||||
const { index, files } = await visible(source, universe, { scope: scopeArg(scope, MANUSCRIPT_SCOPES), book: bookName, type: 'manuscript' });
|
||||
let at = 0;
|
||||
if (chapterName !== undefined) {
|
||||
const matches = files.filter(file => file.stem === chapterName);
|
||||
if (matches.length > 1) raise('query-chapter-ambiguous', `${matches.length} visible files are named ${JSON.stringify(chapterName)}`);
|
||||
if (!matches.length) return null;
|
||||
at = files.indexOf(matches[0]);
|
||||
}
|
||||
const file = files[at];
|
||||
if (!file) return null;
|
||||
const nextChapter = files[at + 1]?.stem ?? null;
|
||||
return fitPage(file.lines.slice(start - 1, end), {
|
||||
limit: MANUSCRIPT_LINES,
|
||||
build: (picked, next) => ({
|
||||
path: file.path, commit: index.commit, scope: file.scope, lines: picked,
|
||||
next: next === null ? null : start + next, next_chapter: nextChapter,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// universeInfo: a universe's kind, name, books, playground prefixes, type folders and manuscript folders.
|
||||
// A hybrid adds its sources, constraints and notes. A draft has no books and no canon: its kind, name
|
||||
// and prefixes are the whole answer beside the mapping's types. It reads only the checked mapping and the
|
||||
// served commit.
|
||||
export async function universeInfo(source, { universe: name } = {}) {
|
||||
const universe = universeArg(source.mapping, name);
|
||||
const entry = source.mapping.universes[universe];
|
||||
const hybrid = entry.kind === 'hybrid';
|
||||
return checked({
|
||||
commit: source.commit, universe, name: entry.name, kind: entry.kind ?? 'universe',
|
||||
books: entry.books, playground: entry.playground, types: source.mapping.types, manuscript: source.mapping.manuscript,
|
||||
...(hybrid ? {
|
||||
sources: entry.sources, constraints: entry.constraints,
|
||||
...(entry.notes === undefined ? {} : { notes: entry.notes }),
|
||||
} : {}),
|
||||
});
|
||||
}
|
||||
@@ -2,22 +2,16 @@
|
||||
// Paths are repo-relative and compared exactly, case included (R7 handles case-only twins).
|
||||
const inside = (folder, path) => path.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
|
||||
|
||||
// { scope: 'supporting' | 'playground' | 'canon', universe, book, type, folder } or { scope: 'unmapped' }.
|
||||
// Prefixes come first (ruling after r11, C2-01c), supporting before playground (C2-01d, design 1.2):
|
||||
// a `.md` file under any entry's supporting prefix is supporting, and otherwise one under a playground
|
||||
// prefix is playground. Either way `universe` is that entry and `book` is the book folder that holds
|
||||
// the prefix, or null at universe level and in a draft. Otherwise a `.md` file in a book folder is
|
||||
// canon. `type` is the mapped type of the first folder under the prefix or under the book (canon),
|
||||
// { scope: 'playground' | 'canon', universe, book, type, folder } or { scope: 'unmapped' }.
|
||||
// Prefixes come first (ruling after r11, C2-01c): a `.md` file under any entry's playground prefix is
|
||||
// playground, with that entry as `universe` and the book folder that holds the prefix as `book`, or
|
||||
// null at universe level and in a draft. Otherwise a `.md` file in a book folder is canon. `type` is
|
||||
// the mapped type of the first folder under the prefix (playground) or under the book (canon),
|
||||
// 'manuscript' for a manuscript folder, else null. `folder` is that folder name. checkMapping keeps
|
||||
// prefixes of one list from overlapping, and keeps a supporting prefix from holding a playground
|
||||
// prefix, so the first match is the only one that counts.
|
||||
// prefixes from overlapping, so at most one matches.
|
||||
export function classify(mapping, path) {
|
||||
if (typeof path !== 'string' || !path.endsWith('.md')) return { scope: 'unmapped' };
|
||||
const entries = Object.entries(mapping.universes);
|
||||
for (const [universe, entry] of entries) {
|
||||
const prefix = entry.supporting.find(p => inside(p, path));
|
||||
if (prefix) return place(mapping, path, prefix, { scope: 'supporting', universe, book: entry.books.find(b => inside(b, prefix)) ?? null });
|
||||
}
|
||||
for (const [universe, entry] of entries) {
|
||||
const prefix = entry.playground.find(p => inside(p, path));
|
||||
if (prefix) return place(mapping, path, prefix, { scope: 'playground', universe, book: entry.books.find(b => inside(b, prefix)) ?? null });
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
// C2-01d (M12): the paths an agent may write. A path passes only under a supporting prefix of the
|
||||
// named universe, and only if nothing in the tree could turn the write into something else: a
|
||||
// symlink or gitlink on the way, or a name that differs from a tree name only in case or Unicode
|
||||
// form (APFS folds both, so her checkout would put the file somewhere else). Pure; reads no git.
|
||||
import { LibraryError } from './problems.mjs';
|
||||
import { classify } from './scope.mjs';
|
||||
|
||||
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
||||
const MAX_BYTES = 1024;
|
||||
const LINKS = new Set(['120000', '160000']);
|
||||
const fold = name => name.normalize('NFC').toLowerCase();
|
||||
|
||||
// `tree` is the array listTree returns ({ mode, type, oid, size, path }). Returns `path` or throws
|
||||
// library-write-story-path naming the class. Checks run in a fixed order, so a path with two faults
|
||||
// always names the same one.
|
||||
export function supportingPath(mapping, universe, path, tree) {
|
||||
if (!Array.isArray(tree)) throw new TypeError('supportingPath needs the listTree array');
|
||||
const refuse = message => { throw new LibraryError('library-write-story-path', message); };
|
||||
if (typeof path !== 'string') refuse('the path must be a string');
|
||||
const at = JSON.stringify(path);
|
||||
if (Buffer.byteLength(path, 'utf8') > MAX_BYTES) refuse(`${at} is longer than ${MAX_BYTES} bytes`);
|
||||
if (CONTROL.test(path)) refuse('the path holds a control character');
|
||||
if (path.includes('\\')) refuse(`${at} holds a backslash`);
|
||||
if (path.startsWith('/')) refuse(`${at} is absolute`);
|
||||
const parts = path.split('/');
|
||||
if (parts.some(part => part === '' || part === '.' || part === '..')) refuse(`${at} has an empty, . or .. segment`);
|
||||
const place = classify(mapping, path);
|
||||
if (place.scope !== 'supporting' || place.universe !== universe)
|
||||
refuse(`${at} is not under a supporting folder of universe ${JSON.stringify(universe)}`);
|
||||
const own = parts.map((_, i) => parts.slice(0, i + 1).join('/'));
|
||||
const modes = new Map(tree.map(entry => [entry.path, entry.mode]));
|
||||
for (const p of own) if (LINKS.has(modes.get(p))) refuse(`${at} goes through the symlink or gitlink ${JSON.stringify(p)}`);
|
||||
const names = new Map();
|
||||
for (const entry of tree) {
|
||||
const segs = entry.path.split('/');
|
||||
for (let i = 1; i <= segs.length; i++) {
|
||||
const name = segs.slice(0, i).join('/');
|
||||
const key = fold(name);
|
||||
if (!names.has(key)) names.set(key, new Set());
|
||||
names.get(key).add(name);
|
||||
}
|
||||
}
|
||||
for (const p of own) {
|
||||
const seen = names.get(fold(p));
|
||||
if (seen && [...seen].some(name => name !== p))
|
||||
refuse(`${at} differs only in case or Unicode form from ${JSON.stringify([...seen].find(name => name !== p))}`);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export async function survey({ repo, commit, mapping }) {
|
||||
forms, attachments, fields,
|
||||
};
|
||||
if (mapping) {
|
||||
const scopes = { canon: 0, playground: 0, supporting: 0, unmapped: 0 };
|
||||
const scopes = { canon: 0, playground: 0, unmapped: 0 };
|
||||
for (const entry of entries) scopes[classify(mapping, entry.path).scope]++;
|
||||
const library = await readLibrary({ repo, commit: head, mapping });
|
||||
const steps = Object.fromEntries(STEPS.map(s => [s, 0]));
|
||||
@@ -89,7 +89,7 @@ export function formatSurvey(s) {
|
||||
out.push(`body links ${FORMS.map(f => `${f} ${s.forms[f]}`).join(', ')}; attachment ${s.attachments}`);
|
||||
for (const [field, c] of Object.entries(s.fields)) out.push(`field ${field} wikilink ${c.wikilink}, name ${c.name}`);
|
||||
if (s.scopes) {
|
||||
out.push(`scopes canon ${s.scopes.canon}, playground ${s.scopes.playground}, supporting ${s.scopes.supporting}, unmapped ${s.scopes.unmapped}; indexed ${s.indexed}`);
|
||||
out.push(`scopes canon ${s.scopes.canon}, playground ${s.scopes.playground}, unmapped ${s.scopes.unmapped}; indexed ${s.indexed}`);
|
||||
out.push(`resolved ${STEPS.map(st => `${st} ${s.steps[st]}`).join(', ')}, unresolved ${s.steps.unresolved}`);
|
||||
out.push(`problems ${Object.entries(s.problems).map(([code, n]) => `${code} ${n}`).join(', ')}`);
|
||||
}
|
||||
|
||||
+12
-147
@@ -2,9 +2,6 @@
|
||||
// configured branch into refs/remotes/library/<branch>. A fast-forward moves refs/heads/served,
|
||||
// a non-fast-forward holds it until a human accepts, and a failed fetch keeps serving. The token
|
||||
// never reaches argv, env, config, a URL or a log: git's GIT_ASKPASS helper reads it from the slot.
|
||||
// C2-03b: the same command pushes, fast-forward only, and the file the helper reads is the one
|
||||
// credential the caller chose: a seat's grant store file, the chief's for the fetch, slot
|
||||
// library-gitea before any chief grant, or slot library-human for her save (ruling after r11).
|
||||
import { constants, closeSync, fsyncSync, lstatSync, openSync, renameSync, rmSync, writeSync } from 'node:fs';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { dirname, join } from 'node:path';
|
||||
@@ -14,13 +11,10 @@ import { SAFE, git } from '../../project/src/git.mjs';
|
||||
import { checkMapping } from './mapping.mjs';
|
||||
import { LibraryError } from './problems.mjs';
|
||||
import { readLibrary } from './reader.mjs';
|
||||
import { indexFor } from './index-build.mjs';
|
||||
import { SYNC_STATE_FILE, ensureMirror, withLibraryLock, servedCommit } from './mirror.mjs';
|
||||
|
||||
export const ASKPASS = fileURLToPath(new URL('../bin/askpass', import.meta.url));
|
||||
export const SLOT = 'library-gitea';
|
||||
export const HUMAN_SLOT = 'library-human';
|
||||
export const CREDENTIAL_KINDS = Object.freeze(['slot', 'chief', 'seat', 'human']);
|
||||
export const SYNC_INTERVAL_MS = 60_000;
|
||||
// Row 199's patterns (packages/seat/src/working.mjs).
|
||||
const HOST = /^[a-z0-9.-]{1,253}(:[0-9]{1,5})?$/;
|
||||
@@ -33,21 +27,10 @@ const fail = (code, message = code) => { throw new LibraryError(code, message);
|
||||
// One sync per mirror in this process. The library lock covers publication only, so a second
|
||||
// fetch into the same mirror is refused here rather than racing the first.
|
||||
const running = new Set();
|
||||
// C2-02: opaque identity lets only the owning write re-enter push's race fetch.
|
||||
const writers = new Map();
|
||||
export async function withLibraryWriteGuard(mirror, operation) {
|
||||
if (running.has(mirror)) fail('library-sync-busy');
|
||||
const token = Object.freeze({});
|
||||
running.add(mirror); writers.set(mirror, token);
|
||||
try { return await operation(token); }
|
||||
finally { writers.delete(mirror); running.delete(mirror); }
|
||||
}
|
||||
|
||||
// The one command builder for her remote. `kind` is 'fetch' or 'push'. A push sends `commit` to the
|
||||
// branch with no '+', so the remote takes a fast-forward only (D6). `slot` is the file the helper reads.
|
||||
export function syncCommand(kind, url, { mirror, branch, user, slot, commit = null }) {
|
||||
if (kind !== 'fetch' && kind !== 'push') fail('library-remote-invalid', 'sync fetches or pushes');
|
||||
if (kind === 'push' && (typeof commit !== 'string' || !SHA.test(commit))) fail('library-remote-invalid', 'a push takes a full commit id');
|
||||
// The one command sync runs against her remote. `kind` is 'fetch'; there is no other.
|
||||
export function syncCommand(kind, url, { mirror, branch, user, slot }) {
|
||||
if (kind !== 'fetch') fail('library-remote-invalid', 'sync only fetches');
|
||||
let parsed;
|
||||
try { parsed = new URL(url); } catch { fail('library-remote-invalid', 'remote must be an https:// URL'); }
|
||||
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.search || parsed.hash
|
||||
@@ -55,13 +38,10 @@ export function syncCommand(kind, url, { mirror, branch, user, slot, commit = nu
|
||||
fail('library-remote-invalid', 'remote must be https:// with a plain host and no credentials');
|
||||
// core.createObject=rename: git's default files each object by link then unlink, and a reader's
|
||||
// inspectMirror (C2-04) refuses a file with two links while that runs.
|
||||
const command = kind === 'fetch'
|
||||
? ['fetch', '--no-tags', '--no-write-fetch-head', '--no-recurse-submodules', '--end-of-options',
|
||||
parsed.href, `+refs/heads/${branch}:refs/remotes/library/${branch}`]
|
||||
: ['push', '--porcelain', '--no-verify', '--no-follow-tags', '--end-of-options', parsed.href, `${commit}:refs/heads/${branch}`];
|
||||
return {
|
||||
argv: [...SAFE, '-c', 'protocol.https.allow=always', '-c', 'http.followRedirects=false', '-c', 'credential.helper=',
|
||||
'-c', 'core.createObject=rename', '-C', mirror, ...command],
|
||||
'-c', 'core.createObject=rename', '-C', mirror, 'fetch', '--no-tags', '--no-write-fetch-head', '--no-recurse-submodules', '--end-of-options',
|
||||
parsed.href, `+refs/heads/${branch}:refs/remotes/library/${branch}`],
|
||||
env: { ...gitEnv(), LC_ALL: 'C', GIT_CEILING_DIRECTORIES: dirname(mirror), GIT_ASKPASS: ASKPASS,
|
||||
MOSAIC_LIBRARY_HOST: parsed.host, MOSAIC_LIBRARY_USER: user, MOSAIC_LIBRARY_SLOT: slot },
|
||||
};
|
||||
@@ -73,32 +53,6 @@ function slotPresent(slot) {
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
// A chosen credential, { kind, user, file }, or { refused: code } when the bus refused one.
|
||||
function checkCredential(credential) {
|
||||
if (credential === null || typeof credential !== 'object') fail('library-credential-missing', 'no credential chosen');
|
||||
if (Object.hasOwn(credential, 'refused')) return credential;
|
||||
const { kind, user, file } = credential;
|
||||
if (!CREDENTIAL_KINDS.includes(kind) || (user !== undefined && (typeof user !== 'string' || !NAME.test(user)))
|
||||
|| (file !== null && typeof file !== 'string'))
|
||||
fail('library-credential-missing', 'credential must name a kind, a plain user and a file');
|
||||
return credential;
|
||||
}
|
||||
|
||||
// C2-03b: her save and revert push with slot library-human and her Gitea login. A missing login, or a
|
||||
// slot that is missing, a link, another uid's or not 0600, refuses before any git call. Like sync, this
|
||||
// never opens the slot; the helper reads it with readSecretFile's checks.
|
||||
export function humanCredential(context, { user, configDir }) {
|
||||
const missing = message => fail('library-human-missing', message);
|
||||
if (typeof user !== 'string' || !NAME.test(user)) missing('her Gitea login is not configured');
|
||||
if (typeof configDir !== 'string' || typeof context?.business?.id !== 'string') missing('slot library-human is not configured');
|
||||
const file = join(configDir, 'secrets', context.business.id, HUMAN_SLOT);
|
||||
let st;
|
||||
try { st = lstatSync(file); } catch { missing('slot library-human is missing'); }
|
||||
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600 || st.size > 16384)
|
||||
missing('slot library-human must be a regular 0600 file the bus owns');
|
||||
return { kind: 'human', user, file };
|
||||
}
|
||||
|
||||
async function isAncestor(mirror, older, newer) {
|
||||
const result = await runGit([...SAFE, '-C', mirror, 'merge-base', '--is-ancestor', older, newer],
|
||||
{ ...gitEnv(), GIT_CEILING_DIRECTORIES: dirname(mirror) });
|
||||
@@ -135,27 +89,19 @@ const errorCode = error => typeof error?.code === 'string' && CODE.test(error.co
|
||||
// 'library-credential-missing', 'library-diverged' or a reader refusal). It throws for a bad
|
||||
// entry, a busy mirror, a stale --accept or an unreadable state. Sync never runs gc or prune:
|
||||
// a reader holds `served` after the lock is released and reads its objects later (Filbert N2).
|
||||
// C2-03b: `credential` is the bus's choice for the fetch, { kind, user, file } or { refused: code }.
|
||||
// A refusal (library-fetch-no-grant) is recorded like any held fetch, and no file is opened. Without
|
||||
// it, `slot` is slot library-gitea with the entry's user, as in C2-03a. The state names the kind and
|
||||
// the user the last fetch ran with, never the file or the value.
|
||||
export async function sync(context, { slot, credential = null, accept = null, command = syncCommand, now = () => new Date(), limits = GIT_LIMITS } = {}) {
|
||||
export async function sync(context, { slot, accept = null, command = syncCommand, now = () => new Date(), limits = GIT_LIMITS } = {}) {
|
||||
const mirror = await ensureMirror(context);
|
||||
const mapping = checkMapping(context.business.projects[context.project]);
|
||||
const { branch } = mapping;
|
||||
if (typeof mapping.user !== 'string') fail('library-remote-invalid', 'library entry has no user for sync');
|
||||
const chosen = credential === null ? { kind: 'slot', file: typeof slot === 'string' ? slot : null } : checkCredential(credential);
|
||||
const user = chosen.user ?? mapping.user;
|
||||
const { branch, user } = mapping;
|
||||
if (typeof user !== 'string') fail('library-remote-invalid', 'library entry has no user for sync');
|
||||
if (accept !== null && (typeof accept !== 'string' || !SHA.test(accept))) fail('library-accept-invalid', 'accept takes a full commit id');
|
||||
if (running.has(mirror)) fail('library-sync-busy', 'a sync of this library is running');
|
||||
running.add(mirror);
|
||||
try {
|
||||
let code = null, tip = null, read = null, used = null;
|
||||
if (Object.hasOwn(chosen, 'refused')) code = typeof chosen.refused === 'string' && CODE.test(chosen.refused) ? chosen.refused : 'library-fetch-no-grant';
|
||||
else if (typeof chosen.file !== 'string' || !slotPresent(chosen.file)) code = 'library-credential-missing';
|
||||
let code = null, tip = null, read = null;
|
||||
if (typeof slot !== 'string' || !slotPresent(slot)) code = 'library-credential-missing';
|
||||
else {
|
||||
used = { kind: chosen.kind, user };
|
||||
const { argv, env } = command('fetch', mapping.remote, { mirror, branch, user, slot: chosen.file });
|
||||
const { argv, env } = command('fetch', mapping.remote, { mirror, branch, user, slot });
|
||||
const result = await runGit(argv, env, limits);
|
||||
// git's stderr is never kept or logged: the code is the whole record.
|
||||
if (!result.ok) code = 'library-fetch-failed';
|
||||
@@ -181,7 +127,7 @@ export async function sync(context, { slot, credential = null, accept = null, co
|
||||
const prev = accept !== null
|
||||
? { version: 1, branch, served, lastFetch: null, lastError: null, diverged: null, problems: [] }
|
||||
: await readSyncState({ branch });
|
||||
let next = { ...prev, credential: used }, target = served;
|
||||
let next = { ...prev }, target = served;
|
||||
if (code !== null) next.lastError = { code, at };
|
||||
else if (accept !== null) {
|
||||
const problems = await problemsOf();
|
||||
@@ -212,84 +158,3 @@ export async function sync(context, { slot, credential = null, accept = null, co
|
||||
});
|
||||
} finally { running.delete(mirror); }
|
||||
}
|
||||
|
||||
// C2-03b: push `commit`, already in the mirror, to her branch with the writer's own credential. It
|
||||
// resolves { pushed: true } or, when the remote tip moved, { pushed: false, raced: true, tip } after
|
||||
// one fetch with the same credential; the caller re-applies once and calls again with retried: true,
|
||||
// and a second race refuses stale-base. The fetch refuses library-sync-busy while a sync of the same
|
||||
// mirror runs. A hook refusal ([remote rejected]) or any other failure
|
||||
// refuses library-push-failed, and git's output is never kept. It moves no local ref: C2-02 moves the
|
||||
// remote-tracking and served refs after a push.
|
||||
export async function push(context, { commit, credential, retried = false, writeToken = null, command = syncCommand, limits = GIT_LIMITS } = {}) {
|
||||
const mirror = await ensureMirror(context);
|
||||
const mapping = checkMapping(context.business.projects[context.project]);
|
||||
const { branch } = mapping;
|
||||
const chosen = checkCredential(credential);
|
||||
if (Object.hasOwn(chosen, 'refused'))
|
||||
fail(typeof chosen.refused === 'string' && CODE.test(chosen.refused) ? chosen.refused : 'library-credential-missing', 'the bus refused a credential');
|
||||
if (typeof chosen.user !== 'string') fail('library-credential-missing', 'a push names its user');
|
||||
if (typeof chosen.file !== 'string' || !slotPresent(chosen.file))
|
||||
fail(chosen.kind === 'human' ? 'library-human-missing' : 'library-credential-missing', 'credential file missing or unsafe');
|
||||
if (typeof commit !== 'string' || !SHA.test(commit)) fail('library-remote-invalid', 'a push takes a full commit id');
|
||||
const opts = { mirror, branch, user: chosen.user, slot: chosen.file };
|
||||
const { argv, env } = command('push', mapping.remote, { ...opts, commit });
|
||||
const result = await runGit(argv, env, limits);
|
||||
if (result.ok) return { pushed: true };
|
||||
// --porcelain: one tab-separated line per ref on stdout, '!' and the summary for a refusal.
|
||||
const line = String(result.out ?? '').split('\n').find(l => l.split('\t')[1] === `${commit}:refs/heads/${branch}`);
|
||||
const summary = line?.split('\t')[2] ?? '';
|
||||
if (!line?.startsWith('!\t') || !/^\[rejected\] \((fetch first|non-fast-forward)\)$/.test(summary))
|
||||
fail('library-push-failed', 'push refused');
|
||||
if (retried) fail('stale-base', 'her branch moved again during the write');
|
||||
// The race fetch writes the mirror, so it takes sync's guard (dewey-0864 R1). A held guard refuses
|
||||
// library-sync-busy and the caller tries the write again.
|
||||
const owned = writeToken !== null && writers.get(mirror) === writeToken;
|
||||
if (running.has(mirror) && !owned) fail('library-sync-busy', 'a sync of this library is running');
|
||||
if (!owned) running.add(mirror);
|
||||
try {
|
||||
const fetch = command('fetch', mapping.remote, opts);
|
||||
if (!(await runGit(fetch.argv, fetch.env, limits)).ok) fail('library-push-failed', 'fetch after a moved tip failed');
|
||||
let tip;
|
||||
try { tip = await git(mirror, ['rev-parse', '--verify', '--end-of-options', `refs/remotes/library/${branch}^{commit}`]); }
|
||||
catch { fail('library-push-failed', 'fetch after a moved tip found no branch'); }
|
||||
if (!SHA.test(tip)) fail('library-push-failed', 'fetch after a moved tip found no branch');
|
||||
return { pushed: false, raced: true, tip };
|
||||
} finally { if (!owned) running.delete(mirror); }
|
||||
}
|
||||
|
||||
// Called under the publication lock by C2-02; the raw state writer stays private.
|
||||
export async function publishLibraryWrite(mirror, { mapping, commit, previous, readSyncState, now = () => new Date() }) {
|
||||
if (!SHA.test(commit) || (previous !== null && !SHA.test(previous))) fail('library-state-invalid');
|
||||
const state = await readSyncState({ branch: mapping.branch });
|
||||
if (state.diverged !== null) fail('library-diverged');
|
||||
if (state.served !== previous) fail('library-state-invalid');
|
||||
const read = await indexFor({ repo: mirror, commit, mapping });
|
||||
const at = now().toISOString();
|
||||
const next = { ...state, served: commit, lastPush: { commit, at }, lastError: null,
|
||||
problems: read.problems.map(p => ({ ...p })) };
|
||||
const tracking = `refs/remotes/library/${mapping.branch}`;
|
||||
// The remote push already succeeded. A local publication failure is outcome-unknown to the
|
||||
// caller; it must never encourage an automatic second push.
|
||||
let moved = false;
|
||||
try {
|
||||
await git(mirror, ['update-ref', 'refs/heads/served', commit, previous ?? ZERO]);
|
||||
moved = true;
|
||||
await git(mirror, ['update-ref', tracking, commit]);
|
||||
writeState(mirror, next);
|
||||
} catch {
|
||||
if (moved) {
|
||||
// A rename may have succeeded before directory fsync failed. Keep the new ref when the
|
||||
// validated state already matches it; otherwise restore served by compare-and-swap.
|
||||
let published = false;
|
||||
try { published = (await readSyncState({ branch: mapping.branch })).served === commit; } catch {}
|
||||
if (!published) {
|
||||
try {
|
||||
await git(mirror, ['update-ref', ...(previous === null
|
||||
? ['-d', 'refs/heads/served', commit] : ['refs/heads/served', previous, commit])]);
|
||||
} catch { fail('outcome-unknown'); }
|
||||
}
|
||||
}
|
||||
fail('outcome-unknown');
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
// C2-02: one-path plumbing commits on the private bare mirror. No checkout and no pending push.
|
||||
import { lstatSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { SAFE, git, gitBytes } from '../../project/src/git.mjs';
|
||||
import { gitEnv, runGit, GIT_LIMITS } from '../../seat/src/checkout.mjs';
|
||||
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
|
||||
import { checkMapping } from './mapping.mjs';
|
||||
import { classify } from './scope.mjs';
|
||||
import { supportingPath } from './supporting.mjs';
|
||||
import { LibraryError } from './problems.mjs';
|
||||
import { listTree, MAX_BLOB } from './reader.mjs';
|
||||
import { mirrorPath, ensureMirror, withLibraryLock } from './mirror.mjs';
|
||||
import { syncCommand, push, withLibraryWriteGuard, publishLibraryWrite } from './sync.mjs';
|
||||
|
||||
const SHA = /^[a-f0-9]{40}$/;
|
||||
const fail = code => { throw new LibraryError(code, code); };
|
||||
const ident = identity => ({ GIT_AUTHOR_NAME: identity.name, GIT_AUTHOR_EMAIL: identity.email,
|
||||
GIT_COMMITTER_NAME: identity.name, GIT_COMMITTER_EMAIL: identity.email });
|
||||
|
||||
function mappingOf(context) {
|
||||
if (!Object.hasOwn(context.business?.projects ?? {}, context.project)) fail('project-not-configured');
|
||||
return checkMapping(context.business.projects[context.project]);
|
||||
}
|
||||
function contentOf(value) {
|
||||
if (typeof value !== 'string' || value.includes('\0') || Buffer.byteLength(value, 'utf8') > MAX_BLOB
|
||||
|| Buffer.from(value, 'utf8').toString('utf8') !== value) fail('invalid-request');
|
||||
return value;
|
||||
}
|
||||
function credentialOf(credential, human) {
|
||||
const code = human ? 'library-human-missing' : 'library-write-no-grant';
|
||||
if (!credential || credential.kind !== (human ? 'human' : 'seat')
|
||||
|| typeof credential.user !== 'string' || !/^[A-Za-z0-9_.-]{1,100}$/.test(credential.user)
|
||||
|| typeof credential.file !== 'string') fail(code);
|
||||
try {
|
||||
const st = lstatSync(credential.file);
|
||||
if (!st.isFile() || st.uid !== process.getuid() || st.nlink !== 1 || (st.mode & 0o777) !== 0o600 || st.size > 16384) fail(code);
|
||||
} catch { fail(code); }
|
||||
return credential;
|
||||
}
|
||||
// Her save uses the same structural and folded-tree checks; only the supporting rule changes.
|
||||
function pathOf(mapping, universe, path, tree, human) {
|
||||
if (!human) return supportingPath(mapping, universe, path, tree);
|
||||
if (typeof path !== 'string' || classify(mapping, path).scope === 'unmapped') fail('library-write-story-path');
|
||||
const place = classify(mapping, path);
|
||||
const universes = Object.fromEntries(Object.keys(mapping.universes).map(id => [id,
|
||||
{ ...mapping.universes[id], supporting: id === place.universe ? [path.slice(0, path.lastIndexOf('/') + 1)] : [] }]));
|
||||
return supportingPath({ ...mapping, universes }, place.universe, path, tree);
|
||||
}
|
||||
async function ref(mirror, name) {
|
||||
const rows = await git(mirror, ['for-each-ref', '--format=%(refname) %(objectname)', name]);
|
||||
const line = rows.split('\n').find(row => row.startsWith(name + ' '));
|
||||
return line ? line.slice(name.length + 1) : null;
|
||||
}
|
||||
async function ancestor(mirror, older, newer) {
|
||||
const result = await runGit([...SAFE, '-C', mirror, 'merge-base', '--is-ancestor', older, newer], gitEnv());
|
||||
if (result.ok) return true;
|
||||
if (result.why === 'exit 1') return false;
|
||||
fail('library-git');
|
||||
}
|
||||
async function fetchTip(mirror, mapping, credential, command) {
|
||||
const c = command('fetch', mapping.remote, { mirror, branch: mapping.branch, user: credential.user, slot: credential.file });
|
||||
if (!(await runGit(c.argv, c.env)).ok) fail('library-push-failed');
|
||||
const tip = await ref(mirror, `refs/remotes/library/${mapping.branch}`);
|
||||
if (!tip || !SHA.test(tip)) fail('library-push-failed');
|
||||
return tip;
|
||||
}
|
||||
|
||||
async function plumbing(mirror, tip, path, content, identity, message, stage) {
|
||||
const env = { ...gitEnv(), ...ident(identity), GIT_INDEX_FILE: join(stage, 'index'), GIT_CEILING_DIRECTORIES: dirname(mirror) };
|
||||
const run = async (args, input = null) => {
|
||||
const result = await runGit([...SAFE, '-c', 'core.createObject=rename', '-C', mirror, ...args], env, GIT_LIMITS, { stdin: input });
|
||||
if (!result.ok) fail('library-git');
|
||||
return result.out.trimEnd();
|
||||
};
|
||||
rmSync(env.GIT_INDEX_FILE, { force: true });
|
||||
await run(['read-tree', tip]);
|
||||
if (content === null) await run(['update-index', '--force-remove', '--', path]);
|
||||
else {
|
||||
const file = join(stage, 'content'); writeFileSync(file, content, { mode: 0o600 });
|
||||
const blob = await run(['hash-object', '-w', '--no-filters', file]);
|
||||
// --cacheinfo keeps the path a separate argv item; no pathspec expansion or filters.
|
||||
await run(['update-index', '--add', '--cacheinfo', '100644', blob, path]);
|
||||
}
|
||||
const tree = await run(['write-tree']);
|
||||
return run(['commit-tree', tree, '-p', tip, '-m', message]);
|
||||
}
|
||||
|
||||
async function revertChange(mirror, served, context, mapping) {
|
||||
const commit = context.commit;
|
||||
const refuse = () => fail('library-revert-not-agent');
|
||||
if (!served || !SHA.test(commit)) refuse();
|
||||
let text, parents;
|
||||
try {
|
||||
if (!await ancestor(mirror, commit, served)) refuse();
|
||||
text = await git(mirror, ['show', '-s', '--format=%(trailers:key=Mosaic-Agent,valueonly)', commit]);
|
||||
parents = (await git(mirror, ['show', '-s', '--format=%P', commit])).split(' ');
|
||||
} catch { refuse(); }
|
||||
if (parents.length !== 1 || !SHA.test(parents[0])
|
||||
|| !text.startsWith(context.business.id + '/')
|
||||
|| !ID_PATTERN.test(text.slice(context.business.id.length + 1))) refuse();
|
||||
const before = await listTree(mirror, parents[0]), after = await listTree(mirror, commit);
|
||||
const old = new Map(before.map(e => [e.path, e])), current = new Map(after.map(e => [e.path, e]));
|
||||
const changed = [...new Set([...old.keys(), ...current.keys()])].filter(path =>
|
||||
old.get(path)?.oid !== current.get(path)?.oid || old.get(path)?.mode !== current.get(path)?.mode);
|
||||
if (changed.length !== 1) refuse();
|
||||
const path = changed[0], was = old.get(path), made = current.get(path);
|
||||
if ((was && (was.mode !== '100644' || was.type !== 'blob' || was.size > MAX_BLOB))
|
||||
|| (made && (made.mode !== '100644' || made.type !== 'blob'))) refuse();
|
||||
pathOf(mapping, null, path, after, true);
|
||||
const bytes = was ? await gitBytes(mirror, ['cat-file', 'blob', was.oid], MAX_BLOB) : null;
|
||||
let content = null;
|
||||
if (bytes !== null) {
|
||||
try { content = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); } catch { refuse(); }
|
||||
contentOf(content);
|
||||
}
|
||||
return { path, base: made?.oid ?? null, content, reverted: commit };
|
||||
}
|
||||
|
||||
async function write(context, options, human, revert = false) {
|
||||
const mapping = mappingOf(context);
|
||||
if (human && !mapping.editor) fail('library-mapping-invalid');
|
||||
const credential = credentialOf(options.credential, human);
|
||||
const identity = human ? mapping.editor : options.identity;
|
||||
if (!identity || typeof identity.name !== 'string' || typeof identity.email !== 'string') fail('library-write-refused');
|
||||
if (!human && (typeof options.agent !== 'string' || !options.agent.startsWith(context.business.id + '/'))) fail('library-write-refused');
|
||||
const op = options.op ?? 'write';
|
||||
if (!['write', 'delete'].includes(op)) fail('invalid-request');
|
||||
let change = revert ? null : { path: context.path, base: context.base, content: op === 'delete' ? null : contentOf(context.content) };
|
||||
if (change) {
|
||||
if (change.base !== null && (typeof change.base !== 'string' || !SHA.test(change.base))) fail('invalid-request');
|
||||
pathOf(mapping, context.universe, change.path, [], human);
|
||||
}
|
||||
const mirror = mirrorPath(context);
|
||||
return withLibraryWriteGuard(mirror, writeToken => withLibraryLock(mirror, async lock => {
|
||||
await ensureMirror(context);
|
||||
const state = await lock.readSyncState({ branch: mapping.branch });
|
||||
if (state.diverged !== null) fail('library-diverged');
|
||||
const served = state.served;
|
||||
if (!served) fail('library-commit');
|
||||
if (revert) change = await revertChange(mirror, served, context, mapping);
|
||||
// Check the served tree before any remote effect, then recheck against every fetched tip.
|
||||
pathOf(mapping, context.universe, change.path, await listTree(mirror, served), human);
|
||||
const tracking = `refs/remotes/library/${mapping.branch}`, previous = await ref(mirror, tracking);
|
||||
let pushed = false;
|
||||
const stage = mkdtempSync(join(dirname(mirror), '.library-write-'));
|
||||
try {
|
||||
let tip = await fetchTip(mirror, mapping, credential, options.command ?? syncCommand);
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
if (!await ancestor(mirror, served, tip)) fail('library-diverged');
|
||||
const tree = await listTree(mirror, tip);
|
||||
pathOf(mapping, context.universe, change.path, tree, human);
|
||||
const entry = tree.find(e => e.path === change.path);
|
||||
if ((!human && op === 'delete' && (!entry || entry.mode !== '100644' || entry.type !== 'blob'))
|
||||
|| (entry && (entry.mode !== '100644' || entry.type !== 'blob'))
|
||||
|| tree.some(e => e.path.startsWith(change.path + '/') || change.path.startsWith(e.path + '/')))
|
||||
fail('library-write-story-path');
|
||||
if ((entry?.oid ?? null) !== change.base) fail('stale-base');
|
||||
const action = change.content === null ? 'delete' : 'write';
|
||||
const message = `Mosaic: ${action} ${change.path}` + (human ? '' : `\n\nMosaic-Agent: ${options.agent}`);
|
||||
const commit = await plumbing(mirror, tip, change.path, change.content, identity, message, stage);
|
||||
// Broker rechecks live grants/holder immediately before each push.
|
||||
options.beforePush?.();
|
||||
const result = await push(context, { commit, credential, retried: attempt === 1, writeToken, command: options.command ?? syncCommand });
|
||||
if (!result.pushed) { tip = result.tip; continue; }
|
||||
pushed = true;
|
||||
try {
|
||||
// The broker records the pushed commit even if publication fails next (mos-1520).
|
||||
options.onPushed?.({ project: context.project, path: change.path, commit });
|
||||
await publishLibraryWrite(mirror, { mapping, commit, previous: served, readSyncState: lock.readSyncState, now: options.now });
|
||||
} catch { fail('outcome-unknown'); }
|
||||
return { project: context.project, path: change.path, ...(revert ? { reverted: change.reverted } : {}), commit };
|
||||
}
|
||||
fail('stale-base');
|
||||
} finally {
|
||||
rmSync(stage, { recursive: true, force: true });
|
||||
if (!pushed) {
|
||||
if (previous === null) await git(mirror, ['update-ref', '-d', tracking]);
|
||||
else await git(mirror, ['update-ref', tracking, previous]);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
export const writeLibraryDoc = (context, options = {}) => write(context, options, false);
|
||||
export const saveLibraryFile = (context, options = {}) => write(context, options, true);
|
||||
export const revertLibraryCommit = (context, options = {}) => write(context, options, true, true);
|
||||
@@ -1,91 +0,0 @@
|
||||
// Index tests (C2-05a): the cache key and its bound, failed reads, mapped-only contents, and the rebuild
|
||||
// time on a 3,000-file fixture. Fixtures are made up and built under the scratch area.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { clearIndexCache, indexCacheSize, indexFor } from '../src/index-build.mjs';
|
||||
import { git, mapping, MARKER } from './helpers.mjs';
|
||||
import { note, scratchRepo } from './query-fixture.mjs';
|
||||
|
||||
const FILES = {
|
||||
'Book One/Characters/Mara Vell.md': note('character', 'Mara Vell', MARKER),
|
||||
'Book One/Chapters/Chapter 01.md': '# One\n[[Mara Vell]]\n',
|
||||
'Inbox Note.md': `${MARKER}\n`,
|
||||
};
|
||||
|
||||
// A mapping whose harbor universe has the given display name. Each name gives a new cache key.
|
||||
const named = name => mapping(e => ({ ...e, universes: { ...e.universes, harbor: { ...e.universes.harbor, name } } }));
|
||||
|
||||
test('the same commit, repo path and mapping return the same index, and only mapped files are in it', async t => {
|
||||
clearIndexCache();
|
||||
const { repo, commit } = scratchRepo(t, FILES);
|
||||
const a = await indexFor({ repo, commit, mapping: mapping() });
|
||||
const b = await indexFor({ repo, commit, mapping: mapping() });
|
||||
assert.equal(a, b);
|
||||
assert.equal(indexCacheSize(), 1);
|
||||
assert.equal(a.commit, commit);
|
||||
assert.ok(a.files.has('Book One/Characters/Mara Vell.md'));
|
||||
assert.ok(!a.files.has('Inbox Note.md'));
|
||||
assert.deepEqual(a.names.get('mara vell'), ['Book One/Characters/Mara Vell.md']);
|
||||
assert.deepEqual(a.linksIn.get('Book One/Characters/Mara Vell.md').map(l => l.path), ['Book One/Chapters/Chapter 01.md']);
|
||||
});
|
||||
|
||||
test('a different repo path, mapping or commit gets its own index', async t => {
|
||||
clearIndexCache();
|
||||
const { root, repo, commit } = scratchRepo(t, FILES);
|
||||
const base = await indexFor({ repo, commit, mapping: mapping() });
|
||||
const alias = join(root, 'alias');
|
||||
symlinkSync(repo, alias);
|
||||
assert.notEqual(await indexFor({ repo: alias, commit, mapping: mapping() }), base);
|
||||
assert.notEqual(await indexFor({ repo, commit, mapping: named('Renamed Harbor') }), base);
|
||||
writeFileSync(join(repo, 'Book One/Places/New.md'), note('place', 'New'));
|
||||
git(repo, 'add', '-A');
|
||||
git(repo, 'commit', '-qm', 'second');
|
||||
const next = git(repo, 'rev-parse', 'HEAD');
|
||||
const later = await indexFor({ repo, commit: next, mapping: mapping() });
|
||||
assert.notEqual(later, base);
|
||||
assert.equal(later.commit, next);
|
||||
assert.ok(later.files.has('Book One/Places/New.md'));
|
||||
});
|
||||
|
||||
test('the cache holds at most eight indexes and evicts the least recently used', async t => {
|
||||
clearIndexCache();
|
||||
const { repo, commit } = scratchRepo(t, FILES);
|
||||
const maps = Array.from({ length: 9 }, (_, i) => named(`Harbor ${i}`));
|
||||
const first = await indexFor({ repo, commit, mapping: maps[0] });
|
||||
for (const m of maps.slice(1)) await indexFor({ repo, commit, mapping: m });
|
||||
assert.equal(indexCacheSize(), 8);
|
||||
assert.notEqual(await indexFor({ repo, commit, mapping: maps[0] }), first);
|
||||
assert.equal(indexCacheSize(), 8);
|
||||
});
|
||||
|
||||
test('a failed read is not kept in the cache', async t => {
|
||||
clearIndexCache();
|
||||
const { repo } = scratchRepo(t, FILES);
|
||||
await assert.rejects(indexFor({ repo, commit: '0'.repeat(40), mapping: mapping() }));
|
||||
assert.equal(indexCacheSize(), 0);
|
||||
});
|
||||
|
||||
test('a missing or unchecked mapping is refused before any read', async t => {
|
||||
clearIndexCache();
|
||||
const { repo, commit } = scratchRepo(t, FILES);
|
||||
await assert.rejects(indexFor({ repo, commit, mapping: undefined }), { code: 'library-mapping-invalid' });
|
||||
await assert.rejects(indexFor({ repo, commit, mapping: { kind: 'hybrid' } }), { code: 'library-mapping-invalid' });
|
||||
});
|
||||
|
||||
test('a 3,000-file rebuild completes and its time is reported', { timeout: 300_000 }, async t => {
|
||||
clearIndexCache();
|
||||
const folders = ['Characters', 'Places', 'Research'];
|
||||
const files = {};
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
files[`Book One/${folders[i % 3]}/Note ${i}.md`] = note('character', `Note ${i}`, `Body ${i} [[Note ${(i + 1) % 3000}]]`);
|
||||
}
|
||||
const { repo, commit } = scratchRepo(t, files);
|
||||
const started = performance.now();
|
||||
const index = await indexFor({ repo, commit, mapping: mapping() });
|
||||
const ms = performance.now() - started;
|
||||
t.diagnostic(`3,000-file index rebuild: ${ms.toFixed(0)} ms`);
|
||||
assert.equal(index.paths.length, 3000);
|
||||
});
|
||||
@@ -1,9 +1,5 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { checkMapping, classify, LibraryError } from '../src/index.mjs';
|
||||
import { mapping, mappingEntry } from './helpers.mjs';
|
||||
|
||||
@@ -49,7 +45,7 @@ test('C2-01c: universe-level prefixes lie outside every book, in a plain univers
|
||||
|
||||
test('C2-01c: a draft takes kind, name and playground only, and comes back frozen with books []', () => {
|
||||
const m = mapping(e => { e.universes.idea = draft(); return e; });
|
||||
assert.deepEqual(m.universes.idea, { kind: 'draft', name: 'New World', books: [], playground: ['Drafts/New World/'], supporting: [] });
|
||||
assert.deepEqual(m.universes.idea, { kind: 'draft', name: 'New World', books: [], playground: ['Drafts/New World/'] });
|
||||
assert.ok(Object.isFrozen(m.universes.idea) && Object.isFrozen(m.universes.idea.books) && Object.isFrozen(m.universes.idea.playground));
|
||||
refuses(e => { e.universes.idea = { ...draft(), books: ['New World'] }; return e; }, 'library-mapping-invalid', /universes.idea is a draft and takes no books/);
|
||||
refuses(e => { e.universes.idea = { ...draft(), books: [] }; return e; }, 'library-mapping-invalid', /takes no books/);
|
||||
@@ -60,107 +56,6 @@ test('C2-01c: a draft takes kind, name and playground only, and comes back froze
|
||||
refuses(e => { e.universes.harbor.kind = kind; return e; }, 'library-mapping-invalid', /universes.harbor.kind must be "hybrid" or "draft"/);
|
||||
});
|
||||
|
||||
test('C2-01d: supporting prefixes at book level, universe level, in a hybrid and in a draft, [] when absent', () => {
|
||||
const m = mapping(e => {
|
||||
e.universes.harbor.supporting = ['Book One/Notes/', 'Harbor Notes/'];
|
||||
e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: [], supporting: ['Crossover Notes/'] };
|
||||
e.universes.idea = { ...draft(), supporting: ['Drafts/New World Notes/'] };
|
||||
return e;
|
||||
});
|
||||
assert.deepEqual(m.universes.harbor.supporting, ['Book One/Notes/', 'Harbor Notes/']);
|
||||
assert.deepEqual(m.universes.cross.supporting, ['Crossover Notes/']);
|
||||
assert.deepEqual(m.universes.idea.supporting, ['Drafts/New World Notes/']);
|
||||
assert.deepEqual(m.universes.second.supporting, []);
|
||||
assert.ok(Object.isFrozen(m.universes.harbor.supporting) && Object.isFrozen(m.universes.second.supporting));
|
||||
assert.deepEqual(mapping().universes.harbor.supporting, []);
|
||||
});
|
||||
|
||||
test('C2-01d: each supporting prefix rule refuses with its code', () => {
|
||||
const invalid = (change, pattern) => refuses(change, 'library-mapping-invalid', pattern);
|
||||
invalid(e => { e.universes.harbor.supporting = ['Book One/Notes']; return e; }, /universes.harbor.supporting holds "Book One\/Notes", which is not a folder path ending in \//);
|
||||
invalid(e => { e.universes.harbor.supporting = ['Book One/../Notes/']; return e; }, /which is not a folder path ending in \//);
|
||||
invalid(e => { e.universes.harbor.supporting = 'Book One/Notes/'; return e; }, /universes.harbor.supporting must be a list/);
|
||||
invalid(e => { e.universes.harbor.supporting = ['Book One/Notes/', 'Book One/Notes/Deep/']; return e; },
|
||||
/supporting "Book One\/Notes\/" holds supporting "Book One\/Notes\/Deep\/"/);
|
||||
invalid(e => { e.universes.harbor.supporting = ['Ideas/']; e.universes.idea = { ...draft(), supporting: ['Ideas/'] }; return e; },
|
||||
/a supporting prefix is listed twice/);
|
||||
invalid(e => { e.universes.harbor.supporting = ['Book One/']; return e; }, /universes.harbor.supporting "Book One\/" is the whole book folder "Book One"/);
|
||||
invalid(e => { e.universes.harbor.books = ['Shelf/Book One']; e.universes.harbor.playground = []; e.universes.harbor.supporting = ['Shelf/']; return e; },
|
||||
/universes.harbor.supporting "Shelf\/" holds its own book folder "Shelf\/Book One"/);
|
||||
// Supporting is checked first in scope, so a supporting prefix holding a playground would hide it.
|
||||
invalid(e => { e.universes.harbor.supporting = ['Book One/Workshop/']; e.universes.harbor.playground = ['Book One/Workshop/Playground/']; return e; },
|
||||
/universes.harbor.supporting "Book One\/Workshop\/" holds playground "Book One\/Workshop\/Playground\/"/);
|
||||
// Across entries any overlap would give one path two universes.
|
||||
for (const [supporting, playground] of [['Ideas/', 'Ideas/'], ['Ideas/Deep/', 'Ideas/'], ['Ideas/', 'Ideas/Deep/']])
|
||||
invalid(e => { e.universes.harbor.supporting = [supporting]; e.universes.idea = draft([playground]); return e; },
|
||||
new RegExp(`universes.harbor.supporting "${supporting}" overlaps playground "${playground}" of universes.idea`));
|
||||
refuses(e => { e.universes.harbor.supporting = ['Second World/Notes/']; return e; },
|
||||
'library-supporting-other-universe', /universes.harbor.supporting "Second World\/Notes\/" lies inside book folder "Second World" of universes.second/);
|
||||
refuses(e => { e.universes.second.books = ['Worlds/Second']; e.universes.harbor.supporting = ['Worlds/']; return e; },
|
||||
'library-supporting-other-universe', /universes.harbor.supporting "Worlds\/" holds book folder "Worlds\/Second" of universes.second/);
|
||||
refuses(e => { e.universes.idea = { ...draft(), supporting: ['Book One/Idea Notes/'] }; return e; },
|
||||
'library-supporting-other-universe', /universes.idea.supporting "Book One\/Idea Notes\/" lies inside book folder "Book One" of universes.harbor/);
|
||||
});
|
||||
|
||||
test('C2-01d: between the lists, a supporting prefix may equal its own playground, and a playground may hold its own supporting', () => {
|
||||
const same = mapping(e => { e.universes.harbor.supporting = ['Book One/Playground/']; return e; });
|
||||
assert.deepEqual([same.universes.harbor.playground, same.universes.harbor.supporting], [['Book One/Playground/'], ['Book One/Playground/']]);
|
||||
const nested = mapping(e => { e.universes.harbor.supporting = ['Book One/Playground/Notes/']; return e; });
|
||||
assert.deepEqual(nested.universes.harbor.supporting, ['Book One/Playground/Notes/']);
|
||||
});
|
||||
|
||||
test('C2-01d: editor is optional, kept when given, and refuses anything but a plain name and email', () => {
|
||||
assert.equal(Object.hasOwn(mapping(), 'editor'), false);
|
||||
const editor = { name: 'Her Name', email: '[email protected]' };
|
||||
const m = mapping(e => ({ ...e, editor }));
|
||||
assert.deepEqual(m.editor, editor);
|
||||
assert.notEqual(m.editor, editor);
|
||||
assert.ok(Object.isFrozen(m.editor));
|
||||
const invalid = (value, pattern) => refuses(e => ({ ...e, editor: value }), 'library-mapping-invalid', pattern);
|
||||
for (const value of [null, 'Her Name', 7, ['Her Name', '[email protected]']]) invalid(value, /editor must be an object/);
|
||||
invalid({ ...editor, signingKey: 'x' }, /editor has unknown key "signingKey"/);
|
||||
// sage-2925: an optional Gitea login for her save, checked as the entry's user is.
|
||||
assert.deepEqual(mapping(e => ({ ...e, editor: { ...editor, user: 'her.login-1' } })).editor, { ...editor, user: 'her.login-1' });
|
||||
for (const user of ['', 'her login', 'her@host', 'a'.repeat(101), 7, null])
|
||||
invalid({ ...editor, user }, /editor.user must be a plain account name/);
|
||||
invalid({ name: 'Her Name' }, /editor.email must be non-empty text with no control character, < or >/);
|
||||
invalid({ email: '[email protected]' }, /editor.name must be non-empty text/);
|
||||
for (const bad of ['', ' ', 7, null, 'a\u0000b', 'a\nb', 'a\u001fb', 'a\u007fb', 'a\u0085b', 'a\u009fb', '<her>', 'a<b', 'a>b']) {
|
||||
invalid({ ...editor, name: bad }, /editor.name must be non-empty text with no control character, < or >/);
|
||||
invalid({ ...editor, email: bad }, /editor.email must be non-empty text with no control character, < or >/);
|
||||
}
|
||||
// sage-3601, sage-3606: git's crud() strips whitespace and , : ; < > " \ ' from both ends of an
|
||||
// ident, so a value starting or ending with one refuses. Inner < > and controls refuse above.
|
||||
const ends = [' ', '\u00a0', '\u3000', ',', ':', ';', '"', "'", '\\'];
|
||||
for (const c of ends) for (const padded of [`${c}Her Name`, `Her Name${c}`]) {
|
||||
invalid({ ...editor, name: padded }, /editor.name must not start or end with whitespace or any of , : ; " ' \\/);
|
||||
invalid({ ...editor, email: padded }, /editor.email must not start or end with whitespace or any of , : ; " ' \\/);
|
||||
}
|
||||
for (const c of ['<', '>', '\n', '\t']) for (const padded of [`${c}Her Name`, `Her Name${c}`, `Her${c}Name`])
|
||||
invalid({ ...editor, name: padded }, /editor.name must be non-empty text with no control character, < or >/);
|
||||
assert.equal(mapping(e => ({ ...e, editor: { name: 'Her Middle Name', email: '[email protected]' } })).editor.name, 'Her Middle Name');
|
||||
for (const name of ['Doe, Jane Q.', 'Jane: "Q" Doe', "O'Neil", 'Back\\slash Name'])
|
||||
assert.equal(mapping(e => ({ ...e, editor: { name, email: '[email protected]' } })).editor.name, name);
|
||||
});
|
||||
|
||||
// sage-3606: an accepted editor commits under exactly the saved ident, as author and committer. A
|
||||
// future git that strips more from an ident fails here.
|
||||
test('C2-01d: an accepted editor round-trips through a real git commit unchanged', t => {
|
||||
const editor = mapping(e => ({ ...e, editor: { name: 'Doe, Jane Q.', email: '[email protected].' } })).editor;
|
||||
const root = mkdtempSync(join(tmpdir(), 'library-ident-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
// An ident in the environment would win over -c, so the commit runs without one. GIT_DIR and its
|
||||
// family would beat -C (inside a hook, say), and git reads no global or system config (webby-0236 E5).
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^GIT_(AUTHOR|COMMITTER)_/.test(k)));
|
||||
for (const k of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE']) delete env[k];
|
||||
Object.assign(env, { GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' });
|
||||
const git = (...args) => execFileSync('git', ['-c', `user.name=${editor.name}`, '-c', `user.email=${editor.email}`,
|
||||
'-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', '-C', root, ...args], { encoding: 'utf8', env });
|
||||
git('init', '-q');
|
||||
git('commit', '-q', '--allow-empty', '-m', 'ident');
|
||||
assert.equal(git('log', '-1', '--format=%an%n%ae%n%cn%n%ce'), `${editor.name}\n${editor.email}\n${editor.name}\n${editor.email}\n`);
|
||||
});
|
||||
|
||||
test('no playground prefix holds another, and no book folder holds another', () => {
|
||||
refuses(e => { e.universes.harbor.playground = ['Book One/Playground/', 'Book One/Playground/Deep/']; return e; },
|
||||
'library-mapping-invalid', /holds playground/);
|
||||
|
||||
Loaded 100 of 185 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user