Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e25ef0373c |
No files matched your search
@@ -5411,10 +5411,3 @@ 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.
|
||||
@@ -0,0 +1,124 @@
|
||||
# Row 217 (Case 2 C2-06): build notes
|
||||
|
||||
Brief: docs/plans/2026-10-20_case2-rows.md, section C2-06, at base 45dca69f6.
|
||||
Builder: Launchpad until 2026-10-23 19:09Z. Then Mos moved coding to Codex seats (mos-1500, Q245) and
|
||||
the row went to Beakley on beakley/row217.
|
||||
|
||||
## State at handoff
|
||||
|
||||
All code in this commit is UNTESTED. No node, no test and no mutant has run on it. No test file is written.
|
||||
`packages/harness/src/runner.mjs` is not started.
|
||||
|
||||
Done, untested:
|
||||
- `packages/library/src/limits.mjs` (new, pure): `REPLY_LIMITS`, `resolveReplyLimits(read)` gives
|
||||
`{requireCitation, citationsPerReply, fallback, unreadable}`, `checkReplyLimits(value)` gives a clone or null.
|
||||
An unreadable business file forces `requireCitation` true. A key falls back to its default when
|
||||
`replyLimits` is not an object or the key holds an invalid value.
|
||||
- `packages/library/src/guard.mjs` (new): `GUARD_CODES` in design 5.2's table order, `FIXED_TEXTS`,
|
||||
`WEB_CITE_DEFINITIONS`, `REASON_MAX` (300), `GuardError`, `parseReply`, `checkReply`, `libraryReader`,
|
||||
`lastCommits`, `guardReply`.
|
||||
- `packages/library/src/index.mjs`: exports for both.
|
||||
- `packages/business/src/business.mjs`: `replyLimits` is a known top key. The validated output doesn't carry it.
|
||||
- `packages/business/src/organization.mjs`: the M11 fence `replyLimitsFence(old, document, actor)` compares
|
||||
against `old.human`, refuses the tester and any `<definition>.<instance>` of `old.roles`, and is called for
|
||||
both `document` and `written`. `setReplyLimits` and `readRaw` are exported.
|
||||
- `packages/bus/src/broker.mjs`: the guard call, the page store, the check event, `library.limits.set`,
|
||||
`library.status` gains `replyLimits`, `role.claim` gains `library`, `thread.messages` gains `check` and `last`.
|
||||
- `packages/bus/src/server.mjs`, `client.mjs`: `BusError.reason` (sage-3599).
|
||||
- `packages/bus/src/runtime.mjs`: guard deps and the `library.limits.set` dispatch.
|
||||
- `packages/cli/src/limits.mjs`: `limits replies --file LIMITS`.
|
||||
|
||||
Left:
|
||||
- `packages/harness/src/runner.mjs`: the C2-07 runner work (see "Runner plan" below).
|
||||
- `packages/cli/README.md`: a line for `limits replies`.
|
||||
- Every test in the brief and the rulings below, then the mutants.
|
||||
- The rebases onto rows 283, 216 and 285, with the drift checks below.
|
||||
- This file's gate results and the manifest.
|
||||
|
||||
## Rulings in force
|
||||
|
||||
sage-3595:
|
||||
1. Supporting scope: a stub classify test now. Before in-review, rebase onto row 283's landing
|
||||
(honker/row283-build bd2c1c4ea when ruled) and add two real-fixture tests: a supporting cite with
|
||||
`as: "canon"` refuses `playground-cited-as-canon`, and the stored check result names `supporting`.
|
||||
2. runtime.mjs is approved for the guard wiring and the one `library.limits.set` branch. List its hunks here,
|
||||
name it in the manifest, and drift-check it after the 216 and 285 rebases.
|
||||
3. The check result is an `action.allowed` event with `action: "message.checked"`. The events kind CHECK at
|
||||
`packages/bus/src/migrations/3b.sql:9` has no `message.checked`, so a new kind would be a schema step.
|
||||
The event and the message INSERT share one transaction: a mutant that splits them must fail.
|
||||
4. readLibrary at the cited commit stands in for the C2-05a index. On the 216 rebase, compare 216's index
|
||||
set with readLibrary's for the fixture and record the result here.
|
||||
5. The retry is for a library business only. Tests: a JSON body between 32,768 characters and 64 KiB gets
|
||||
the retry; outside a library business nothing changes.
|
||||
6. Old `role.claim` pins stay as they are.
|
||||
7. `render.mjs:6` and `render.test.mjs:58` belong to row 225. Leave them.
|
||||
8. `thread.messages` rows that carry `check` get `last` per path cite: the file's last commit at the served
|
||||
tip, one git call per distinct path per read. Test with a fixture file committed after the cite.
|
||||
|
||||
sage-3599: `BusError` gets an optional `reason`. Only the citation guard's refusals set it. The envelope
|
||||
carries `reason` only when it is set. It is the guard's own text, never a quote, file text, url or a path
|
||||
other than the cited one, at most 300 characters. Tests: a guard refusal reaches the runner with code and
|
||||
reason; a refusal without a reason has an envelope byte-identical to today's, checked against an existing
|
||||
pin; a mutant that drops the client.mjs pass-through fails.
|
||||
|
||||
sage-3602: store `file` per path cite from the same git log call as the trailer. Test with a cited commit
|
||||
that didn't touch the file, so `file` differs from the cited commit, and `last` equals `file`.
|
||||
|
||||
## The check-result contract with row 225 (launchpad-0118, launchpad-0120, morgana-0200, sage-3596)
|
||||
|
||||
- Stored on the `message.checked` event: `{citations: [...]}` in body order.
|
||||
- A path cite: `{scope, agent, file}`. `scope` is the classify scope. `agent` is the cite's file's last
|
||||
commit's `Mosaic-Agent` trailer at the cited commit, or null. `file` is that commit's 40-hex id, or null.
|
||||
- A url cite: `{scope: "web", agent: null}`.
|
||||
- On `thread.messages`, each path cite gains `last`: the file's last commit at the served tip (40-hex or null).
|
||||
The Console compares `last` with `file`.
|
||||
- Rows without `check` are unchanged. No `changed` boolean. The webui passes rows through unchanged.
|
||||
|
||||
## Hunks outside the brief's file list
|
||||
|
||||
- `packages/bus/src/runtime.mjs`: imports `setReplyLimits`; the library deps gain `guard`, `replyLimits`
|
||||
(organizationWriter `readRaw`, or `{readable: false}` with no organization), `last` and `GuardError`;
|
||||
the organization dispatch gains `library.limits.set`.
|
||||
- `packages/bus/src/server.mjs`: a `refusal(e, other)` helper above serve's doc comment builds every
|
||||
BusError envelope and adds `reason` only when set. The synchronous request path accepts a Promise result
|
||||
(the guard is async), with a 120 s timeout, and releases `own` when it settles.
|
||||
- `packages/bus/src/client.mjs`: `new BusError(r.error, reason)` with `reason` a string cut at 300.
|
||||
|
||||
## Decisions and their tradeoffs
|
||||
|
||||
- The guard runs async outside the SQL transaction, then re-enters request() through `#guardPass`, so
|
||||
message.send's insert and the check event stay one synchronous transaction.
|
||||
- The guard runs before message.send's own checks, so a guard code can come before an authority code.
|
||||
- The check event id is derived from the message id (a UUID-shaped sha256), so `thread.messages` finds it
|
||||
through the unique index on `events.id`.
|
||||
- With no organization configured, `replyLimits` reads as unreadable, so it fails closed.
|
||||
- With more than one library project, the guard uses the first in the business file's order.
|
||||
- A library business without guard deps refuses guarded sends with `project-not-configured`.
|
||||
- The page store is per cap: 20 pages of 200 KB, kept only in a library business, dropped at session
|
||||
delete or disconnect and at run end.
|
||||
- The CLI `replies` verb reads the revision itself from `limits`.
|
||||
- Guard reasons never carry a url or a quote (sage-3599).
|
||||
|
||||
## Runner plan (not built)
|
||||
|
||||
`role.claim`'s reply is discarded at `runner.mjs:462`; keep it and read `library === true`. In a library
|
||||
business, at the reply site (`runner.mjs:582-587`):
|
||||
- Fixed texts `(the session gave no answer)` and `(the turn failed)`; the reason stays in the run log.
|
||||
A fixed text goes with no note, and `unslop.sent()` is not called, so the note waits.
|
||||
- A body that parses as a JSON object with a string `text` gets `unslop.note()` appended to `text`, is
|
||||
serialized again and is never clipped. A plain body keeps `clip(body) + unslop.note()`.
|
||||
- On a refusal whose code is in `GUARD_CODES`, or `request-too-large` from the client, or the broker's
|
||||
`invalid-request` from `string(a.body, 32768)` for a body over 32,768 characters (ruling 5; the runner
|
||||
can tell it by the body length it sent), run one new turn in the same
|
||||
session naming the code and `err.reason`, or the 64 KiB limit. Bracket it with usage like other turns.
|
||||
- After the retry: a table code sends `(no answer passed the citation check)`, too large sends
|
||||
`(the answer was too long to send)`. One retry per answer, whatever the cause.
|
||||
- `runTurn`'s output buffer holds up to 4 * BODY_MAX characters, so a reply over 64 KiB can reach the send.
|
||||
- Outside a library business nothing changes.
|
||||
|
||||
## Mutants planned
|
||||
|
||||
- The M11 writer check removed.
|
||||
- `document.human` compared instead of `old.human`.
|
||||
- The check event split from the insert transaction.
|
||||
- The client.mjs reason pass-through dropped.
|
||||
@@ -1,266 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,67 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,9 +0,0 @@
|
||||
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
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/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')
|
||||
@@ -1,87 +0,0 @@
|
||||
# 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.
|
||||
@@ -1593,4 +1593,3 @@ 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.
|
||||
+134
-7
@@ -203,15 +203,26 @@ const canonical = (x) =>
|
||||
Number.isFinite(Date.parse(x)) &&
|
||||
new Date(x).toISOString() === x;
|
||||
export class BusError extends Error {
|
||||
constructor(code) {
|
||||
// C2-06 (sage-3599): only the citation guard's refusals carry a reason, and the envelope passes it on.
|
||||
constructor(code, reason) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
if (reason !== undefined) this.reason = reason;
|
||||
}
|
||||
}
|
||||
const fail = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
||||
// C2-06 (design 5.2, B7): the page store keeps a session's last 20 broker fetches, 200 KB of text each.
|
||||
const PAGE_COUNT = 20;
|
||||
const PAGE_BYTES = 200 * 1024;
|
||||
// C2-06: the guard's check event takes an id derived from the message's, so a thread read finds it
|
||||
// through the unique index on events.id (there is none on subject).
|
||||
const checkEventId = (mid) => {
|
||||
const h = createHash('sha256').update('message.checked:' + mid).digest('hex');
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
|
||||
};
|
||||
const text = (x, max = 4096) => typeof x === 'string' && x.length > 0 && x.length <= max && !x.includes('\0');
|
||||
const id = (x) => text(x, 160) && /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/.test(x);
|
||||
function keys(x, allowed, required = []) {
|
||||
@@ -389,6 +400,11 @@ export class Broker {
|
||||
#credentialVerbs;
|
||||
#project;
|
||||
#library;
|
||||
// C2-06 (design 5.2, B7): cap -> the session's kept web.fetch pages, in a library business only.
|
||||
// In memory, dropped with the session.
|
||||
#pages = new Map();
|
||||
// C2-06: { s, check } while a guarded message.send or a thread.messages re-enters request().
|
||||
#guardPass = null;
|
||||
#usage;
|
||||
#limits;
|
||||
#harnessOn;
|
||||
@@ -1403,7 +1419,10 @@ export class Broker {
|
||||
this.#launcherReleased.delete(key);
|
||||
this.#accessRevoked.delete(key);
|
||||
for (const [cap, s] of this.#sessions)
|
||||
if (!s.human && !s.reader && s.business === record.business && s.run === record.run) this.#sessions.delete(cap);
|
||||
if (!s.human && !s.reader && s.business === record.business && s.run === record.run) {
|
||||
this.#sessions.delete(cap);
|
||||
this.#pages.delete(cap);
|
||||
}
|
||||
return this.#store.transaction(() => {
|
||||
const h = this.#own(session);
|
||||
const released = h?.op === 'claim' && h.holder_run === session.run;
|
||||
@@ -1485,6 +1504,7 @@ export class Broker {
|
||||
}
|
||||
disconnect(cap) {
|
||||
this.#sessions.delete(cap);
|
||||
this.#pages.delete(cap);
|
||||
}
|
||||
identity(cap) {
|
||||
const s = this.#session(cap);
|
||||
@@ -4666,10 +4686,19 @@ export class Broker {
|
||||
return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) });
|
||||
if (LIBRARY_VERBS.includes(request.verb))
|
||||
return this.#requestLibrary(s, { verb: request.verb, args: structuredClone(args) });
|
||||
// C2-06 (design 5.2): an agent's message to the human in a library business passes the citation
|
||||
// guard first, outside any transaction, and thread.messages adds each path cite's `last`. Both
|
||||
// then re-enter request() for the synchronous path below.
|
||||
if (this.#guardPass?.s !== s) {
|
||||
if (request.verb === 'message.send' && this.#guarded(s, args.to))
|
||||
return this.#guardedSend(cap, s, request, structuredClone(args));
|
||||
if (request.verb === 'thread.messages' && this.#libraryProject(s.business) && this.#library?.last)
|
||||
return this.#withLast(cap, s, request);
|
||||
}
|
||||
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
|
||||
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
|
||||
return this.#chiefLifecycle(s, request.verb, structuredClone(args));
|
||||
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set"].includes(request.verb)) {
|
||||
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb)) {
|
||||
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
|
||||
// file lists organization.change. It takes the same keys, runs the same write path and
|
||||
// logs the same receipt; only the actor differs: the role definition and instance.
|
||||
@@ -4715,6 +4744,12 @@ export class Broker {
|
||||
keys(args, VETO_KEYS, VETO_KEYS);
|
||||
this.#human(s);
|
||||
this.#mayHuman(s, "veto.set", args.name);
|
||||
}
|
||||
// C2-06 M11: the human's replyLimits verb. The tester is refused by the gate above and an agent
|
||||
// session by human-required; the writer's fence refuses any other actor.
|
||||
else if (request.verb === "library.limits.set") {
|
||||
keys(args, ["revision", "replyLimits"], ["revision", "replyLimits"]);
|
||||
this.#human(s);
|
||||
} else {
|
||||
keys(args, ORGANIZATION_KEYS, ["revision"]);
|
||||
// Row 194 (D2.4, B7): the chief can't change a kept instance's model, as that replaces it at
|
||||
@@ -5138,7 +5173,14 @@ export class Broker {
|
||||
const context = { business: this.#business(s.business), project: args.project };
|
||||
let result;
|
||||
if (verb === 'library.file') result = await this.#library.readFile({ ...context, path: args.path });
|
||||
else if (verb === 'library.status') result = await this.#library.status(context);
|
||||
else if (verb === 'library.status') {
|
||||
result = await this.#library.status(context);
|
||||
// C2-06: the reply limits in force, read from the file now, and the keys that fell back.
|
||||
if (this.#library.replyLimits) {
|
||||
const { requireCitation, citationsPerReply, fallback, unreadable } = this.#library.replyLimits(s.business);
|
||||
result = { ...result, replyLimits: { requireCitation, citationsPerReply, fallback, problem: unreadable ? 'reply-limits-unreadable' : null } };
|
||||
}
|
||||
}
|
||||
else {
|
||||
// A fetch that did not move served still answers with the code, so the refusal is recorded.
|
||||
const { state, code } = await this.#library.sync(context, { accept: args.accept ?? null });
|
||||
@@ -5152,6 +5194,78 @@ export class Broker {
|
||||
throw this.#refused(s, Failure && error instanceof Failure ? new BusError(error.code) : error, request);
|
||||
}
|
||||
}
|
||||
// C2-06: the business's library project, the first in file order, or null.
|
||||
#libraryProject(business) {
|
||||
return Object.entries(this.#business(business).projects).find(([, p]) => p.kind === 'library')?.[0] ?? null;
|
||||
}
|
||||
// C2-06 (design 5.2): every agent session's message to the human in a business with a library project
|
||||
// is guarded, whatever the sender's definition.
|
||||
#guarded(s, to) {
|
||||
return to === 'human' && !s.human && !s.tester && !s.user && !s.reader && !s.console && this.#libraryProject(s.business) !== null;
|
||||
}
|
||||
#reenter(cap, s, request, check) {
|
||||
this.#guardPass = { s, check };
|
||||
try {
|
||||
return this.request(cap, request);
|
||||
} finally {
|
||||
this.#guardPass = null;
|
||||
}
|
||||
}
|
||||
// As #requestProject: the git reads run outside any SQL transaction. A guard refusal is recorded with
|
||||
// its code and reaches the caller with the guard's reason; the send then runs, and stores the check
|
||||
// result in its own transaction.
|
||||
async #guardedSend(cap, s, request, args) {
|
||||
let check;
|
||||
try {
|
||||
this.#store.transaction(() => this.#agent(s));
|
||||
if (!this.#library?.guard || !this.#library?.replyLimits) fail('project-not-configured');
|
||||
if (typeof args.body !== 'string') fail('invalid-request');
|
||||
const business = this.#business(s.business);
|
||||
check = await this.#library.guard({ business, project: this.#libraryProject(s.business) }, args.body, {
|
||||
limits: this.#library.replyLimits(s.business),
|
||||
definition: business.roles[s.role]?.definition ?? null,
|
||||
pages: this.#pages.get(cap) ?? [],
|
||||
});
|
||||
} catch (e) {
|
||||
const Guard = this.#library?.GuardError;
|
||||
throw this.#refused(s, Guard && e instanceof Guard ? new BusError(e.code, e.reason) : e, request);
|
||||
}
|
||||
return this.#reenter(cap, s, 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) {
|
||||
@@ -5235,6 +5349,7 @@ export class Broker {
|
||||
throw refuse(new BusError('web-failed'));
|
||||
}
|
||||
complete(out.summary);
|
||||
if (verb === 'web.fetch') this.#keepPage(cap, s, args.url, out.result);
|
||||
return { ...out.result, untrusted: true, notice: WEB_UNTRUSTED, source: verb };
|
||||
}
|
||||
// Row 210 (#1707, design D5): host.run, one sudo command on a registered host over ssh, for a
|
||||
@@ -5361,7 +5476,7 @@ export class Broker {
|
||||
by,
|
||||
);
|
||||
this.#routeSession(s.session);
|
||||
return { role: s.role, run: s.run, thread: s.thread };
|
||||
return { role: s.role, run: s.run, thread: s.thread, ...(this.#libraryProject(s.business) ? { library: true } : {}) };
|
||||
}
|
||||
if (
|
||||
this.#store.get(
|
||||
@@ -5385,7 +5500,8 @@ export class Broker {
|
||||
by,
|
||||
);
|
||||
this.#routeWaiting(s.business, s.role);
|
||||
return { role: s.role, run: s.run };
|
||||
// C2-06 (C2-07): the runner's switch to the library texts and retry. Absent elsewhere.
|
||||
return { role: s.role, run: s.run, ...(this.#libraryProject(s.business) ? { library: true } : {}) };
|
||||
}
|
||||
case 'role.release': {
|
||||
keys(a, []);
|
||||
@@ -5749,6 +5865,10 @@ export class Broker {
|
||||
if (this.#userThread(s) && to === 'human') fail('user-thread-human');
|
||||
this.#checkMessaging(s.business, this.#sender(s), testerThread && to === 'tester' ? 'human' : to);
|
||||
string(a.body, 32768);
|
||||
// C2-06: a guarded send is stored only through #guardedSend's pass, with its check result.
|
||||
const guarded = this.#guarded(s, to);
|
||||
if (guarded && this.#guardPass?.s !== s) fail('storage-refused');
|
||||
const check = guarded ? this.#guardPass.check : null;
|
||||
if (
|
||||
a.class !== undefined &&
|
||||
![
|
||||
@@ -5795,6 +5915,9 @@ export class Broker {
|
||||
a.corrects ?? null,
|
||||
a.body,
|
||||
);
|
||||
// C2-06: the stored check result shares the message's transaction (sage-3595 item 3). No new
|
||||
// event kind, which would be a schema step: the events kind CHECK is migrations/3b.sql:9.
|
||||
if (check) this.#event(s, 'action.allowed', { action: 'message.checked', message: mid, check }, mid, checkEventId(mid));
|
||||
const cancelled = this.#route(s.business, mid, to, thread);
|
||||
const request = s.human
|
||||
? this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid)
|
||||
@@ -6414,11 +6537,15 @@ export class Broker {
|
||||
)?.seq;
|
||||
if (after === undefined) fail('message-not-found');
|
||||
}
|
||||
// C2-06 (row 225, morgana-0198): a row the citation guard checked carries the stored result.
|
||||
return this.#store.all(
|
||||
`SELECT m.* FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=? AND m.seq>? ORDER BY m.seq LIMIT ${limit}`,
|
||||
session,
|
||||
after,
|
||||
);
|
||||
).map((m) => {
|
||||
const e = this.#store.get("SELECT body FROM events WHERE id=? AND kind='action.allowed' AND subject=?", checkEventId(m.id), m.id);
|
||||
return e ? { ...m, check: JSON.parse(e.body).check } : m;
|
||||
});
|
||||
}
|
||||
// Row 206 (193b2, #1704, design 5.3): the owner of a thread shares it read-only, or ends a share.
|
||||
// A user's collaborator grant must still cover the thread, and a user target needs a live grant
|
||||
|
||||
@@ -41,7 +41,8 @@ export class Client {
|
||||
const r = JSON.parse(input.toString('utf8'));
|
||||
if (r.ok === true) end(null, r.result);
|
||||
else if (r.ok === false && typeof r.error === 'string' && /^[a-z-]{1,64}$/.test(r.error))
|
||||
end(new BusError(r.error));
|
||||
// C2-06 (sage-3599): the citation guard's reason, when the envelope has one.
|
||||
end(new BusError(r.error, typeof r.reason === 'string' ? r.reason.slice(0, 300) : undefined));
|
||||
else end(new BusError('invalid-response'));
|
||||
} catch {
|
||||
end(new BusError('invalid-response'));
|
||||
|
||||
@@ -14,7 +14,7 @@ import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working
|
||||
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
|
||||
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
|
||||
import { instanceSettings } from '../../business/src/instance-settings.mjs';
|
||||
import { organizationWriter, OrganizationError } from '../../business/src/organization.mjs';
|
||||
import { organizationWriter, OrganizationError, setReplyLimits } from '../../business/src/organization.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { loadBusiness } from '../../business/src/business.mjs';
|
||||
import { loadRole } from '../../business/src/role.mjs';
|
||||
@@ -305,6 +305,13 @@ export async function startBroker({
|
||||
status: context => libraryModule.libraryStatus({ ...context, dataRoot }),
|
||||
sync: (context, { accept }) => processing.work(() => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept })),
|
||||
LibraryError: libraryModule.LibraryError,
|
||||
// C2-06: the citation guard, the reply limits read from the business file at each check (none
|
||||
// configured reads as unreadable, so requireCitation is true), and thread.messages' `last`.
|
||||
guard: (context, body, options) => libraryModule.guardReply({ ...context, dataRoot }, body, options),
|
||||
replyLimits: id => libraryModule.resolveReplyLimits(organization
|
||||
? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }).readRaw(id) : { readable: false }),
|
||||
last: (context, paths) => libraryModule.lastCommits({ ...context, dataRoot }, paths),
|
||||
GuardError: libraryModule.GuardError,
|
||||
},
|
||||
project: {
|
||||
readSpec: context => projectModule.readSpec({ ...context, dataRoot }),
|
||||
@@ -334,6 +341,8 @@ export async function startBroker({
|
||||
else if(verb==='plugins.set')result=plugins.set(id,args,actor);
|
||||
// Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer.
|
||||
else if(verb==='veto.set')result=instances.veto(id,args,actor);
|
||||
// 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});
|
||||
|
||||
@@ -7,6 +7,11 @@ const LIMIT = 65536;
|
||||
// Row 148: a document save may carry up to 1 MiB of text. Only a request that names that verb near
|
||||
// its start (the proof object comes first) may pass LIMIT; the parsed verb is checked again below.
|
||||
const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.', '\\.')}"`);
|
||||
// The refusal envelope. C2-06 (sage-3599): `reason` is added only when the error carries one, which
|
||||
// only the citation guard's refusals do, so every other envelope is byte-identical to before.
|
||||
const refusal = (e, other) => e instanceof BusError
|
||||
? { ok: false, error: e.code, ...(typeof e.reason === 'string' ? { reason: e.reason } : {}) }
|
||||
: { ok: false, error: other };
|
||||
// One request per connection. There is deliberately no reconnect/retry or SQL verb.
|
||||
// `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it.
|
||||
// `web` is the trusted web service (web.mjs), or a function from a business id to one (or null); only
|
||||
@@ -15,6 +20,7 @@ const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.
|
||||
// host-not-configured.
|
||||
// A request carries exactly one of cap, human (row S4), tester (row 110), user (FED R3) and console
|
||||
// (row 252) proof.
|
||||
|
||||
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, authenticateUser = null, authenticateConsole = null, peerReader = null, timeout = 5000, tasks = null, web = null, host = null }) {
|
||||
try {
|
||||
lstatSync(path);
|
||||
@@ -96,7 +102,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
.requestTask(cap, { verb: r.verb, args: r.args ?? {} }, tasks.handle)
|
||||
.then(
|
||||
(result) => finish({ ok: true, result }),
|
||||
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'adapter-failed' }),
|
||||
(e) => finish(refusal(e, 'adapter-failed')),
|
||||
)
|
||||
.finally(() => {
|
||||
if (own) release();
|
||||
@@ -114,7 +120,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
.requestWeb(cap, { verb: r.verb, args: r.args ?? {} }, service, { onAdmit: () => socket.setTimeout(service.timeout) })
|
||||
.then(
|
||||
(result) => finish({ ok: true, result }),
|
||||
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'web-failed' }),
|
||||
(e) => finish(refusal(e, 'web-failed')),
|
||||
)
|
||||
.finally(() => {
|
||||
if (own) release();
|
||||
@@ -130,7 +136,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
.requestHost(cap, { verb: r.verb, args: r.args ?? {} }, host, { onAdmit: (ms) => socket.setTimeout(ms + 10000) })
|
||||
.then(
|
||||
(result) => finish({ ok: true, result }),
|
||||
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'host-failed' }),
|
||||
(e) => finish(refusal(e, 'host-failed')),
|
||||
)
|
||||
.finally(() => {
|
||||
if (own) release();
|
||||
@@ -153,14 +159,26 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
socket.setTimeout(120_000);
|
||||
Promise.resolve().then(() => broker.request(cap, { verb: r.verb, args: r.args ?? {} })).then(
|
||||
result => { if (!socket.destroyed) finish({ ok: true, result }); },
|
||||
e => { if (!socket.destroyed) finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' }); },
|
||||
e => { if (!socket.destroyed) finish(refusal(e, 'invalid-envelope')); },
|
||||
).finally(() => { if (own) release(); });
|
||||
return;
|
||||
}
|
||||
const result = broker.request(cap, { verb: r.verb, args: r.args ?? {} });
|
||||
// C2-06: a guarded message.send and a library business's thread.messages read Git first, so
|
||||
// the broker answers them with a promise. Every other verb stays synchronous.
|
||||
if (result instanceof Promise) {
|
||||
const own = transient;
|
||||
transient = null;
|
||||
socket.setTimeout(120_000);
|
||||
result.then(
|
||||
value => { if (!socket.destroyed) finish({ ok: true, result: value }); },
|
||||
e => { if (!socket.destroyed) finish(refusal(e, 'invalid-envelope')); },
|
||||
).finally(() => { if (own) release(); });
|
||||
return;
|
||||
}
|
||||
finish({ ok: true, result });
|
||||
} catch (e) {
|
||||
finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' });
|
||||
finish(refusal(e, 'invalid-envelope'));
|
||||
} finally {
|
||||
if (transient) release();
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
||||
// How long a thread's session stays up with no turn before the launcher stops
|
||||
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
|
||||
export const THREAD_IDLE_SECONDS = 900;
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired"];
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits"];
|
||||
|
||||
// The config directory: next to the system config, $MOSAIC_CONFIG or
|
||||
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { join, dirname } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { businessFilePath, validateBusinessDocument } from "./business.mjs";
|
||||
import { requireId, sha256, canonicalJson } from "./util.mjs";
|
||||
import { checkReplyLimits } from "../../library/src/limits.mjs";
|
||||
|
||||
export class OrganizationError extends Error {
|
||||
constructor(code, message = code) {
|
||||
@@ -80,6 +81,33 @@ function changes(old, next, path = "") {
|
||||
}];
|
||||
}
|
||||
|
||||
// C2-06 M11: replyLimits is the human's alone. Any setter that changes it refuses unless the actor is
|
||||
// the business's human as the file stood before the write, and no agent or the tester under that
|
||||
// name. Compared as M9 compares projects, on the document and again on what lands on disk.
|
||||
function replyLimitsFence(old, document, actor) {
|
||||
if (canonicalJson(document?.replyLimits) === canonicalJson(old.replyLimits)) return;
|
||||
const agents = Object.entries(old.roles ?? {}).map(([instance, entry]) => `${entry?.definition}.${instance}`);
|
||||
if (actor === old.human && actor !== "tester" && !agents.includes(actor)) return;
|
||||
// The reason names each changed key and its value in force (the brief's M11).
|
||||
const plain = v => v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
const before = old.replyLimits, after = document?.replyLimits;
|
||||
const named = plain(before) && plain(after)
|
||||
? [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(k => canonicalJson(before[k]) !== canonicalJson(after[k]))
|
||||
.map(k => `replyLimits.${k} is ${canonicalJson(before[k]) ?? "unset"}`)
|
||||
: [`replyLimits is ${canonicalJson(before) ?? "unset"}`];
|
||||
fail("organization-reply-limits-human-only", `${named.join(", ").slice(0, 240)}; only the human changes it`);
|
||||
}
|
||||
|
||||
// C2-06: library.limits.set, the human's verb. It replaces the whole replyLimits object; a key left out
|
||||
// is its default. An unknown key or a value outside the table refuses invalid-request, and the write's
|
||||
// M11 fence refuses any actor but the human.
|
||||
export function setReplyLimits(writer, id, args, actor) {
|
||||
const replyLimits = checkReplyLimits(args?.replyLimits);
|
||||
if (replyLimits === null || typeof args?.revision !== "string") fail("invalid-request", "replyLimits takes requireCitation and citationsPerReply only");
|
||||
const { document } = writer.read(id);
|
||||
return writer.write(id, { ...document, replyLimits }, { revision: args.revision, actor });
|
||||
}
|
||||
|
||||
export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
const evidence = join(dir, "businesses", ".history");
|
||||
const logFile = id => join(evidence, id, "changes.jsonl");
|
||||
@@ -93,6 +121,19 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
return { document, revision: sha256(bytes) };
|
||||
}
|
||||
|
||||
// C2-06: the citation guard reads replyLimits fresh on every check, from the raw file, so a business
|
||||
// file that fails validation still yields its limits. { readable: false } when the file is missing,
|
||||
// unreadable, not JSON or not an object.
|
||||
function readRaw(id) {
|
||||
try {
|
||||
const document = JSON.parse(regular(businessFilePath(id, dir)));
|
||||
return document !== null && typeof document === "object" && !Array.isArray(document)
|
||||
? { readable: true, document } : { readable: false };
|
||||
} catch {
|
||||
return { readable: false };
|
||||
}
|
||||
}
|
||||
|
||||
function withLock(id, fn) {
|
||||
const parent = join(dir, "businesses");
|
||||
directory(evidence);
|
||||
@@ -191,6 +232,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
// mapping, including library playgrounds and hybrid universes.
|
||||
if (canonicalJson(document?.projects) !== canonicalJson(old.projects))
|
||||
fail('organization-projects-protected');
|
||||
replyLimitsFence(old, document, actor);
|
||||
validateBusinessDocument(document, file, { rolesDir });
|
||||
const bytes = JSON.stringify(document, null, 2) + "\n";
|
||||
// Check and log exactly what lands on disk, for every setter: a value JSON
|
||||
@@ -198,6 +240,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
const written = JSON.parse(bytes);
|
||||
if (canonicalJson(written.projects) !== canonicalJson(old.projects))
|
||||
fail('organization-projects-protected');
|
||||
replyLimitsFence(old, written, actor);
|
||||
validateBusinessDocument(written, file, { rolesDir });
|
||||
const after = sha256(bytes);
|
||||
const transaction = randomUUID();
|
||||
@@ -245,5 +288,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
});
|
||||
}
|
||||
|
||||
return { logFile, read, write, record, recover: id => withLock(id, () => recover(id)) };
|
||||
return { logFile, read, readRaw, write, record, recover: id => withLock(id, () => recover(id)) };
|
||||
}
|
||||
@@ -7,9 +7,10 @@ import { CliError } from "./errors.mjs";
|
||||
import { migrateLaunch } from "../../business/src/migrate-launch.mjs";
|
||||
|
||||
// Row 155 (#1649, Q169): a PATCH may carry `assistants`, the assistant's vars, tracker and definition.
|
||||
// C2-06 M11: `replies --file LIMITS` replaces replyLimits with the file's object, at the current revision.
|
||||
export const LIMITS_USAGE = "mosaic limits [--business ID] [--json] [--tester] | set --file PATCH [--business ID] | " +
|
||||
"reset PATH... [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " +
|
||||
"PATCH keys: revision, limits, models, instances, reset, assistants";
|
||||
"reset PATH... [--business ID] | replies --file LIMITS [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " +
|
||||
"PATCH keys: revision, limits, models, instances, reset, assistants; LIMITS keys: requireCitation, citationsPerReply";
|
||||
|
||||
export const DAILY_NOTE = "Daily quotas count only this stack's own use of the account, not its use anywhere else.";
|
||||
|
||||
@@ -67,8 +68,9 @@ export async function limitsCommand(args, io, callFor) {
|
||||
io.stdout.write(JSON.stringify(converted, null, 2) + "\n");
|
||||
return;
|
||||
}
|
||||
if (!["view", "set", "reset"].includes(verb) || values["--models"] || values["--roles-dir"] ||
|
||||
(verb !== "set" && values["--file"]) || (verb === "set" && !values["--file"]) ||
|
||||
const fileVerb = verb === "set" || verb === "replies";
|
||||
if (!["view", "set", "reset", "replies"].includes(verb) || values["--models"] || values["--roles-dir"] ||
|
||||
(!fileVerb && values["--file"]) || (fileVerb && !values["--file"]) ||
|
||||
(verb === "reset" && parsed.positional.length < 2)) {
|
||||
throw new CliError(LIMITS_USAGE, 4);
|
||||
}
|
||||
@@ -78,6 +80,11 @@ export async function limitsCommand(args, io, callFor) {
|
||||
// result's `tokens` lists each token limit: counted, not-enforced or check-failed.
|
||||
let result;
|
||||
if (verb === "set") result = await call("limits.set", JSON.parse(readFileSync(values["--file"], "utf8")));
|
||||
else if (verb === "replies") {
|
||||
const replyLimits = JSON.parse(readFileSync(values["--file"], "utf8"));
|
||||
const { revision } = await call("limits", {});
|
||||
result = await call("library.limits.set", { revision, replyLimits });
|
||||
}
|
||||
else if (verb === "reset") {
|
||||
const { revision } = await call("limits", {});
|
||||
result = await call("limits.set", { revision, reset: [parsed.positional.slice(1)] });
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
// 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
@@ -1,67 +0,0 @@
|
||||
// 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 });
|
||||
@@ -1,7 +0,0 @@
|
||||
// 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 });
|
||||
@@ -0,0 +1,197 @@
|
||||
// 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 });
|
||||
}
|
||||
@@ -8,3 +8,5 @@ export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs';
|
||||
export { survey, formatSurvey, surveyMain } from './survey.mjs';
|
||||
export { SYNC_STATE_FILE, mirrorPath, ensureMirror, withLibraryLock, servedCommit, readSyncState, libraryStatus, readLibraryFile } from './mirror.mjs';
|
||||
export { syncCommand, sync, ASKPASS, SLOT, SYNC_INTERVAL_MS } from './sync.mjs';
|
||||
export { REPLY_LIMITS, resolveReplyLimits, checkReplyLimits } from './limits.mjs';
|
||||
export { GUARD_CODES, FIXED_TEXTS, WEB_CITE_DEFINITIONS, REASON_MAX, GuardError, parseReply, checkReply, libraryReader, guardReply, lastCommits } from './guard.mjs';
|
||||
@@ -0,0 +1,41 @@
|
||||
// C2-06 (design 5.2): the business file's replyLimits, read by the citation guard and written only
|
||||
// by the human's library.limits.set. Pure, so the business package's organization writer imports it.
|
||||
|
||||
export const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
// The replyLimits keys, their defaults and their valid values. A key is read only when Object.hasOwn
|
||||
// finds it and its value passes `valid`; anything else is the default (webby-0144 R3).
|
||||
export const REPLY_LIMITS = Object.freeze({
|
||||
requireCitation: { default: false, valid: v => typeof v === 'boolean' },
|
||||
citationsPerReply: { default: 20, valid: v => Number.isInteger(v) && v >= 1 && v <= 1000 },
|
||||
});
|
||||
|
||||
// `read` is { readable: false } when the business file is missing, unreadable, not JSON or not an
|
||||
// object at check time, else { readable: true, document }. An unreadable file checks at the defaults
|
||||
// except requireCitation, which is true so a setting she turned on doesn't fail open (webby-0151 R2).
|
||||
export function resolveReplyLimits(read) {
|
||||
const limits = {}, fallback = [];
|
||||
const unreadable = !read?.readable || !plain(read.document);
|
||||
const raw = unreadable ? undefined : read.document.replyLimits;
|
||||
for (const [key, { default: value, valid }] of Object.entries(REPLY_LIMITS)) {
|
||||
if (plain(raw) && Object.hasOwn(raw, key) && valid(raw[key])) limits[key] = raw[key];
|
||||
else {
|
||||
limits[key] = value;
|
||||
// A key fell back when replyLimits is there but isn't an object, or holds the key with a value
|
||||
// outside the table. A key left out is simply its default.
|
||||
if (raw !== undefined && (!plain(raw) || Object.hasOwn(raw, key))) fallback.push(key);
|
||||
}
|
||||
}
|
||||
if (unreadable) limits.requireCitation = true;
|
||||
return { ...limits, fallback, unreadable };
|
||||
}
|
||||
|
||||
// library.limits.set's check: the whole object is replaced, a key left out means its default, and an
|
||||
// unknown key or a value outside the table refuses. Returns the object to write.
|
||||
export function checkReplyLimits(value) {
|
||||
if (!plain(value)) return null;
|
||||
for (const [key, v] of Object.entries(value))
|
||||
if (!Object.hasOwn(REPLY_LIMITS, key) || !REPLY_LIMITS[key].valid(v)) return null;
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
#!/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=
|
||||
Reference in new issue
Block a user