Compare commits

..
Author SHA1 Message Date
Lena a1321f8f90 Untested: record R7 reload ordering and slot boundaries (#1802) 2026-10-23 14:31:47 -05:00
Lena e855f82e18 Untested: preserve R7 shell cleanup during mutant runs (#1802) 2026-10-23 14:30:20 -05:00
Lena f06a1f78cf Untested: row 296 scratch two-instance acceptance (#1802) 2026-10-23 14:24:24 -05:00
jason.woltje ad84c1e45c row 296 handoff: FED R7 design notes to lena/row296 (mos-1503, Q245)
Quackerjack stops row 296 here. BUILD.md holds the verified reads, the proposed
harness design, the open decisions for Lena, and the R2 learning that R7 depends on.
No test or code changes in this commit.
2026-10-23 14:10:41 -05:00
22 changed files with 927 additions and 589 deletions

No files matched your search

+7
View File
@@ -5411,3 +5411,10 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r
2026-10-23T17:29:46Z | Gyro Vikunja571 sage-3542 Connections amendment | Branch gyro/editor-connections-design cut exactly from landed PRD1.17 30e6bb680; read REQ-LIB-5 in that PRD. Replaced stale J-b waits in parent and W4. Proposed W8 owns current authorized Markdown/DOCX links/backlinks list, labels, versioned refresh, leakage refusals and fixture tests. Build order under mos-1470/comment961 is W7 closed routing candidate, W8 landing, then W7 human first-real-import opening. No REQ-LIB-8 condition or acceptance change. Q234/mos-1363/S0 M1-M14 and post-10-27/MVP priority fixed. Static diff, twelve brief sections and append-only checks pass. No repo Node, tests, build, browser, queue mutation or live action. Fenton reviews after authorized docs branch push; Sage lands on approval.
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 — Lena, row 296 / #1802 FED R7 start
Before: R4 and R6 landed in base 45dca69f6; no scratch two-instance acceptance script.
Plan: preserve Quackerjack handoff c6ab9bdc9, add isolated script/CLI test harness with real
Console proof in transient user services, map assertions and two mutants for Filbert.
Untested: all Node/tests and shared gate wait for Sage CLEAR; no production or live state changes.
-124
View File
@@ -1,124 +0,0 @@
# Row 217 (Case 2 C2-06): build notes
Brief: docs/plans/2026-10-20_case2-rows.md, section C2-06, at base 45dca69f6.
Builder: Launchpad until 2026-10-23 19:09Z. Then Mos moved coding to Codex seats (mos-1500, Q245) and
the row went to Beakley on beakley/row217.
## State at handoff
All code in this commit is UNTESTED. No node, no test and no mutant has run on it. No test file is written.
`packages/harness/src/runner.mjs` is not started.
Done, untested:
- `packages/library/src/limits.mjs` (new, pure): `REPLY_LIMITS`, `resolveReplyLimits(read)` gives
`{requireCitation, citationsPerReply, fallback, unreadable}`, `checkReplyLimits(value)` gives a clone or null.
An unreadable business file forces `requireCitation` true. A key falls back to its default when
`replyLimits` is not an object or the key holds an invalid value.
- `packages/library/src/guard.mjs` (new): `GUARD_CODES` in design 5.2's table order, `FIXED_TEXTS`,
`WEB_CITE_DEFINITIONS`, `REASON_MAX` (300), `GuardError`, `parseReply`, `checkReply`, `libraryReader`,
`lastCommits`, `guardReply`.
- `packages/library/src/index.mjs`: exports for both.
- `packages/business/src/business.mjs`: `replyLimits` is a known top key. The validated output doesn't carry it.
- `packages/business/src/organization.mjs`: the M11 fence `replyLimitsFence(old, document, actor)` compares
against `old.human`, refuses the tester and any `<definition>.<instance>` of `old.roles`, and is called for
both `document` and `written`. `setReplyLimits` and `readRaw` are exported.
- `packages/bus/src/broker.mjs`: the guard call, the page store, the check event, `library.limits.set`,
`library.status` gains `replyLimits`, `role.claim` gains `library`, `thread.messages` gains `check` and `last`.
- `packages/bus/src/server.mjs`, `client.mjs`: `BusError.reason` (sage-3599).
- `packages/bus/src/runtime.mjs`: guard deps and the `library.limits.set` dispatch.
- `packages/cli/src/limits.mjs`: `limits replies --file LIMITS`.
Left:
- `packages/harness/src/runner.mjs`: the C2-07 runner work (see "Runner plan" below).
- `packages/cli/README.md`: a line for `limits replies`.
- Every test in the brief and the rulings below, then the mutants.
- The rebases onto rows 283, 216 and 285, with the drift checks below.
- This file's gate results and the manifest.
## Rulings in force
sage-3595:
1. Supporting scope: a stub classify test now. Before in-review, rebase onto row 283's landing
(honker/row283-build bd2c1c4ea when ruled) and add two real-fixture tests: a supporting cite with
`as: "canon"` refuses `playground-cited-as-canon`, and the stored check result names `supporting`.
2. runtime.mjs is approved for the guard wiring and the one `library.limits.set` branch. List its hunks here,
name it in the manifest, and drift-check it after the 216 and 285 rebases.
3. The check result is an `action.allowed` event with `action: "message.checked"`. The events kind CHECK at
`packages/bus/src/migrations/3b.sql:9` has no `message.checked`, so a new kind would be a schema step.
The event and the message INSERT share one transaction: a mutant that splits them must fail.
4. readLibrary at the cited commit stands in for the C2-05a index. On the 216 rebase, compare 216's index
set with readLibrary's for the fixture and record the result here.
5. The retry is for a library business only. Tests: a JSON body between 32,768 characters and 64 KiB gets
the retry; outside a library business nothing changes.
6. Old `role.claim` pins stay as they are.
7. `render.mjs:6` and `render.test.mjs:58` belong to row 225. Leave them.
8. `thread.messages` rows that carry `check` get `last` per path cite: the file's last commit at the served
tip, one git call per distinct path per read. Test with a fixture file committed after the cite.
sage-3599: `BusError` gets an optional `reason`. Only the citation guard's refusals set it. The envelope
carries `reason` only when it is set. It is the guard's own text, never a quote, file text, url or a path
other than the cited one, at most 300 characters. Tests: a guard refusal reaches the runner with code and
reason; a refusal without a reason has an envelope byte-identical to today's, checked against an existing
pin; a mutant that drops the client.mjs pass-through fails.
sage-3602: store `file` per path cite from the same git log call as the trailer. Test with a cited commit
that didn't touch the file, so `file` differs from the cited commit, and `last` equals `file`.
## The check-result contract with row 225 (launchpad-0118, launchpad-0120, morgana-0200, sage-3596)
- Stored on the `message.checked` event: `{citations: [...]}` in body order.
- A path cite: `{scope, agent, file}`. `scope` is the classify scope. `agent` is the cite's file's last
commit's `Mosaic-Agent` trailer at the cited commit, or null. `file` is that commit's 40-hex id, or null.
- A url cite: `{scope: "web", agent: null}`.
- On `thread.messages`, each path cite gains `last`: the file's last commit at the served tip (40-hex or null).
The Console compares `last` with `file`.
- Rows without `check` are unchanged. No `changed` boolean. The webui passes rows through unchanged.
## Hunks outside the brief's file list
- `packages/bus/src/runtime.mjs`: imports `setReplyLimits`; the library deps gain `guard`, `replyLimits`
(organizationWriter `readRaw`, or `{readable: false}` with no organization), `last` and `GuardError`;
the organization dispatch gains `library.limits.set`.
- `packages/bus/src/server.mjs`: a `refusal(e, other)` helper above serve's doc comment builds every
BusError envelope and adds `reason` only when set. The synchronous request path accepts a Promise result
(the guard is async), with a 120 s timeout, and releases `own` when it settles.
- `packages/bus/src/client.mjs`: `new BusError(r.error, reason)` with `reason` a string cut at 300.
## Decisions and their tradeoffs
- The guard runs async outside the SQL transaction, then re-enters request() through `#guardPass`, so
message.send's insert and the check event stay one synchronous transaction.
- The guard runs before message.send's own checks, so a guard code can come before an authority code.
- The check event id is derived from the message id (a UUID-shaped sha256), so `thread.messages` finds it
through the unique index on `events.id`.
- With no organization configured, `replyLimits` reads as unreadable, so it fails closed.
- With more than one library project, the guard uses the first in the business file's order.
- A library business without guard deps refuses guarded sends with `project-not-configured`.
- The page store is per cap: 20 pages of 200 KB, kept only in a library business, dropped at session
delete or disconnect and at run end.
- The CLI `replies` verb reads the revision itself from `limits`.
- Guard reasons never carry a url or a quote (sage-3599).
## Runner plan (not built)
`role.claim`'s reply is discarded at `runner.mjs:462`; keep it and read `library === true`. In a library
business, at the reply site (`runner.mjs:582-587`):
- Fixed texts `(the session gave no answer)` and `(the turn failed)`; the reason stays in the run log.
A fixed text goes with no note, and `unslop.sent()` is not called, so the note waits.
- A body that parses as a JSON object with a string `text` gets `unslop.note()` appended to `text`, is
serialized again and is never clipped. A plain body keeps `clip(body) + unslop.note()`.
- On a refusal whose code is in `GUARD_CODES`, or `request-too-large` from the client, or the broker's
`invalid-request` from `string(a.body, 32768)` for a body over 32,768 characters (ruling 5; the runner
can tell it by the body length it sent), run one new turn in the same
session naming the code and `err.reason`, or the 64 KiB limit. Bracket it with usage like other turns.
- After the retry: a table code sends `(no answer passed the citation check)`, too large sends
`(the answer was too long to send)`. One retry per answer, whatever the cause.
- `runTurn`'s output buffer holds up to 4 * BODY_MAX characters, so a reply over 64 KiB can reach the send.
- Outside a library business nothing changes.
## Mutants planned
- The M11 writer check removed.
- `document.human` compared instead of `old.human`.
- The check event split from the insert transaction.
- The client.mjs reason pass-through dropped.
+266
View File
@@ -0,0 +1,266 @@
# Row 296: FED R7, scratch two-instance acceptance (#1802)
## Start
- Started 2026-10-23 18:53:27Z, on Sage's sage-3583 (mos-1489).
- Base: origin/refactor 45dca69f6 (pinned; local copy used, no fetch: fetch runs the credential node script, which
is held during Darkwing's re-pin window).
- Branch: quackerjack/row296 in worktree quackerjack-fed-r5-row296.
- Brief: docs/plans/2026-10-23_mvp-readiness-rows.md at 45dca69f6, section "Brief: FED R7, scratch two-instance
acceptance" (line 77).
- Constraints from Sage: scratch roots only (mktemp -d under /mnt/storage/scratch/tmp), never ~/.mosaic-dev or
the live bus. No push, claim, queue write, node or test run until CLEAR.
## What the brief asks for (summary)
- One script test under scripts/ with its harness in packages/*/tests/, as row 190's tests are.
- Two configs, two data roots, two Consoles, one fake OP. Consoles start outside agent ancestry in a transient
user unit. They never use the live stack's units, ports or sockets. The script removes its units on exit (trap).
- Test (design section 7 R7 gate): Jason's test identity, granted collaborator on one hub project, signs in,
makes a thread, sends to the granted instance, gets the reply in the thread, is refused on another project and
on board reads, is revoked, is refused again, and the run ends. The factory shows nothing of the hub.
- Mutants: grant project list widened to `*`; revocation claim end skipped.
- Gate: Filbert approves the manifest; the script test and every scripts/test-*.sh green on Sage's gate; no unit,
port or socket of the script outlives the run. R8 (live window) stays row 209.
## Open reads before design
- R4 and R6 candidates (the brief says it builds on their combined candidate). Confirm what they expose: the
hub sign-in, grant and revocation paths, and the Console's board-read refusal.
- Row 190's test harness (several instances on one uid): how it starts units and cleans up.
- Transient user unit support on this host (systemd-run --user), and how the existing tests detect it.
## Status
- 18:53Z: worktree created, brief read. No code yet.
## Handoff 2026-10-23 19:10:09Z (Mos ruling Q245, mos-1503)
Row 296 moves to Lena (Codex seat, thread db27faca-85fb-4ca0-9fc7-78d740d3fa3a, branch lena/row296). Quackerjack
stops here: no builds, no claim, no queue writes. This design is the handoff; Lena continues it.
Base: 45dca69f6. R4 landed as 2efd127ce (ancestor of the base, checked with merge-base --is-ancestor). R6 landed as
d3059c2c3 (row 294). R7 is claimable once Darkwing's window ends (Sage, sage-3592).
What I verified by reading (not by running):
- packages/cli/src/access.mjs:65 reads `system.federation?.peers ?? []` (the R6 seam).
- packages/bus/src/user-cli.mjs exists at base (the user-cli transport).
- Row 190's tests (packages/cli/tests/stack-instances.test.mjs, stack-release.test.mjs T56, stack-release-fixture.mjs)
drive stand-in units and fixtures. They do not start real transient user units. Not a model for R7's Consoles.
- Real transient-unit launch pattern: packages/conversation/src/cohort.mjs (`scopeAvailable()` and `ScopeLauncher`,
systemd-run --user --scope -p Delegate=yes --unit=...; MANAGER_ENV = XDG_RUNTIME_DIR, DBUS_SESSION_BUS_ADDRESS).
- Test-only spawn of systemd-run in packages/conversation/tests is the nearest precedent for start/stop cleanup.
What I did NOT verify (so treat as open): the hub sign-in, grant, revocation and board-read paths in R4/R6 code; the
Console startup flags; whether the fake OP can be a local stand-in; the trap-based unit cleanup.
Design (proposal, not yet checked against code):
1. scripts/test-federation-acceptance.sh: the entry point. Creates a mktemp -d scratch root under
/mnt/storage/scratch/tmp (never ~/.mosaic-dev, never the live bus). trap removes every unit it started and the
scratch root. Refuses to run if a transient unit name collides.
2. Two configs (instance A = hub-side granted seat, instance B = collaborator's home), each with its own
MOSAIC_CONFIG, data root and federation.self; federation.peers names the other.
3. One fake OP: a local stand-in that issues Jason's test identity. Its port and socket are per-run, not the live ones.
4. Two Consoles, each in its own transient user unit (systemd-run --user --unit=mosaic-r7-<run>-<A|B>); outside agent
ancestry; ports picked per run.
5. Test flow (the R7 gate): identity -> grant on one hub project -> sign in -> thread -> send to granted instance ->
reply in thread -> refused on another project -> refused on board reads -> revoke -> refused after revoke -> run
ends. Factory output must show nothing of the hub.
6. Mutants: (a) grant project list widened to `*`, must fail the other-project refusal; (b) revocation claim end
skipped, must fail the refusal-after-revoke check.
Unresolved decisions for Lena (I did not decide these):
- Whether the harness lives in packages/cli/tests (matching row 190) or packages/conversation/tests (matching
cohort.mjs). Brief says packages/*/tests/; pick the one that owns the Console start path.
- Fake OP: in-process HTTP stand-in versus file-backed identity. No preference recorded.
- Port selection: fixed range per run vs OS-assigned. Brief forbids the live ports; it does not pick a range.
R2 and R5 learning:
- R2 (row 203, #1701) landed as a4da4e6df. I built it. Relevant to R7: validateFederation in
scripts/mosaic-config.mjs rejects unknown keys and bad ids, and test-config.sh has an FED block
(expect_fed_refusal). Use federation.self/peers exactly as row 203 validates them.
Mutant runner pattern: one textual edit that must occur exactly once, then a named test must fail; restore from git.
Name filter gotcha: `node --test --test-name-pattern` skips subtests whose parent does not match; include the
parent name (T57 for row 203).
- R5: I did not build R5 in this session and have no first-hand notes on it. Ask Sage for R5's landing sha and notes.
Status: design written; no code, no test runs, no claim, no push of the test files. Next owner: Lena.
## Lena continuation, 2026-10-23
Owner: Lena, thread db27faca-85fb-4ca0-9fc7-78d740d3fa3a. Branch lena/row296.
Base remains 45dca69f6. Quackerjack's handoff c6ab9bdc9 preserved by cherry-pick ad84c1e45.
Issue #1802 exists. Reviewer Filbert. Sage owns queue state, shared test slot and landing.
Status: Untested. All Node, tests and credential-helper fetch/push held for Sage CLEAR (sage-3615).
The earlier canonical `queue next lena` read finished with `nothing` and a rendered-view warning;
no queue state was changed. Lena's owner registration is Sage's action.
### Design decisions sent to Sage (lena-0003)
- Harness under packages/cli/tests, beside row 190's instance proof; shell entry under scripts/.
- Existing HTTPS fakeOidc helper, its generated scratch CA trusted through NODE_EXTRA_CA_CERTS
by each real Console CLI. One fake OP, two client ids; no TLS verification bypass.
- Kernel-assigned loopback ports (0), discovered from each Console's startup log. Public origins
are distinct *.example.test HTTPS names; HTTP requests model the loopback reverse proxy with
exact Host and Origin. This proves the Console boundary, not a deployed HTTPS front end (R8).
- Transient user service units, not scopes: a scope leaves ancestry beneath the caller; the real
user proof walks ancestry. Each Console runs the existing cli.mjs serve --sso --send verb.
- Real startHost broker process and createLauncher. Only the harness adapter is a test stand-in,
as in thread-revoke.test.mjs. A real sandboxed run replies through the broker.
- Scratch-only setup and access grant/revoke use real human CLI transport from a transient service.
No injected principal verifier, direct bindHuman/bindUser, or simulated user transport.
- Every service has a unique per-run prefix and is recorded before launch. Shell EXIT/INT/TERM
traps stop only these services, wait for collection and verify socket and port closure.
- Claim-end assertion reads append-only claim history independently of the launcher's final
shutdown so the claim-end mutant cannot be hidden by endLaunch's cleanup.
### Implementation plan
Goal: an executable scratch proof of federation section 7's R7 gate, without live state.
Architecture: shell lifecycle owner, Node test driver, test-only host fixture, existing real Console CLI.
Tech: Bash, Node ESM/node:test, systemd user transient services, existing fake OP and bwrap adapter.
Spec: docs/plans/2026-10-23_mvp-readiness-rows.md, FED R7, plus preserved handoff above.
Constraints: scratch only under /mnt/storage/scratch/tmp; no live units/ports/sockets/configs or
credential reads; no production behavior/policy changes; no old test edits without Mos via Sage.
Review focus: partial startup cleanup; signals; unit name collision; mismatched hub/factory sessions;
claim end versus process exit. Each must have explicit assertions or a negative cleanup exercise.
- [ ] Write acceptance driver assertions: Jason test sub binds as collaborator (owner is another
fixture identity), one allowed project/instance, thread send and correlated RESULT, private
project creation/read refused, board reads refused, factory has no hub objects, revoke denies
session and re-login, claim history records access-revoked and runner ends.
- [ ] Write shell lifecycle: scratch HOME/TMPDIR, unique unit ledger, bounded command timeout,
traps stop/collect only this run's units, verify recorded sockets and ports no longer listen.
- [ ] Write host fixture using startHost(bootConfig), createLauncher with fakeHarnesses and the
existing thread adapter. Prepare two named configs through bootstrap before fixture edits;
supply only generated test client slots. Setup/grant/revoke through real human transport.
- [ ] On Sage CLEAR run only the new script, fix failures in new files and record exact results.
- [ ] Prepare explicit once-only mutant patches: R5 projects widened to ['*']; R4 #claimEnd call
skipped. On Sage CLEAR prove each gives an assertion failure, restore bytes in finally.
- [ ] Publish manifest (paths, hashes, assertion mapping, old-test numstat), request Filbert review,
push additive commits to lena/row296 only. Sage runs shared suites and gate and lands the row.
Pre-existing test changes: none (numstat: empty).
### Source inspection corrections before first execution
- The real webui chat is channel-gated. Scratch businesses enable only harness pi and channel webui;
the loader fixture re-exports the unchanged webui channel. The adapter replacement is exactly
fakeHarnesses' pi.adapter export; no product file gains a fixture branch.
- startHost creates a real broker child and a real Unix proof socket; it is hosted in the test
driver. The two Console services run the exact same-tree webui CLI, which is required by
verifyPrincipal. Human setup runs the exact same-tree human-cli through humanTransport.
- The earlier in-process host proposal did not need a separate host fixture: startHost exposes
the production launcher API directly, so the only helper file is federation-human.mjs.
- Read-only SQLite inspects claim evidence while the broker remains the sole writer. The test
also checks the registered runner and engine pids/start times are gone after revocation.
- `--kill-after-start` kills the test process after both services have listened; Node teardown
hooks cannot run. The shell trap owns service cleanup independently. This is an expected-red
exercise; acceptance requires its kill marker, nonzero exit and successful cleanup line.
- Main driver environment is a whitelist (PATH, scratch HOME/TMPDIR, systemd manager transport,
R7 run ids). Console/human services use env -i. No host preload or credential env is carried.
- No Node or test has run. Bash syntax and git diff --check passed during preparation.
### Binding design correction: sage-3625, 2026-10-23 19:17:35Z
Sage rejected the transient-unit approach: "no transient systemd user units used to satisfy the
human proof"; such a unit makes the ancestry walk miss the development agent, and creates host
state under mos-1438. This direction replaces the brief/handoff's unit requirement for this build.
No unit was created by the superseded draft, and no Node/test execution occurred.
Current design, replacing the earlier proposals above:
- Consoles are ordinary spawned child processes in their own process groups; no systemd commands,
units, user manager or manager environment are involved.
- Test-only federation-host.mjs follows channel-fixture.mjs's mock.module pattern. It re-exports
real readProcess and replaces verifyHuman's readProcess argument with real process fields plus
ppid=1. This is explicitly a stand-in for Jason's shell, NOT a real ancestry acceptance proof.
Real pid/uid/startTime/nonce/path checks still run; verifyPrincipal still checks the Console's
actual direct parent path for user calls. No product source receives this override.
- Each fixture host runs real startBroker and createLauncher. Its local host API is the existing
thread-revoke.test.mjs pattern, with only the pi adapter replaced via fakeHarnesses.
- Setup/grant/revoke are plain children using real CLI wrappers against that fixture proof seam.
- Shell records each detached group's leader pid and start time, checks both before signalling,
and verifies no live group member, TCP listener or Unix listener remains. Killed-driver mode
bypasses every Node teardown hook and still leaves cleanup to the shell trap.
- Read-only claim evidence and actual runner/engine exit remain independent assertions.
- Gate limitation: this proves the revised scratch integration, not real outside-agent ancestry,
transient unit cleanup, TLS proxy deployment or live federation. R8 remains row 209.
- R5 landing found in git log: ddfb29af7 (#1700).
- Targeted Node/tests remain held. Sage permits the first lena/row296 push in the 19:41Z gap,
with MOSAIC_GITEA_CREDENTIAL_FILE unset. 226 and 283 have earlier slots.
Current files: shell entry; federation-acceptance.test.mjs; fixtures/federation-host.mjs and
fixtures/federation-human.mjs; this BUILD; mutant runner; manifest; append-only logs; preserved
Quackerjack BUILD at ad84c1e45. No pre-existing test modification (numstat empty).
### First candidate, 2026-10-23
Implementation is prepared under sage-3625, not yet executed. Static checks: Bash parser accepts
scripts/test-federation-acceptance.sh; Python ast.parse accepts mutants.py; git diff --check clean.
No test outcome is claimed. The root node_modules links the existing canonical install so the
future targeted run needs no install or network. No webui editor build is involved.
M1 now requires the user project-list leak assertion (`R7 ungranted project must not appear`),
so it proves exposure through the actual Console transport rather than only inspecting grant
configuration. The private-project thread-create refusal and exact grant-list check follow it.
M2 requires `R7 revoke must end the claim as access-revoked` before any host teardown.
Sage opened the push gap early in sage-3644 (19:23:04Z). The first candidate is committed as
Untested, for push only to lena/row296 with MOSAIC_GITEA_CREDENTIAL_FILE unset. Node, acceptance,
negative cleanup exercise and mutants still wait for the test slot. Full suites/gate are Sage's.
### Filbert static review, filbert-296-first-push-static, 19:28:51Z
Review pinned f06a1f78c against 45dca69f6. Filbert verified all nine manifest hashes from Git
objects, no product/pre-existing test changes, no systemd calls, fixture-only ancestry override,
real path/uid/startTime/nonce checks, group ledger identity checks and child-only CA trust.
He ran nothing. The verdict remains pending execution and gate evidence.
- C1 confirmed by inspection: subprocess.run(timeout=280) kills the shell on timeout and can
bypass its trap, while the shell's own deadline plus cleanup can exceed 280 seconds. Removed
the outer Python timeout (the simpler alternative Filbert explicitly allowed). Python now
waits for the shell's bounded run and EXIT/TERM cleanup. Source restoration remains in finally.
- N1 retained limitation: a SIGKILL between spawn and the pid/startTime ledger append can leave
an unrecorded group. The killed-driver exercise begins after both Consoles are recorded and
does not cover that gap. Cleanup claims apply to recorded groups; no universal crash-proof
process creation claim is made.
- N2 addressed before the mutant run: the named readonly claim-history assertion now runs after
revoke and before waiting for the run's end. A skipped #claimEnd therefore cannot first fail
the run-end wait. The final latest-op check still runs after engine/runner exit. If M2 instead
fails an earlier condition, its runner must say NOT KILLED; diagnose the first failure on the
same pinned candidate, retain the intended assertion, and rerun on Sage CLEAR. Never accept a
timeout or change the mutant's expected assertion merely to mark it killed.
- N3 deferred cosmetic fixture detail: wrap() returns fn(value) without await, so async rejection
skips its code mapping. This is not an authorization or cleanup change; the rejection still
propagates, and no verdict relies on that fixture mapping.
- N4: corrected the exact-grant assertion's indentation in the new test.
- N5: prepare() first calls bootstrap --instance, then overwrites that bootstrap-generated
config inside the unique disposable scratch HOME with fixture Console/provider settings.
This is fixture-only setup, not a product/bootstrap overwrite path or a live-config operation.
There remains one selected config per scratch instance, with the same marked data root.
Still Untested. No Node, acceptance, mutant, negative cleanup or CLI suite run. Static validation
only; execution waits for Sage's test window. Follow-up push needs a currently authorized gap.
### Push receipt and N2 ordering proof, 2026-10-23 19:31Z
Sage sage-3659 authorized push before 19:38Z. e855f82e1846f094927c3de69aea1a801253736a
was pushed to origin/lena/row296 with MOSAIC_GITEA_CREDENTIAL_FILE unset. No Node/tests ran.
N2, filbert-296-c1-ack: this path is synchronous after a successful reload. accessCommand awaits
reload at access.mjs:289; reload awaits the access.reload transport at :297. broker.mjs:4645
calls #revokeLost before constructing its reply. #revokeLost (:1025–1034) calls #claimEnd inside
Store.transaction, which executes COMMIT before returning (store.mjs:282–302). server.mjs:160–161
calls broker.request and then writes the response. Therefore the claim record is committed before
the successful CLI call returns; no asynchronous sweep is required for this assertion. A refused
reload can be reported by the CLI with exit 0, but then immediate revocation evidence is absent
and this test must fail; that is not a race the test should hide. No polling change is needed.
T3 package comparison (sage-3659): the four planned runs execute only the new dedicated
federation-acceptance.test.mjs and its fixture host/human children. They do not execute stack-copy,
stack-release or the whole CLI suite. Existing helpers are imported; no old test module is run.
Asked Researcher to confirm against row216. Full CLI suite remains a later Sage gate requirement,
outside the targeted T3 command list. All test resources are unique scratch state.
+67
View File
@@ -0,0 +1,67 @@
# Row 296 / #1802 — C1 follow-up candidate (Untested)
Base: 45dca69f6. Branch: lena/row296. Reviewer: Filbert. Gate owner: Sage.
Handoff: c6ab9bdc9 preserved as ad84c1e45, including
agents/quackerjack/work/queue-296/BUILD.md. Every changed path is in MANIFEST.sha256,
except this manifest and the checksum file itself. No product file or pre-existing test changed.
Pre-existing test numstat: empty. BUILD-LOG.md and docs/SESSIONS.md are append-only additions.
Binding ruling: sage-3625 replaces the brief's transient-unit start with plain child Consoles and
explicit test stand-ins. **This candidate does not prove genuine outside-agent ancestry.**
It creates no transient systemd unit and makes no systemd CLI call. R8 remains row 209.
## What is real, and what is a stand-in
- Two configs created by bootstrap --instance hub/factory, two marked data roots, real Console
cli.mjs serve --sso --send, real broker runtime and launcher, real path-bound human-cli and
user-cli wrappers, real access loader, CLI writes/reload, grant filters and revocation.
- One existing HTTPS fakeOidc server, two issuer/client identities. Its generated CA is trusted
only through NODE_EXTRA_CA_CERTS in this test's child environment, never any trust store.
- federation-host.mjs replaces verifyHuman's readProcess argument with actual process fields plus
ppid=1, exactly the test pattern Sage named. This stands in for Jason's shell on setup,
grant/revoke and both Consoles' transports. The actual Console parent/path check remains.
There is no product parameter, environment switch or altered verifier default.
- fakeHarnesses replaces only the pi harness adapter() with a scratch shell invoking the existing
thread-adapter.mjs. The existing webui channel is re-exported unchanged. No network model runs.
- Public HTTPS origins are modelled through HTTP loopback requests with matching Host/Origin;
reverse-proxy TLS deployment is outside this scratch proof.
## Acceptance map
All checks live in `FED R7 scratch two-instance acceptance` in
packages/cli/tests/federation-acceptance.test.mjs.
| Requirement | Assertion/evidence |
| --- | --- |
| Isolated instances | different config paths/data roots/ports; scratch HOME/TMPDIR; two named markers from bootstrap; port 0, forbidden live ports 7330–7332 checked |
| Jason is collaborator, not owner | owner melanie; access user jason-test, home factory, fake sub jason-test-sub; hub /api/config user=true |
| One project, one granted instance | actual access grant CLI, projects=[shared], instances=[assistant]; user project list contains shared only |
| Thread and reply | create thread on shared, POST message, correlated RESULT with in_reply_to=sent.id, from_role=assistant, to_role=user, body contains question |
| Other project refused | private project absent from listing; create on private returns 403 access-refused |
| Board reads refused | /api/board, /api/bus/inbox and /api/bus/tasks return 403 owner-only |
| Factory sees no hub objects | Jason sub refused at factory; factory owner sees factory-only project and no threads; hub cookie gets 401 at factory |
| Revoke takes effect | actual access revoke CLI; existing hub session 401 and re-login 403 |
| Claim and process ended | live run before revoke; end log exitCode=0; no registered sessions; engine and runner pid/startTime gone; readonly claim history has revoke/access-revoked before the process-end wait, latest op=revoke afterward |
| Normal cleanup | EXIT trap terminates only recorded pid/startTime-checked groups; checks no live group member, no recorded TCP/Unix listener, no other scratch Unix listener, removes scratch directory/socket files |
| Killed-driver cleanup | --kill-after-start prints both-Consoles-started marker then SIGKILLs test driver; shell performs the same independent cleanup; expected nonzero run with successful cleanup line |
## Verification commands (held for Sage CLEAR)
- scripts/test-federation-acceptance.sh — must exit 0, one acceptance test passes, cleanup line present.
- scripts/test-federation-acceptance.sh --kill-after-start — must exit nonzero, print the explicit
interruption marker and `R7 cleanup: no child groups, ports or sockets remain; no units created`.
- python3 agents/lena/work/queue-296/mutants.py M1 — once-only R5 grant projects -> ['*']; must fail
`R7 ungranted project must not appear` and verify cleanup, then restore original source bytes. Python relies on the shell's own run deadline and cleanup; no outer killing timeout.
- python3 agents/lena/work/queue-296/mutants.py M2 — once-only R4 claim end omitted; must fail
`R7 revoke must end the claim as access-revoked` before shutdown, verify cleanup and restore bytes.
- Sage's gate: the new script plus every scripts/test-*.sh; full applicable suites per Sage's slot.
Status: no Node, acceptance or mutant run. Static Bash syntax, Python parsing and diff checks only.
The script never silently skips a missing tool/sandbox/provider: prerequisites fail the acceptance.
Ordinary CLI suites skip the dedicated test until invoked by its owning script.
Static-review follow-up: filbert-296-first-push-static. C1 removes Python's outer timeout so it
cannot SIGKILL the shell before EXIT cleanup. N2 moves the named claim-history assertion before
the process-end wait. N1's spawn-to-ledger SIGKILL gap is retained and documented in BUILD.md;
--kill-after-start exercises the fully recorded groups. N3 remains a cosmetic fixture limitation.
N4 indentation corrected. N5 scratch-bootstrap config overwrite is explicitly fixture-only.
@@ -0,0 +1,9 @@
6e0193ba91cbd20edd3acfbc57ae9af2f016e6d2c3587b434a9085b3a02c493c BUILD-LOG.md
72029331a22f33433ad14ff08fe8b24d59f79e784009b12fd9c5b74c9e524f13 docs/SESSIONS.md
87239327014c49abbe130c17881bb48e13d96545bf9f3e09b6f4aed46e11453b agents/quackerjack/work/queue-296/BUILD.md
6f32d7e5e0c2c1ee086949a68b176a9d1fefb8ad0df562f5ce72b0c01d21e7a0 agents/lena/work/queue-296/BUILD.md
bd6e9fd9e3f7aa0ee6702401776ef7bd3e39562059a11b87aaaf2a427904381b agents/lena/work/queue-296/mutants.py
89a15e62aa6f951596b764993afeff7e33eeaa3a4f45d31599eba7d27a4000ad packages/cli/tests/federation-acceptance.test.mjs
86b76565f5afe9efdd076ef61cc54e40c447d235f77b95818d0d601ce036effa packages/cli/tests/fixtures/federation-host.mjs
b12a9bad4be083f2439c3363738b9763fa0061228f84d501a385ade48fea90e4 packages/cli/tests/fixtures/federation-human.mjs
4008361f062a2f21b6075c500060196e10a48351fd7d95b25d681f36b4fa801a scripts/test-federation-acceptance.sh
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""R7 mutants. Run only on Sage CLEAR in Lena's isolated candidate.
Every replacement must occur once. Product bytes are restored even on timeout
or failure. A killed mutant must contain the named assertion in the script's
output; startup errors and timeout alone are not evidence. The shell owns its
own timeout and EXIT/TERM cleanup. Do not put a killing Python timeout around it.
"""
from pathlib import Path
import subprocess
import sys
repo = Path(__file__).resolve().parents[4]
mutants = {
'M1': (
'packages/cli/src/access.mjs',
'projects: resolveProjects(list(parsed.values["--projects"]), rows),',
'projects: ["*"],',
'R7 ungranted project must not appear',
),
'M2': (
'packages/bus/src/broker.mjs',
"this.#claimEnd({ business: t.business }, h, 'revoke', null, 'access-revoked', by);",
"/* R7 mutant: skip ending the revoked claim */",
'R7 revoke must end the claim as access-revoked',
),
}
if len(sys.argv) != 2 or sys.argv[1] not in mutants:
raise SystemExit('usage: mutants.py M1|M2')
name = sys.argv[1]
file, before, after, assertion = mutants[name]
path = repo / file
original = path.read_bytes()
text = original.decode()
if text.count(before) != 1:
raise SystemExit(f'{name}: anchor count {text.count(before)}, expected 1')
try:
path.write_text(text.replace(before, after))
result = subprocess.run(['scripts/test-federation-acceptance.sh'], cwd=repo,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True)
print(result.stdout, end='')
if result.returncode == 0 or assertion not in result.stdout:
raise SystemExit(f'{name}: NOT KILLED by expected assertion')
if 'R7 cleanup: no child groups, ports or sockets remain; no units created' not in result.stdout:
raise SystemExit(f'{name}: cleanup not verified')
print(f'{name}: KILLED by {assertion}')
finally:
path.write_bytes(original)
if path.read_bytes() != original:
raise SystemExit(f'{name}: restore failed')
@@ -0,0 +1,87 @@
# Row 296: FED R7, scratch two-instance acceptance (#1802)
## Start
- Started 2026-10-23 18:53:27Z, on Sage's sage-3583 (mos-1489).
- Base: origin/refactor 45dca69f6 (pinned; local copy used, no fetch: fetch runs the credential node script, which
is held during Darkwing's re-pin window).
- Branch: quackerjack/row296 in worktree quackerjack-fed-r5-row296.
- Brief: docs/plans/2026-10-23_mvp-readiness-rows.md at 45dca69f6, section "Brief: FED R7, scratch two-instance
acceptance" (line 77).
- Constraints from Sage: scratch roots only (mktemp -d under /mnt/storage/scratch/tmp), never ~/.mosaic-dev or
the live bus. No push, claim, queue write, node or test run until CLEAR.
## What the brief asks for (summary)
- One script test under scripts/ with its harness in packages/*/tests/, as row 190's tests are.
- Two configs, two data roots, two Consoles, one fake OP. Consoles start outside agent ancestry in a transient
user unit. They never use the live stack's units, ports or sockets. The script removes its units on exit (trap).
- Test (design section 7 R7 gate): Jason's test identity, granted collaborator on one hub project, signs in,
makes a thread, sends to the granted instance, gets the reply in the thread, is refused on another project and
on board reads, is revoked, is refused again, and the run ends. The factory shows nothing of the hub.
- Mutants: grant project list widened to `*`; revocation claim end skipped.
- Gate: Filbert approves the manifest; the script test and every scripts/test-*.sh green on Sage's gate; no unit,
port or socket of the script outlives the run. R8 (live window) stays row 209.
## Open reads before design
- R4 and R6 candidates (the brief says it builds on their combined candidate). Confirm what they expose: the
hub sign-in, grant and revocation paths, and the Console's board-read refusal.
- Row 190's test harness (several instances on one uid): how it starts units and cleans up.
- Transient user unit support on this host (systemd-run --user), and how the existing tests detect it.
## Status
- 18:53Z: worktree created, brief read. No code yet.
## Handoff 2026-10-23 19:10:09Z (Mos ruling Q245, mos-1503)
Row 296 moves to Lena (Codex seat, thread db27faca-85fb-4ca0-9fc7-78d740d3fa3a, branch lena/row296). Quackerjack
stops here: no builds, no claim, no queue writes. This design is the handoff; Lena continues it.
Base: 45dca69f6. R4 landed as 2efd127ce (ancestor of the base, checked with merge-base --is-ancestor). R6 landed as
d3059c2c3 (row 294). R7 is claimable once Darkwing's window ends (Sage, sage-3592).
What I verified by reading (not by running):
- packages/cli/src/access.mjs:65 reads `system.federation?.peers ?? []` (the R6 seam).
- packages/bus/src/user-cli.mjs exists at base (the user-cli transport).
- Row 190's tests (packages/cli/tests/stack-instances.test.mjs, stack-release.test.mjs T56, stack-release-fixture.mjs)
drive stand-in units and fixtures. They do not start real transient user units. Not a model for R7's Consoles.
- Real transient-unit launch pattern: packages/conversation/src/cohort.mjs (`scopeAvailable()` and `ScopeLauncher`,
systemd-run --user --scope -p Delegate=yes --unit=...; MANAGER_ENV = XDG_RUNTIME_DIR, DBUS_SESSION_BUS_ADDRESS).
- Test-only spawn of systemd-run in packages/conversation/tests is the nearest precedent for start/stop cleanup.
What I did NOT verify (so treat as open): the hub sign-in, grant, revocation and board-read paths in R4/R6 code; the
Console startup flags; whether the fake OP can be a local stand-in; the trap-based unit cleanup.
Design (proposal, not yet checked against code):
1. scripts/test-federation-acceptance.sh: the entry point. Creates a mktemp -d scratch root under
/mnt/storage/scratch/tmp (never ~/.mosaic-dev, never the live bus). trap removes every unit it started and the
scratch root. Refuses to run if a transient unit name collides.
2. Two configs (instance A = hub-side granted seat, instance B = collaborator's home), each with its own
MOSAIC_CONFIG, data root and federation.self; federation.peers names the other.
3. One fake OP: a local stand-in that issues Jason's test identity. Its port and socket are per-run, not the live ones.
4. Two Consoles, each in its own transient user unit (systemd-run --user --unit=mosaic-r7-<run>-<A|B>); outside agent
ancestry; ports picked per run.
5. Test flow (the R7 gate): identity -> grant on one hub project -> sign in -> thread -> send to granted instance ->
reply in thread -> refused on another project -> refused on board reads -> revoke -> refused after revoke -> run
ends. Factory output must show nothing of the hub.
6. Mutants: (a) grant project list widened to `*`, must fail the other-project refusal; (b) revocation claim end
skipped, must fail the refusal-after-revoke check.
Unresolved decisions for Lena (I did not decide these):
- Whether the harness lives in packages/cli/tests (matching row 190) or packages/conversation/tests (matching
cohort.mjs). Brief says packages/*/tests/; pick the one that owns the Console start path.
- Fake OP: in-process HTTP stand-in versus file-backed identity. No preference recorded.
- Port selection: fixed range per run vs OS-assigned. Brief forbids the live ports; it does not pick a range.
R2 and R5 learning:
- R2 (row 203, #1701) landed as a4da4e6df. I built it. Relevant to R7: validateFederation in
scripts/mosaic-config.mjs rejects unknown keys and bad ids, and test-config.sh has an FED block
(expect_fed_refusal). Use federation.self/peers exactly as row 203 validates them.
Mutant runner pattern: one textual edit that must occur exactly once, then a named test must fail; restore from git.
Name filter gotcha: `node --test --test-name-pattern` skips subtests whose parent does not match; include the
parent name (T57 for row 203).
- R5: I did not build R5 in this session and have no first-hand notes on it. Ask Sage for R5's landing sha and notes.
Status: design written; no code, no test runs, no claim, no push of the test files. Next owner: Lena.
+1
View File
@@ -1593,3 +1593,4 @@ 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 — lena (Codex, db27faca): row 296 / #1802 FED R7; recovered Quackerjack design on lena/row296, inspecting acceptance paths; Node/tests held pending Sage CLEAR, no live changes.
+7 -134
View File
@@ -203,26 +203,15 @@ const canonical = (x) =>
Number.isFinite(Date.parse(x)) &&
new Date(x).toISOString() === x;
export class BusError extends Error {
// C2-06 (sage-3599): only the citation guard's refusals carry a reason, and the envelope passes it on.
constructor(code, reason) {
constructor(code) {
super(code);
this.code = code;
if (reason !== undefined) this.reason = reason;
}
}
const fail = (code) => {
throw new BusError(code);
};
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
// C2-06 (design 5.2, B7): the page store keeps a session's last 20 broker fetches, 200 KB of text each.
const PAGE_COUNT = 20;
const PAGE_BYTES = 200 * 1024;
// C2-06: the guard's check event takes an id derived from the message's, so a thread read finds it
// through the unique index on events.id (there is none on subject).
const checkEventId = (mid) => {
const h = createHash('sha256').update('message.checked:' + mid).digest('hex');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
};
const text = (x, max = 4096) => typeof x === 'string' && x.length > 0 && x.length <= max && !x.includes('\0');
const id = (x) => text(x, 160) && /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/.test(x);
function keys(x, allowed, required = []) {
@@ -400,11 +389,6 @@ export class Broker {
#credentialVerbs;
#project;
#library;
// C2-06 (design 5.2, B7): cap -> the session's kept web.fetch pages, in a library business only.
// In memory, dropped with the session.
#pages = new Map();
// C2-06: { s, check } while a guarded message.send or a thread.messages re-enters request().
#guardPass = null;
#usage;
#limits;
#harnessOn;
@@ -1419,10 +1403,7 @@ export class Broker {
this.#launcherReleased.delete(key);
this.#accessRevoked.delete(key);
for (const [cap, s] of this.#sessions)
if (!s.human && !s.reader && s.business === record.business && s.run === record.run) {
this.#sessions.delete(cap);
this.#pages.delete(cap);
}
if (!s.human && !s.reader && s.business === record.business && s.run === record.run) this.#sessions.delete(cap);
return this.#store.transaction(() => {
const h = this.#own(session);
const released = h?.op === 'claim' && h.holder_run === session.run;
@@ -1504,7 +1485,6 @@ export class Broker {
}
disconnect(cap) {
this.#sessions.delete(cap);
this.#pages.delete(cap);
}
identity(cap) {
const s = this.#session(cap);
@@ -4686,19 +4666,10 @@ export class Broker {
return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) });
if (LIBRARY_VERBS.includes(request.verb))
return this.#requestLibrary(s, { verb: request.verb, args: structuredClone(args) });
// C2-06 (design 5.2): an agent's message to the human in a library business passes the citation
// guard first, outside any transaction, and thread.messages adds each path cite's `last`. Both
// then re-enter request() for the synchronous path below.
if (this.#guardPass?.s !== s) {
if (request.verb === 'message.send' && this.#guarded(s, args.to))
return this.#guardedSend(cap, s, request, structuredClone(args));
if (request.verb === 'thread.messages' && this.#libraryProject(s.business) && this.#library?.last)
return this.#withLast(cap, s, request);
}
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
return this.#chiefLifecycle(s, request.verb, structuredClone(args));
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb)) {
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set"].includes(request.verb)) {
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
// file lists organization.change. It takes the same keys, runs the same write path and
// logs the same receipt; only the actor differs: the role definition and instance.
@@ -4744,12 +4715,6 @@ export class Broker {
keys(args, VETO_KEYS, VETO_KEYS);
this.#human(s);
this.#mayHuman(s, "veto.set", args.name);
}
// C2-06 M11: the human's replyLimits verb. The tester is refused by the gate above and an agent
// session by human-required; the writer's fence refuses any other actor.
else if (request.verb === "library.limits.set") {
keys(args, ["revision", "replyLimits"], ["revision", "replyLimits"]);
this.#human(s);
} else {
keys(args, ORGANIZATION_KEYS, ["revision"]);
// Row 194 (D2.4, B7): the chief can't change a kept instance's model, as that replaces it at
@@ -5173,14 +5138,7 @@ export class Broker {
const context = { business: this.#business(s.business), project: args.project };
let result;
if (verb === 'library.file') result = await this.#library.readFile({ ...context, path: args.path });
else if (verb === 'library.status') {
result = await this.#library.status(context);
// C2-06: the reply limits in force, read from the file now, and the keys that fell back.
if (this.#library.replyLimits) {
const { requireCitation, citationsPerReply, fallback, unreadable } = this.#library.replyLimits(s.business);
result = { ...result, replyLimits: { requireCitation, citationsPerReply, fallback, problem: unreadable ? 'reply-limits-unreadable' : null } };
}
}
else 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 });
@@ -5194,78 +5152,6 @@ export class Broker {
throw this.#refused(s, Failure && error instanceof Failure ? new BusError(error.code) : error, request);
}
}
// C2-06: the business's library project, the first in file order, or null.
#libraryProject(business) {
return Object.entries(this.#business(business).projects).find(([, p]) => p.kind === 'library')?.[0] ?? null;
}
// C2-06 (design 5.2): every agent session's message to the human in a business with a library project
// is guarded, whatever the sender's definition.
#guarded(s, to) {
return to === 'human' && !s.human && !s.tester && !s.user && !s.reader && !s.console && this.#libraryProject(s.business) !== null;
}
#reenter(cap, s, request, check) {
this.#guardPass = { s, check };
try {
return this.request(cap, request);
} finally {
this.#guardPass = null;
}
}
// As #requestProject: the git reads run outside any SQL transaction. A guard refusal is recorded with
// its code and reaches the caller with the guard's reason; the send then runs, and stores the check
// result in its own transaction.
async #guardedSend(cap, s, request, args) {
let check;
try {
this.#store.transaction(() => this.#agent(s));
if (!this.#library?.guard || !this.#library?.replyLimits) fail('project-not-configured');
if (typeof args.body !== 'string') fail('invalid-request');
const business = this.#business(s.business);
check = await this.#library.guard({ business, project: this.#libraryProject(s.business) }, args.body, {
limits: this.#library.replyLimits(s.business),
definition: business.roles[s.role]?.definition ?? null,
pages: this.#pages.get(cap) ?? [],
});
} catch (e) {
const Guard = this.#library?.GuardError;
throw this.#refused(s, Guard && e instanceof Guard ? new BusError(e.code, e.reason) : e, request);
}
return this.#reenter(cap, s, request, check);
}
// C2-06 (sage-3595, morgana-0200): each path cite of a checked row gains `last`, the file's last commit
// at the served tip, one git call per distinct path. Rows without `check` are unchanged.
async #withLast(cap, s, request) {
const rows = this.#reenter(cap, s, request, null);
if (!rows.some((m) => m.check)) return rows;
const cites = (m) => {
try {
const c = JSON.parse(m.body).citations;
return Array.isArray(c) ? c : [];
} catch {
return [];
}
};
let last = new Map();
try {
const paths = rows.filter((m) => m.check).flatMap((m) => cites(m).filter((c) => typeof c?.path === 'string').map((c) => c.path));
last = await this.#library.last({ business: this.#business(s.business), project: this.#libraryProject(s.business) }, paths);
} catch {
last = new Map();
}
return rows.map((m) => {
if (!m.check) return m;
const body = cites(m);
return { ...m, check: { ...m.check, citations: m.check.citations.map((c, i) =>
c.scope === 'web' ? c : { ...c, last: last.get(body[i]?.path) ?? null }) } };
});
}
// C2-06 (B7): a fetch's text, kept for url cites in a library business only.
#keepPage(cap, s, url, page) {
if (!this.#sessions.has(cap) || !this.#libraryProject(s.business) || typeof page?.text !== 'string') return;
const text = Buffer.from(page.text).subarray(0, PAGE_BYTES).toString();
const kept = { url, finalUrl: typeof page.finalUrl === 'string' ? page.finalUrl : null, text };
this.#pages.set(cap, [...(this.#pages.get(cap) ?? []), kept].slice(-PAGE_COUNT));
}
// The eight task verbs reach the trusted S3 handler here. It runs outside any transaction, gets the
// cap to authorize and record through authorize() and recordTask(); refusals are recorded as in request().
async requestTask(cap, request, handler) {
@@ -5349,7 +5235,6 @@ export class Broker {
throw refuse(new BusError('web-failed'));
}
complete(out.summary);
if (verb === 'web.fetch') this.#keepPage(cap, s, args.url, out.result);
return { ...out.result, untrusted: true, notice: WEB_UNTRUSTED, source: verb };
}
// Row 210 (#1707, design D5): host.run, one sudo command on a registered host over ssh, for a
@@ -5476,7 +5361,7 @@ export class Broker {
by,
);
this.#routeSession(s.session);
return { role: s.role, run: s.run, thread: s.thread, ...(this.#libraryProject(s.business) ? { library: true } : {}) };
return { role: s.role, run: s.run, thread: s.thread };
}
if (
this.#store.get(
@@ -5500,8 +5385,7 @@ export class Broker {
by,
);
this.#routeWaiting(s.business, s.role);
// C2-06 (C2-07): the runner's switch to the library texts and retry. Absent elsewhere.
return { role: s.role, run: s.run, ...(this.#libraryProject(s.business) ? { library: true } : {}) };
return { role: s.role, run: s.run };
}
case 'role.release': {
keys(a, []);
@@ -5865,10 +5749,6 @@ export class Broker {
if (this.#userThread(s) && to === 'human') fail('user-thread-human');
this.#checkMessaging(s.business, this.#sender(s), testerThread && to === 'tester' ? 'human' : to);
string(a.body, 32768);
// C2-06: a guarded send is stored only through #guardedSend's pass, with its check result.
const guarded = this.#guarded(s, to);
if (guarded && this.#guardPass?.s !== s) fail('storage-refused');
const check = guarded ? this.#guardPass.check : null;
if (
a.class !== undefined &&
![
@@ -5915,9 +5795,6 @@ export class Broker {
a.corrects ?? null,
a.body,
);
// C2-06: the stored check result shares the message's transaction (sage-3595 item 3). No new
// event kind, which would be a schema step: the events kind CHECK is migrations/3b.sql:9.
if (check) this.#event(s, 'action.allowed', { action: 'message.checked', message: mid, check }, mid, checkEventId(mid));
const cancelled = this.#route(s.business, mid, to, thread);
const request = s.human
? this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid)
@@ -6537,15 +6414,11 @@ export class Broker {
)?.seq;
if (after === undefined) fail('message-not-found');
}
// C2-06 (row 225, morgana-0198): a row the citation guard checked carries the stored result.
return this.#store.all(
`SELECT m.* FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=? AND m.seq>? ORDER BY m.seq LIMIT ${limit}`,
session,
after,
).map((m) => {
const e = this.#store.get("SELECT body FROM events WHERE id=? AND kind='action.allowed' AND subject=?", checkEventId(m.id), m.id);
return e ? { ...m, check: JSON.parse(e.body).check } : m;
});
);
}
// Row 206 (193b2, #1704, design 5.3): the owner of a thread shares it read-only, or ends a share.
// A user's collaborator grant must still cover the thread, and a user target needs a live grant
+1 -2
View File
@@ -41,8 +41,7 @@ export class Client {
const r = JSON.parse(input.toString('utf8'));
if (r.ok === true) end(null, r.result);
else if (r.ok === false && typeof r.error === 'string' && /^[a-z-]{1,64}$/.test(r.error))
// C2-06 (sage-3599): the citation guard's reason, when the envelope has one.
end(new BusError(r.error, typeof r.reason === 'string' ? r.reason.slice(0, 300) : undefined));
end(new BusError(r.error));
else end(new BusError('invalid-response'));
} catch {
end(new BusError('invalid-response'));
+1 -10
View File
@@ -14,7 +14,7 @@ import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
import { instanceSettings } from '../../business/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../business/src/organization.mjs';
import { organizationWriter, OrganizationError } from '../../business/src/organization.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { loadRole } from '../../business/src/role.mjs';
@@ -305,13 +305,6 @@ export async function startBroker({
status: context => libraryModule.libraryStatus({ ...context, dataRoot }),
sync: (context, { accept }) => processing.work(() => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept })),
LibraryError: libraryModule.LibraryError,
// C2-06: the citation guard, the reply limits read from the business file at each check (none
// configured reads as unreadable, so requireCitation is true), and thread.messages' `last`.
guard: (context, body, options) => libraryModule.guardReply({ ...context, dataRoot }, body, options),
replyLimits: id => libraryModule.resolveReplyLimits(organization
? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }).readRaw(id) : { readable: false }),
last: (context, paths) => libraryModule.lastCommits({ ...context, dataRoot }, paths),
GuardError: libraryModule.GuardError,
},
project: {
readSpec: context => projectModule.readSpec({ ...context, dataRoot }),
@@ -341,8 +334,6 @@ export async function startBroker({
else if(verb==='plugins.set')result=plugins.set(id,args,actor);
// Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer.
else if(verb==='veto.set')result=instances.veto(id,args,actor);
// C2-06 M11: the human's replyLimits, through the organization writer's fence.
else if(verb==='library.limits.set')result=setReplyLimits(organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),id,args,actor);
// Row 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context.
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
+5 -23
View File
@@ -7,11 +7,6 @@ const LIMIT = 65536;
// Row 148: a document save may carry up to 1 MiB of text. Only a request that names that verb near
// its start (the proof object comes first) may pass LIMIT; the parsed verb is checked again below.
const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.', '\\.')}"`);
// The refusal envelope. C2-06 (sage-3599): `reason` is added only when the error carries one, which
// only the citation guard's refusals do, so every other envelope is byte-identical to before.
const refusal = (e, other) => e instanceof BusError
? { ok: false, error: e.code, ...(typeof e.reason === 'string' ? { reason: e.reason } : {}) }
: { ok: false, error: other };
// One request per connection. There is deliberately no reconnect/retry or SQL verb.
// `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it.
// `web` is the trusted web service (web.mjs), or a function from a business id to one (or null); only
@@ -20,7 +15,6 @@ const refusal = (e, other) => e instanceof BusError
// host-not-configured.
// A request carries exactly one of cap, human (row S4), tester (row 110), user (FED R3) and console
// (row 252) proof.
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, authenticateUser = null, authenticateConsole = null, peerReader = null, timeout = 5000, tasks = null, web = null, host = null }) {
try {
lstatSync(path);
@@ -102,7 +96,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
.requestTask(cap, { verb: r.verb, args: r.args ?? {} }, tasks.handle)
.then(
(result) => finish({ ok: true, result }),
(e) => finish(refusal(e, 'adapter-failed')),
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'adapter-failed' }),
)
.finally(() => {
if (own) release();
@@ -120,7 +114,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
.requestWeb(cap, { verb: r.verb, args: r.args ?? {} }, service, { onAdmit: () => socket.setTimeout(service.timeout) })
.then(
(result) => finish({ ok: true, result }),
(e) => finish(refusal(e, 'web-failed')),
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'web-failed' }),
)
.finally(() => {
if (own) release();
@@ -136,7 +130,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
.requestHost(cap, { verb: r.verb, args: r.args ?? {} }, host, { onAdmit: (ms) => socket.setTimeout(ms + 10000) })
.then(
(result) => finish({ ok: true, result }),
(e) => finish(refusal(e, 'host-failed')),
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'host-failed' }),
)
.finally(() => {
if (own) release();
@@ -159,26 +153,14 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
socket.setTimeout(120_000);
Promise.resolve().then(() => broker.request(cap, { verb: r.verb, args: r.args ?? {} })).then(
result => { if (!socket.destroyed) finish({ ok: true, result }); },
e => { if (!socket.destroyed) finish(refusal(e, 'invalid-envelope')); },
e => { if (!socket.destroyed) finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' }); },
).finally(() => { if (own) release(); });
return;
}
const result = broker.request(cap, { verb: r.verb, args: r.args ?? {} });
// C2-06: a guarded message.send and a library business's thread.messages read Git first, so
// the broker answers them with a promise. Every other verb stays synchronous.
if (result instanceof Promise) {
const own = transient;
transient = null;
socket.setTimeout(120_000);
result.then(
value => { if (!socket.destroyed) finish({ ok: true, result: value }); },
e => { if (!socket.destroyed) finish(refusal(e, 'invalid-envelope')); },
).finally(() => { if (own) release(); });
return;
}
finish({ ok: true, result });
} catch (e) {
finish(refusal(e, 'invalid-envelope'));
finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' });
} finally {
if (transient) release();
}
+1 -1
View File
@@ -21,7 +21,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
// How long a thread's session stays up with no turn before the launcher stops
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
export const THREAD_IDLE_SECONDS = 900;
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits"];
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired"];
// The config directory: next to the system config, $MOSAIC_CONFIG or
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
+1 -44
View File
@@ -8,7 +8,6 @@ import { join, dirname } from "node:path";
import { randomUUID } from "node:crypto";
import { businessFilePath, validateBusinessDocument } from "./business.mjs";
import { requireId, sha256, canonicalJson } from "./util.mjs";
import { checkReplyLimits } from "../../library/src/limits.mjs";
export class OrganizationError extends Error {
constructor(code, message = code) {
@@ -81,33 +80,6 @@ function changes(old, next, path = "") {
}];
}
// C2-06 M11: replyLimits is the human's alone. Any setter that changes it refuses unless the actor is
// the business's human as the file stood before the write, and no agent or the tester under that
// name. Compared as M9 compares projects, on the document and again on what lands on disk.
function replyLimitsFence(old, document, actor) {
if (canonicalJson(document?.replyLimits) === canonicalJson(old.replyLimits)) return;
const agents = Object.entries(old.roles ?? {}).map(([instance, entry]) => `${entry?.definition}.${instance}`);
if (actor === old.human && actor !== "tester" && !agents.includes(actor)) return;
// The reason names each changed key and its value in force (the brief's M11).
const plain = v => v !== null && typeof v === "object" && !Array.isArray(v);
const before = old.replyLimits, after = document?.replyLimits;
const named = plain(before) && plain(after)
? [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(k => canonicalJson(before[k]) !== canonicalJson(after[k]))
.map(k => `replyLimits.${k} is ${canonicalJson(before[k]) ?? "unset"}`)
: [`replyLimits is ${canonicalJson(before) ?? "unset"}`];
fail("organization-reply-limits-human-only", `${named.join(", ").slice(0, 240)}; only the human changes it`);
}
// C2-06: library.limits.set, the human's verb. It replaces the whole replyLimits object; a key left out
// is its default. An unknown key or a value outside the table refuses invalid-request, and the write's
// M11 fence refuses any actor but the human.
export function setReplyLimits(writer, id, args, actor) {
const replyLimits = checkReplyLimits(args?.replyLimits);
if (replyLimits === null || typeof args?.revision !== "string") fail("invalid-request", "replyLimits takes requireCitation and citationsPerReply only");
const { document } = writer.read(id);
return writer.write(id, { ...document, replyLimits }, { revision: args.revision, actor });
}
export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
const evidence = join(dir, "businesses", ".history");
const logFile = id => join(evidence, id, "changes.jsonl");
@@ -121,19 +93,6 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
return { document, revision: sha256(bytes) };
}
// C2-06: the citation guard reads replyLimits fresh on every check, from the raw file, so a business
// file that fails validation still yields its limits. { readable: false } when the file is missing,
// unreadable, not JSON or not an object.
function readRaw(id) {
try {
const document = JSON.parse(regular(businessFilePath(id, dir)));
return document !== null && typeof document === "object" && !Array.isArray(document)
? { readable: true, document } : { readable: false };
} catch {
return { readable: false };
}
}
function withLock(id, fn) {
const parent = join(dir, "businesses");
directory(evidence);
@@ -232,7 +191,6 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
// mapping, including library playgrounds and hybrid universes.
if (canonicalJson(document?.projects) !== canonicalJson(old.projects))
fail('organization-projects-protected');
replyLimitsFence(old, document, actor);
validateBusinessDocument(document, file, { rolesDir });
const bytes = JSON.stringify(document, null, 2) + "\n";
// Check and log exactly what lands on disk, for every setter: a value JSON
@@ -240,7 +198,6 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
const written = JSON.parse(bytes);
if (canonicalJson(written.projects) !== canonicalJson(old.projects))
fail('organization-projects-protected');
replyLimitsFence(old, written, actor);
validateBusinessDocument(written, file, { rolesDir });
const after = sha256(bytes);
const transaction = randomUUID();
@@ -288,5 +245,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
});
}
return { logFile, read, readRaw, write, record, recover: id => withLock(id, () => recover(id)) };
return { logFile, read, write, record, recover: id => withLock(id, () => recover(id)) };
}
+4 -11
View File
@@ -7,10 +7,9 @@ import { CliError } from "./errors.mjs";
import { migrateLaunch } from "../../business/src/migrate-launch.mjs";
// Row 155 (#1649, Q169): a PATCH may carry `assistants`, the assistant's vars, tracker and definition.
// C2-06 M11: `replies --file LIMITS` replaces replyLimits with the file's object, at the current revision.
export const LIMITS_USAGE = "mosaic limits [--business ID] [--json] [--tester] | set --file PATCH [--business ID] | " +
"reset PATH... [--business ID] | replies --file LIMITS [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " +
"PATCH keys: revision, limits, models, instances, reset, assistants; LIMITS keys: requireCitation, citationsPerReply";
"reset PATH... [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " +
"PATCH keys: revision, limits, models, instances, reset, assistants";
export const DAILY_NOTE = "Daily quotas count only this stack's own use of the account, not its use anywhere else.";
@@ -68,9 +67,8 @@ export async function limitsCommand(args, io, callFor) {
io.stdout.write(JSON.stringify(converted, null, 2) + "\n");
return;
}
const fileVerb = verb === "set" || verb === "replies";
if (!["view", "set", "reset", "replies"].includes(verb) || values["--models"] || values["--roles-dir"] ||
(!fileVerb && values["--file"]) || (fileVerb && !values["--file"]) ||
if (!["view", "set", "reset"].includes(verb) || values["--models"] || values["--roles-dir"] ||
(verb !== "set" && values["--file"]) || (verb === "set" && !values["--file"]) ||
(verb === "reset" && parsed.positional.length < 2)) {
throw new CliError(LIMITS_USAGE, 4);
}
@@ -80,11 +78,6 @@ export async function limitsCommand(args, io, callFor) {
// result's `tokens` lists each token limit: counted, not-enforced or check-failed.
let result;
if (verb === "set") result = await call("limits.set", JSON.parse(readFileSync(values["--file"], "utf8")));
else if (verb === "replies") {
const replyLimits = JSON.parse(readFileSync(values["--file"], "utf8"));
const { revision } = await call("limits", {});
result = await call("library.limits.set", { revision, replyLimits });
}
else if (verb === "reset") {
const { revision } = await call("limits", {});
result = await call("limits.set", { revision, reset: [parsed.positional.slice(1)] });
@@ -0,0 +1,234 @@
// FED R7 / #1802. Run through scripts/test-federation-acceptance.sh: it owns
// the child process groups and scratch lifetime. Ordinary CLI suites skip it.
// sage-3625: federation-host.mjs supplies a labelled readProcess ancestry
// stand-in for setup, grant/revoke and both Console transports. This is NOT
// a genuine outside-agent human proof. Product transports/grant checks stay real.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { closeSync, cpSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
import { request } from 'node:http';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { businessDoc, launchDoc, writeJson } from '../../business/tests/helpers.mjs';
import { REPO_SKILLS } from '../../harness/tests/helpers.mjs';
import { DatabaseSync } from 'node:sqlite';
import { SESSION_COOKIE } from '../../webui/src/sso.mjs';
import { fakeOidc } from '../../webui/tests/helpers/fake-oidc.mjs';
import { socketPath, REPO } from '../src/config.mjs';
import { startTimeOf } from '../src/host.mjs';
import { launchLogFile, readSessions } from '../../seat/src/session.mjs';
const root = process.env.MOSAIC_R7_ROOT;
const helper = fileURLToPath(new URL('./fixtures/federation-human.mjs', import.meta.url));
const adapterModule = fileURLToPath(new URL('./fixtures/thread-adapter.mjs', import.meta.url));
const webCli = join(REPO, 'packages/webui/src/cli.mjs');
const cli = join(REPO, 'packages/cli/src/cli.mjs');
const pause = ms => new Promise(r => setTimeout(r, ms));
const json = path => JSON.parse(readFileSync(path, 'utf8'));
function run(cmd, args, options = {}) {
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: 30000, ...options });
assert.equal(r.status, 0, `${cmd} ${args.join(' ')}: ${r.stderr ?? ''} ${r.error?.message ?? ''}`);
return r.stdout;
}
async function until(read, label, ms = 20000) {
const end = Date.now() + ms;
while (Date.now() < end) {
const value = await read();
if (value) return value;
await pause(50);
}
assert.fail(`R7 assertion: ${label}`);
}
function http(instance, path, { cookie, body, method = body === undefined ? 'GET' : 'POST' } = {}) {
return new Promise((resolve, reject) => {
const text = body === undefined ? undefined : JSON.stringify(body);
const headers = { host: new URL(instance.origin).host };
if (cookie) headers.cookie = cookie;
if (text !== undefined) Object.assign(headers, { origin: instance.origin, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(text) });
const req = request({ host: '127.0.0.1', port: instance.port, path, method, headers, timeout: 5000 }, res => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data && String(res.headers['content-type']).startsWith('application/json') ? JSON.parse(data) : data }));
});
req.on('error', reject);
req.on('timeout', () => req.destroy(new Error('R7 HTTP timeout')));
req.end(text);
});
}
let serial = 0;
function child(instance, label, command) {
const log = join(root, `${instance.name}-${label}.log`);
const fd = openSync(log, 'a', 0o600);
let proc;
try { proc = spawn(command[0], command.slice(1), { detached: true, env: instance.env, stdio: ['ignore', fd, fd] }); }
finally { closeSync(fd); }
proc.on('error', e => { console.error(`R7 child ${label}: ${e.message}`); });
assert.ok(proc.pid, `R7 ${label} spawned`);
const start = startTimeOf(proc.pid);
assert.ok(start, `R7 ${label} start time recorded`);
// EXIT trap owns the detached group even if Node's test driver is killed.
writeFileSync(join(root, 'groups'), `${proc.pid} ${start}\n`, { flag: 'a' });
proc.unref();
return { log, proc };
}
// Jason-shell ancestry stand-in is in the fixture host; transport/path are real.
function human(instance, verb, args = {}) {
const out = join(root, `human-${++serial}.json`);
run(process.execPath, [helper, socketPath(instance.dataRoot), instance.name, verb, JSON.stringify(args), out], { env: instance.env });
return json(out);
}
// R5/R4 CLI with that same labelled fixture proof, never a live human shell.
function access(instance, args) {
return run(process.execPath, [cli, 'access', ...args, '--business', instance.name], { env: instance.env });
}
function prepare(name, iss, ca) {
const home = process.env.HOME;
const env = { HOME: home, PATH: process.env.PATH, TMPDIR: process.env.TMPDIR };
run(join(REPO, 'scripts/bootstrap.sh'), ['--instance', name], { env });
const config = join(home, '.config', `mosaic-${name}`, 'config.json');
const system = json(config), dataRoot = system.dataRoot, configDir = dirname(config);
const fixtureRoot = join(root, name);
mkdirSync(fixtureRoot, { recursive: true });
const doc = businessDoc(fixtureRoot, name);
doc.human = 'melanie';
doc.plugins = { harness: ['pi'], channel: ['webui'] };
doc.roles.chief.vars = { harness: 'pi', model: 'claude-opus-5-5', thinking: 'high' };
doc.roles.assistant = { definition: 'assistant', vars: { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high' } };
doc.launch = launchDoc('chief', ['coder', 'assistant'], { opus: 1, sonnet: 2 });
doc.launch.threadIdleSeconds = 3600;
for (const role of Object.values(doc.roles)) role.vars = { harness: 'pi', model: 'glm-5.3-flash', thinking: 'high', ...role.vars };
writeJson(join(configDir, 'businesses', `${name}.json`), doc);
const origin = `https://${name}.example.test`;
system.console = { sso: { publicOrigin: origin, providers: [{ id: 'test-op', issuer: iss.issuer, clientId: iss.clientId,
clientSecret: { slot: name }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'melanie' }] }] } };
system.federation = { self: name, peers: [{ id: name === 'hub' ? 'factory' : 'hub', origin: `https://${name === 'hub' ? 'factory' : 'hub'}.example.test`, label: name === 'hub' ? 'Factory' : 'Hub' }] };
writeJson(config, system);
writeJson(join(configDir, 'access.json'), { accessVersion: 1, revision: 1, users: [], grants: [] });
// Generated fake OP material only; never copies a host slot or credential.
writeJson(join(configDir, 'secrets/mosaic-stack', name), iss.clientSecret);
cpSync(REPO_SKILLS, join(dataRoot, 'skills-enabled'), { recursive: true });
const projectRoot = doc.projects.stack.root;
const gitEnv = { ...env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' };
const git = (...args) => run('git', ['-c', 'user.name=R7 fixture', '-c', '[email protected]', '-C', projectRoot, ...args], { env: gitEnv });
git('init', '-q', '-b', 'main');
writeFileSync(join(projectRoot, 'file.txt'), `${name} fixture\n`);
git('add', 'file.txt'); git('commit', '-q', '-m', 'scratch fixture');
const adapter = join(fixtureRoot, 'adapter.sh');
writeFileSync(adapter, `exec '${process.execPath}' '${adapterModule}'\n`);
return { name, origin, configDir, dataRoot, adapter, env: { ...env, MOSAIC_CONFIG: config, NODE_EXTRA_CA_CERTS: ca }, port: null };
}
async function signIn(instance, op, iss, sub) {
const start = await http(instance, '/auth/login');
assert.equal(start.status, 303, JSON.stringify(start.body));
const location = new URL(start.headers.location);
const { code } = op.grant(iss, location.href, { sub });
const cookie = start.headers['set-cookie'][0].split(';')[0];
const result = await http(instance, `/auth/callback?${new URLSearchParams({ code, state: location.searchParams.get('state') })}`, { cookie });
const session = (result.headers['set-cookie'] ?? []).find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0'))?.split(';')[0];
return { ...result, cookie: session };
}
const logs = i => existsSync(launchLogFile(i.dataRoot, i.name)) ? readFileSync(launchLogFile(i.dataRoot, i.name), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse) : [];
function claimHistory(instance, run) {
const store = new DatabaseSync(join(instance.dataRoot, 'bus/bus.sqlite'), { readOnly: true });
try { return store.prepare('SELECT op, reason FROM thread_claims WHERE holder_run=? ORDER BY seq').all(run); }
finally { store.close(); }
}
test('FED R7 scratch two-instance acceptance', { skip: !root && 'run scripts/test-federation-acceptance.sh', timeout: 180000 }, async t => {
const op = await fakeOidc();
t.after(() => op.close());
const hubIssuer = op.issuer('hub'), factoryIssuer = op.issuer('factory');
assert.notEqual(hubIssuer.clientId, factoryIssuer.clientId);
const ca = join(root, 'fake-op-ca.pem'); writeFileSync(ca, op.cert);
assert.ok(![7330, 7331, 7332].includes(Number(new URL(op.base).port)));
writeFileSync(join(root, 'ports'), `${new URL(op.base).port}\n`, { flag: 'a' });
const hub = prepare('hub', hubIssuer, ca), factory = prepare('factory', factoryIssuer, ca);
assert.notEqual(hub.dataRoot, factory.dataRoot);
assert.notEqual(hub.env.MOSAIC_CONFIG, factory.env.MOSAIC_CONFIG);
for (const i of [hub, factory]) {
const ready = join(root, `${i.name}-host.ready`);
const host = child(i, 'host', [process.execPath, '--experimental-test-module-mocks',
fileURLToPath(new URL('./fixtures/federation-host.mjs', import.meta.url)), i.name, i.adapter, ready]);
await until(() => existsSync(ready), `${i.name} fixture host ready; ${host.log}`);
writeFileSync(join(root, 'sockets'), `${socketPath(i.dataRoot)}\n`, { flag: 'a' });
}
const shared = human(hub, 'project.create', { name: 'hub-shared', repo: 'stack' }).id;
const privateProject = human(hub, 'project.create', { name: 'hub-private' }).id;
human(factory, 'project.create', { name: 'factory-only' });
access(hub, ['user', 'add', 'jason-test', '--home', 'factory', '--subject', 'test-op:jason-test-sub', '--revision', '1']);
access(hub, ['grant', '--user', 'jason-test', '--projects', shared, '--instances', 'assistant', '--level', 'collaborator', '--revision', '2']);
for (const i of [hub, factory]) {
const { log } = child(i, 'console', [process.execPath, webCli, 'serve', '--port', '0', '--business', i.name, '--board', 'http://127.0.0.1:1', '--sso', '--send']);
i.port = Number(await until(() => existsSync(log) && readFileSync(log, 'utf8').match(/Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//)?.[1], `${i.name} Console started`));
assert.ok(![7330, 7331, 7332].includes(i.port));
writeFileSync(join(root, 'ports'), `${i.port}\n`, { flag: 'a' });
}
assert.notEqual(hub.port, factory.port);
if (process.env.MOSAIC_R7_INTERRUPT === '1') {
console.log('R7 interruption: both Consoles started; killing test driver');
process.kill(process.pid, 'SIGKILL');
await pause(60000);
}
const signed = await signIn(hub, op, hubIssuer, 'jason-test-sub');
assert.equal(signed.status, 303, JSON.stringify(signed.body)); assert.ok(signed.cookie);
const cookie = signed.cookie;
const config = await http(hub, '/api/config', { cookie });
assert.equal(config.status, 200); assert.equal(config.body.user, true);
const projects = await http(hub, '/api/chat/projects', { cookie });
assert.equal(projects.status, 200, JSON.stringify(projects.body));
assert.deepEqual(projects.body.rows.map(p => p.id), [shared], 'R7 ungranted project must not appear');
const created = await http(hub, '/api/chat/threads', { cookie, body: { project: shared, instance: 'assistant', name: 'Jason scratch thread' } });
assert.equal(created.status, 200, JSON.stringify(created.body));
const thread = created.body.id;
const sent = await http(hub, `/api/chat/threads/${thread}/messages`, { cookie, body: { body: 'R7 scratch question' } });
assert.equal(sent.status, 200, JSON.stringify(sent.body));
const reply = await until(async () => {
const r = await http(hub, `/api/chat/threads/${thread}/messages`, { cookie });
assert.equal(r.status, 200, JSON.stringify(r.body));
return r.body.rows.find(m => m.class === 'RESULT' && m.in_reply_to === sent.body.id);
}, 'granted instance replies in Jason thread');
assert.equal(reply.from_role, 'assistant'); assert.equal(reply.to_role, 'user');
assert.match(reply.body, /R7 scratch question/);
const refused = await http(hub, '/api/chat/threads', { cookie, body: { project: privateProject, instance: 'assistant', name: 'must refuse' } });
assert.equal(refused.status, 403); assert.equal(refused.body.error, 'access-refused');
assert.deepEqual(json(join(hub.configDir, 'access.json')).grants[0].projects, [shared], 'R7 grant must name exactly one project');
for (const path of ['/api/board', '/api/bus/inbox', '/api/bus/tasks']) {
const r = await http(hub, path, { cookie });
assert.equal(r.status, 403, path); assert.equal(r.body.error, 'owner-only', path);
}
const factoryLogin = await signIn(factory, op, factoryIssuer, 'jason-test-sub');
assert.equal(factoryLogin.status, 403); assert.equal(factoryLogin.cookie, undefined);
const factoryOwner = await signIn(factory, op, factoryIssuer, 'owner-sub');
assert.equal(factoryOwner.status, 303); assert.ok(factoryOwner.cookie);
const factoryProjects = await http(factory, '/api/chat/projects', { cookie: factoryOwner.cookie });
assert.equal(factoryProjects.status, 200);
assert.deepEqual(factoryProjects.body.rows.map(p => p.name), ['factory-only']);
const factoryThreads = await http(factory, '/api/chat/threads', { cookie: factoryOwner.cookie });
assert.equal(factoryThreads.status, 200); assert.deepEqual(factoryThreads.body.rows, []);
const hubCookieAtFactory = await http(factory, '/api/chat/projects', { cookie });
assert.equal(hubCookieAtFactory.status, 401);
const [launch] = logs(hub).filter(e => e.event === 'launch' && e.thread === thread);
assert.ok(launch, 'R7 sandboxed run launched');
const liveRun = readSessions(hub.dataRoot).find(s => s.run === launch.run);
assert.ok(liveRun, 'R7 run remains live before revoke');
access(hub, ['revoke', '--user', 'jason-test', '--revision', '3']);
const after = await http(hub, '/api/chat/projects', { cookie });
assert.equal(after.status, 401); assert.equal(after.body.error, 'sign-in required');
assert.equal((await signIn(hub, op, hubIssuer, 'jason-test-sub')).status, 403);
// Pin the claim before waiting for process exit: a missing broker revocation
// must fail this assertion, even if it also prevents the runner from ending.
assert.ok(claimHistory(hub, launch.run).some(r => r.op === 'revoke' && r.reason === 'access-revoked'),
'R7 revoke must end the claim as access-revoked');
const ended = await until(() => logs(hub).find(e => e.event === 'end' && e.run === launch.run), 'revoked run ended');
assert.equal(ended.exitCode, 0, JSON.stringify(ended));
assert.deepEqual(readSessions(hub.dataRoot), []);
await until(() => startTimeOf(liveRun.pid) !== liveRun.startTime && startTimeOf(liveRun.runnerPid) !== liveRun.runnerStartTime, 'revoked engine and runner processes gone');
assert.equal(claimHistory(hub, launch.run).at(-1).op, 'revoke', 'R7 shutdown must not replace missing revocation with release');
assert.deepEqual(human(factory, 'thread.list'), []);
});
+67
View File
@@ -0,0 +1,67 @@
// FED R7 fixture, sage-3625: the ancestry walk is a stand-in for Jason's
// shell. Keep this override in a test-only process, never in product code.
// As channel-fixture.mjs, real pid/uid/argv/startTime/nonce checks remain;
// verifyHuman sees ppid=1 instead of walking the development agent's tree.
import { mock } from 'node:test';
const humanUrl = new URL('../../../bus/src/human.mjs', import.meta.url).href;
const real = await import(humanUrl);
mock.module(humanUrl, { namedExports: {
readProcess: real.readProcess,
verifyHuman: (proof, options) => real.verifyHuman(proof, {
...options, readProcess: pid => ({ ...real.readProcess(pid), ppid: 1 }),
}),
} });
const { startBroker } = await import('../../../bus/src/runtime.mjs');
const { BusError } = await import('../../../bus/src/broker.mjs');
const { loadSystem, bootConfig } = await import('../../src/config.mjs');
const { createLauncher } = await import('../../src/launcher.mjs');
const { fakeHarnesses } = await import('../helpers.mjs');
const { writeFileSync } = await import('node:fs');
const [business, adapter, ready] = process.argv.slice(2);
const system = loadSystem();
const runtime = await startBroker(bootConfig({ system, businessId: business }));
const hooks = [], cleanups = [];
const ops = {
identity: m => runtime.identity(m.cap),
authorizeLaunch: m => runtime.authorizeLaunch(m.cap, m.instance),
refuse: m => runtime.refuse(m.cap, m.code, m.subject),
endLaunch: m => runtime.endLaunch(m.record),
credentialStatus: m => runtime.credentialStatus(m.business, m.role),
threadsState: m => runtime.threadsState(m.business),
threadIdle: m => runtime.threadIdle(m.record),
mayLaunch: m => runtime.mayLaunch(m),
landHeld: m => runtime.landHeld(m.record),
landHolders: m => runtime.landHolders(m.business),
landRelease: m => runtime.landRelease(m.record),
sessionClears: m => runtime.sessionClears(m.business),
lifecycleRequests: m => runtime.lifecycleRequests(m.business),
};
const wrap = fn => async value => {
try { return fn(value); }
catch (e) { throw Object.assign(new Error(e.message), { code: e instanceof BusError ? e.code : 'fixture-host-refused' }); }
};
const host = {
business, path: runtime.path, dataRoot: system.dataRoot,
op: wrap(m => { if (!Object.hasOwn(ops, m.op)) throw new BusError('invalid-host-request'); return ops[m.op](m); }),
bindLaunch: wrap(record => runtime.bindLaunch(record)),
beforeClose: fn => void hooks.push(fn),
};
const launcher = createLauncher({ host, system,
makeLoader: await fakeHarnesses({ after: fn => cleanups.push(fn) }, { pi: adapter }, write => write('channel', 'webui',
`export * from ${JSON.stringify(new URL('../../../plugins/channel/webui/index.mjs', import.meta.url).href)};`)),
readOnly: [new URL('./thread-adapter.mjs', import.meta.url).pathname],
harnessVersion: '0.85.1', sessionDefaults: { pollInterval: 600000 }, threadTickMs: 100,
});
let closing = false;
async function close() {
if (closing) return;
closing = true;
for (const fn of hooks) await fn(0);
await runtime.close();
for (const fn of cleanups) await fn();
process.exit(0);
}
process.once('SIGTERM', close);
process.once('SIGINT', close);
await launcher.listen();
writeFileSync(ready, 'ready\n', { mode: 0o600 });
+7
View File
@@ -0,0 +1,7 @@
// R7 fixture: actual human-cli path with the fixture host's labelled ancestry override.
// This stands in for Jason's shell (sage-3625); it does not prove real human ancestry.
import { writeFileSync } from 'node:fs';
import { humanTransport } from '../../src/transport.mjs';
const [socket, business, verb, args, out] = process.argv.slice(2);
const result = await humanTransport({ socket, business })(verb, JSON.parse(args));
writeFileSync(out, JSON.stringify(result), { mode: 0o600 });
-197
View File
@@ -1,197 +0,0 @@
// C2-06 (design 5.2, rulings after r11 Q222, Q223 and Q226): the citation guard. The broker runs it
// before it stores an agent's message to the human in a business with a library project. It reads
// only the citations, never what the text says. Codes are design 5.2's table.
import { git } from '../../project/src/git.mjs';
import { classify } from './scope.mjs';
import { listTree, readLibrary, readTexts, splitLines } from './reader.mjs';
import { LibraryError } from './problems.mjs';
import { mirrorPath, readSyncState } from './mirror.mjs';
import { plain } from './limits.mjs';
export const GUARD_CODES = Object.freeze(['reply-invalid', 'citation-missing', 'citations-over-limit', 'citation-invalid',
'playground-cited-as-canon', 'citation-cross-universe', 'citation-unfetched']);
// The runner's four fixed texts in a library business. The guard accepts exactly these bodies when
// requireCitation is true; an agent that sends one gains nothing it couldn't say anyway.
export const FIXED_TEXTS = Object.freeze({
empty: '(the session gave no answer)',
failed: '(the turn failed)',
uncited: '(no answer passed the citation check)',
tooLong: '(the answer was too long to send)',
});
// Web cites are for these definitions only (design 5.2).
export const WEB_CITE_DEFINITIONS = Object.freeze(['research', 'companion']);
// The reason reaches the runner (sage-3599): the guard's own text naming the check, the cite and the
// limit, never a quote or file text, and at most REASON_MAX characters.
export const REASON_MAX = 300;
export class GuardError extends LibraryError {
constructor(code, reason) { reason = reason.slice(0, REASON_MAX); super(code, reason); this.reason = reason; }
}
const refuse = (code, reason) => { throw new GuardError(code, reason); };
const SHA = /^[a-f0-9]{40}$/;
const LIBRARY_CITE = ['path', 'commit', 'lines', 'quote'];
const WEB_CITE = ['url', 'quote'];
const only = (value, required, optional = []) => plain(value)
&& required.every(k => Object.hasOwn(value, k)) && Object.keys(value).every(k => required.includes(k) || optional.includes(k));
const text = value => typeof value === 'string' && value.length > 0 && !value.includes('\0');
function libraryCite(c) {
return only(c, LIBRARY_CITE, ['as']) && text(c.path) && c.path.length <= 4096 && typeof c.commit === 'string' && SHA.test(c.commit)
&& Array.isArray(c.lines) && c.lines.length === 2 && c.lines.every(n => Number.isSafeInteger(n) && n >= 1) && c.lines[0] <= c.lines[1]
&& text(c.quote) && (!Object.hasOwn(c, 'as') || c.as === 'canon');
}
const webCite = c => only(c, WEB_CITE) && text(c.url) && c.url.length <= 4096 && text(c.quote);
// A body is free text, or a JSON object {text, citations} that must fit the schema. Returns
// { text, citations, cited } where `cited` says the body was the JSON form.
export function parseReply(body) {
let value;
try { value = JSON.parse(body); } catch { return { text: body, citations: [], cited: false }; }
if (!plain(value)) return { text: body, citations: [], cited: false };
if (!only(value, ['text', 'citations']) || typeof value.text !== 'string' || !Array.isArray(value.citations))
refuse('reply-invalid', 'a JSON object reply holds exactly "text", a string, and "citations", a list');
value.citations.forEach((c, i) => {
if (!libraryCite(c) && !webCite(c))
refuse('reply-invalid', `citation ${i + 1} is neither {path, commit, lines, quote, as?} nor {url, quote}`);
});
return { text: value.text, citations: value.citations, cited: true };
}
const squash = value => value.replace(/\s+/g, ' ').trim();
// Design 5.2's checks, in the table's order. `limits` comes from resolveReplyLimits. `library` reads
// git for a library cite (libraryReader below). `web` is { allowed, pages } for this sender: whether
// its definition may cite a url, and the session's kept fetches. Returns the stored check result,
// { citations: [{ scope, agent, file }] } in body order (agreed with row 225, morgana-0198 and
// morgana-0200; a url cite is { scope: 'web', agent: null }), or null for a
// plain body. Throws GuardError.
export async function checkReply(body, { limits, mapping, library, web }) {
if (Object.values(FIXED_TEXTS).includes(body)) return null;
const reply = parseReply(body);
const { citations } = reply;
if (limits.requireCitation && citations.length === 0)
refuse('citation-missing', 'replyLimits.requireCitation is true, so a reply carries at least one citation');
if (citations.length > limits.citationsPerReply)
refuse('citations-over-limit', `replyLimits.citationsPerReply is ${limits.citationsPerReply}; the reply carries ${citations.length}`);
if (!reply.cited) return null;
const found = [];
for (const [i, c] of citations.entries()) {
if (!Object.hasOwn(c, 'path')) { found.push(null); continue; }
const at = `citation ${i + 1} (${c.path})`;
const where = classify(mapping, c.path);
if (where.scope === 'unmapped') refuse('citation-invalid', `${at} names a path that isn't mapped`);
const lines = await library.lines(c.commit, c.path);
if (lines === null) refuse('citation-invalid', `${at} isn't an indexed file at a commit reachable from the served commit`);
if (c.lines[1] > lines.length) refuse('citation-invalid', `${at} cites lines past the file's ${lines.length}`);
const quote = squash(c.quote);
if (!quote || !squash(lines.slice(c.lines[0] - 1, c.lines[1]).join('\n')).includes(quote))
refuse('citation-invalid', `${at} quote isn't in lines ${c.lines[0]}-${c.lines[1]}`);
found.push(where);
}
citations.forEach((c, i) => {
if (c.as === 'canon' && found[i].scope !== 'canon')
refuse('playground-cited-as-canon', `citation ${i + 1} (${c.path}) is ${found[i].scope}, not canon`);
});
const universes = [...new Set(found.filter(Boolean).map(w => w.universe))];
if (universes.length > 1) {
const hybrid = universes.find(u => mapping.universes[u]?.kind === 'hybrid');
const sources = hybrid ? mapping.universes[hybrid].sources : [];
if (!hybrid || !universes.every(u => u === hybrid || sources.includes(u)))
refuse('citation-cross-universe', `the citations span universes ${universes.join(', ')}`);
}
citations.forEach((c, i) => {
if (!Object.hasOwn(c, 'url')) return;
if (!web.allowed) refuse('citation-unfetched', `citation ${i + 1}: only research and companion cite a url`);
const quote = squash(c.quote);
const page = web.pages.find(p => p.url === c.url || p.finalUrl === c.url);
if (!page) refuse('citation-unfetched', `citation ${i + 1}: this session fetched no such url through the bus`);
if (!quote || !squash(page.text).includes(quote)) refuse('citation-unfetched', `citation ${i + 1}: the quote isn't in the url's kept text`);
});
const out = [];
for (const [i, c] of citations.entries())
out.push(found[i] ? { scope: found[i].scope, ...await library.origin(c.commit, c.path) } : { scope: 'web', agent: null });
return { citations: out };
}
// Git reads for one check against the bare mirror. `lines` is the file's lines at `commit` when the
// commit is reachable from the served commit and readLibrary indexes the path there (sage-3595 item 4:
// readLibrary at the cited commit stands in for the C2-05a index), else null. Reads are cached per
// commit for the one check.
export function libraryReader(mirror, served, mapping) {
const indexed = new Map(), reach = new Map();
const once = (cache, key, read) => {
if (!cache.has(key)) cache.set(key, read().catch(() => null));
return cache.get(key);
};
const reachable = commit => once(reach, commit, async () => {
if (!mirror || !served) return false;
if (await git(mirror, ['cat-file', '-t', commit]) !== 'commit') return false;
await git(mirror, ['merge-base', '--is-ancestor', commit, served]);
return true;
});
const index = commit => once(indexed, commit, async () => {
const [read, entries] = await Promise.all([readLibrary({ repo: mirror, commit, mapping }), listTree(mirror, commit)]);
return { files: new Set(read.files.map(file => file.path)), entries };
});
return {
async lines(commit, path) {
if (!await reachable(commit)) return null;
const at = await index(commit);
if (!at?.files.has(path)) return null;
const texts = await readTexts(mirror, [at.entries.find(entry => entry.path === path)]).catch(() => new Map());
return texts.has(path) ? splitLines(texts.get(path)) : null;
},
// One git call: the file's last commit at `commit` (morgana-0200: the Console compares it with the
// served tip's, as the cited library commit needn't touch the file) and that commit's trailer.
async origin(commit, path) {
if (!mirror) return { agent: null, file: null };
try {
const [file, trailer = ''] = (await git(mirror, ['log', '-1', '--format=%H%x00%(trailers:key=Mosaic-Agent,valueonly)', commit, '--', path])).split('\0');
return { agent: trailer.trim() || null, file: SHA.test(file) ? file : null };
} catch { return { agent: null, file: null }; }
},
// The file's last commit at the served tip, for thread.messages (sage-3595), or null.
async last(path) {
if (!mirror || !served) return null;
try {
const value = await git(mirror, ['log', '-1', '--format=%H', served, '--', path]);
return SHA.test(value) ? value : null;
} catch { return null; }
},
};
}
// thread.messages' `last` (sage-3595): each path's last commit at the served tip, one git call per
// distinct path. A path maps to null when there is no served commit or the read fails.
export async function lastCommits(context, paths) {
const { mirror, served } = await servedMirror(context);
const reader = libraryReader(mirror, served, null);
const out = new Map();
for (const path of new Set(paths)) out.set(path, await reader.last(path));
return out;
}
// The library project's bare mirror and its served commit. Either is null when there is none yet, so
// a plain reply still passes and any library cite refuses citation-invalid.
async function servedMirror(context) {
const mapping = context.business.projects[context.project];
let mirror = null, served = null;
try {
mirror = mirrorPath(context);
served = (await readSyncState(mirror, { branch: mapping.branch })).served ?? null;
} catch { served = null; }
return { mapping, mirror, served };
}
// The broker's entry: the library project's mirror at its served commit, then checkReply. `context`
// is { business, project, dataRoot }. `definition` is the sender's role definition, which decides only
// whether a url cite may pass; `pages` is the broker's page store for the sender's session.
export async function guardReply(context, body, { limits, definition, pages }) {
const { mapping, mirror, served } = await servedMirror(context);
const web = { allowed: WEB_CITE_DEFINITIONS.includes(definition), pages };
return checkReply(body, { limits, mapping, library: libraryReader(mirror, served, mapping), web });
}
-2
View File
@@ -8,5 +8,3 @@ export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs';
export { survey, formatSurvey, surveyMain } from './survey.mjs';
export { SYNC_STATE_FILE, mirrorPath, ensureMirror, withLibraryLock, servedCommit, readSyncState, libraryStatus, readLibraryFile } from './mirror.mjs';
export { syncCommand, sync, ASKPASS, SLOT, SYNC_INTERVAL_MS } from './sync.mjs';
export { REPLY_LIMITS, resolveReplyLimits, checkReplyLimits } from './limits.mjs';
export { GUARD_CODES, FIXED_TEXTS, WEB_CITE_DEFINITIONS, REASON_MAX, GuardError, parseReply, checkReply, libraryReader, guardReply, lastCommits } from './guard.mjs';
-41
View File
@@ -1,41 +0,0 @@
// C2-06 (design 5.2): the business file's replyLimits, read by the citation guard and written only
// by the human's library.limits.set. Pure, so the business package's organization writer imports it.
export const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
// The replyLimits keys, their defaults and their valid values. A key is read only when Object.hasOwn
// finds it and its value passes `valid`; anything else is the default (webby-0144 R3).
export const REPLY_LIMITS = Object.freeze({
requireCitation: { default: false, valid: v => typeof v === 'boolean' },
citationsPerReply: { default: 20, valid: v => Number.isInteger(v) && v >= 1 && v <= 1000 },
});
// `read` is { readable: false } when the business file is missing, unreadable, not JSON or not an
// object at check time, else { readable: true, document }. An unreadable file checks at the defaults
// except requireCitation, which is true so a setting she turned on doesn't fail open (webby-0151 R2).
export function resolveReplyLimits(read) {
const limits = {}, fallback = [];
const unreadable = !read?.readable || !plain(read.document);
const raw = unreadable ? undefined : read.document.replyLimits;
for (const [key, { default: value, valid }] of Object.entries(REPLY_LIMITS)) {
if (plain(raw) && Object.hasOwn(raw, key) && valid(raw[key])) limits[key] = raw[key];
else {
limits[key] = value;
// A key fell back when replyLimits is there but isn't an object, or holds the key with a value
// outside the table. A key left out is simply its default.
if (raw !== undefined && (!plain(raw) || Object.hasOwn(raw, key))) fallback.push(key);
}
}
if (unreadable) limits.requireCitation = true;
return { ...limits, fallback, unreadable };
}
// library.limits.set's check: the whole object is replaced, a key left out means its default, and an
// unknown key or a value outside the table refuses. Returns the object to write.
export function checkReplyLimits(value) {
if (!plain(value)) return null;
for (const [key, v] of Object.entries(value))
if (!Object.hasOwn(REPLY_LIMITS, key) || !REPLY_LIMITS[key].valid(v)) return null;
return structuredClone(value);
}
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# FED R7 / #1802. Scratch acceptance with labelled ancestry stand-in (sage-3625).
# No systemd units. All children run in owned process groups, removed on exit.
set -euo pipefail
R7_INTERRUPT=0
case "${1:-}" in
'') ;;
--kill-after-start) R7_INTERRUPT=1 ;;
*) echo 'usage: scripts/test-federation-acceptance.sh [--kill-after-start]' >&2; exit 2 ;;
esac
[ "$#" -le 1 ] || exit 2
for R7_TOOL in ss rg timeout node ps awk; do
command -v "$R7_TOOL" >/dev/null || { echo "R7 requires $R7_TOOL" >&2; exit 1; }
done
cd "$(dirname "${BASH_SOURCE[0]}")/.."
R7_ROOT="$(mktemp -d /mnt/storage/scratch/tmp/r7.XXXXXXXX)"
mkdir -p "$R7_ROOT/home" "$R7_ROOT/tmp"
touch "$R7_ROOT/groups" "$R7_ROOT/ports" "$R7_ROOT/sockets"
cleanup() {
local result=$? pid start raw port socket listeners cleanup_failed=0 attempt
local -a fields
trap - EXIT INT TERM
if [ -n "${R7_DRIVER:-}" ]; then
kill -TERM "$R7_DRIVER" 2>/dev/null || true
wait "$R7_DRIVER" 2>/dev/null || true
fi
# A detached child is its group's leader. Confirm pid/start time/group before
# signalling only groups in this run's ledger; never a live stack process.
while read -r pid start; do
[ -n "$pid" ] || continue
if [ -r "/proc/$pid/stat" ]; then
raw="$(cat "/proc/$pid/stat")"
read -r -a fields <<< "${raw##*) }"
if [ "${fields[19]}" != "$start" ] || [ "${fields[2]}" != "$pid" ]; then
echo "R7 cleanup failed: group identity changed: $pid" >&2
result=1; cleanup_failed=1
continue
fi
kill -TERM -- "-$pid" 2>/dev/null || true
for ((attempt=0; attempt<800; attempt++)); do
ps -eo pgid=,stat= | awk -v g="$pid" '$1==g && $2 !~ /^Z/ {found=1} END {exit !found}' || break
sleep 0.05
done
kill -KILL -- "-$pid" 2>/dev/null || true
for ((attempt=0; attempt<40; attempt++)); do
ps -eo pgid=,stat= | awk -v g="$pid" '$1==g && $2 !~ /^Z/ {found=1} END {exit !found}' || break
sleep 0.05
done
fi
# Zombies hold no tools/listeners and are reaped by their parent. The test
# requires no live member of an owned group, including orphan descendants.
if ps -eo pgid=,stat= | awk -v g="$pid" '$1==g && $2 !~ /^Z/ {found=1} END {exit !found}'; then
echo "R7 cleanup failed: group $pid still has a live process" >&2
result=1; cleanup_failed=1
fi
done < "$R7_ROOT/groups"
while IFS= read -r port; do
[ -n "$port" ] || continue
if ! listeners="$(ss -H -ltn "sport = :$port")"; then
echo "R7 cleanup failed: cannot inspect port $port" >&2
result=1; cleanup_failed=1
elif [ -n "$listeners" ]; then
echo "R7 cleanup failed: port $port still listens" >&2
result=1; cleanup_failed=1
fi
done < "$R7_ROOT/ports"
while IFS= read -r socket; do
[ -n "$socket" ] || continue
if ! listeners="$(ss -H -lx)"; then
echo "R7 cleanup failed: cannot inspect socket $socket" >&2
result=1; cleanup_failed=1
elif printf '%s\n' "$listeners" | rg -F -- "$socket" >/dev/null; then
echo "R7 cleanup failed: socket $socket still listens" >&2
result=1; cleanup_failed=1
fi
done < "$R7_ROOT/sockets"
if listeners="$(ss -H -lx)"; then
if printf '%s\n' "$listeners" | rg -F -- "$R7_ROOT/" >/dev/null; then
echo 'R7 cleanup failed: another scratch Unix listener remains' >&2
result=1; cleanup_failed=1
fi
else
result=1; cleanup_failed=1
fi
if [ "$result" -ne 0 ]; then
for raw in "$R7_ROOT"/*.log; do
[ -f "$raw" ] || continue
echo "R7 diagnostic: ${raw##*/}" >&2
tail -40 "$raw" >&2
done
fi
rm -rf -- "$R7_ROOT"
if [ -e "$R7_ROOT" ]; then
echo 'R7 cleanup failed: scratch directory remains' >&2
result=1; cleanup_failed=1
fi
if [ "$cleanup_failed" -eq 0 ]; then echo 'R7 cleanup: no child groups, ports or sockets remain; no units created'; fi
exit "$result"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
# No host configuration, preload hooks, credentials or agent env in fixtures.
# The ancestry stand-in lives only in federation-host.mjs, per sage-3625.
timeout -k 10 210 env -i PATH="$PATH" HOME="$R7_ROOT/home" TMPDIR="$R7_ROOT/tmp" \
MOSAIC_R7_ROOT="$R7_ROOT" MOSAIC_R7_INTERRUPT="$R7_INTERRUPT" \
node --test --test-concurrency=1 packages/cli/tests/federation-acceptance.test.mjs &
R7_DRIVER=$!
wait "$R7_DRIVER"
R7_DRIVER=