diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 89a5fc650..c3887e812 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -5476,3 +5476,10 @@ remain; no live state, schema or bus policy change. 2026-10-24 | Goldie | Row265 / #1770 verified (mos-1567, sage-3942) | Approved correction adds solely address import pin + Row265 comment to project-runtime.test and one pure-source RUNNER_FILES mount; new block checks parser has no imports. Serialized fresh-short-TMP timeout-k10: unchanged sandbox10/10, parser15/15, rooms18/18, full bus957/957, twelve named refusal mutants killed (each Node1, driver0), restored parser15/15 + rooms18/18. All positives zero fail/skip/cancel; every mutation restored full broker SHA71b32ee4. Old first-red evidence retained, no other old-test change. Slot returned goldie-0034; Boyd packet explicitly includes mount-list review, candidate push next. 2026-10-24T07:18:53Z | Sage | Row 327 (#1825, Bradford, Poe and Dewey) LANDED 68c08b4e7. Before: refactor at 94d9e90a6; the push guard was a scratch script with no reviewed-binary check. After: scripts/push-check.sh and scripts/test-push-check.sh on refactor, 3 commits cherry-picked onto 6a5e12511 with blobs equal to the round 3 tip d858c9f8b, plus doc commits 76f84465f and 68c08b4e7. Binaries are found by numstat or a NUL in the first 8000 committed bytes, regardless of attributes; each needs its sha256 and path in an approved latest-round candidate; an unconfirmed queue tail exits 2. Reviews: Poe c31525 rev 3882, Dewey c31526 rev 3883. Gate: 1st run 06:57Z RED, not the candidate: canonical node_modules was empty from 05:45:15Z (sage-4023). Rerun with its own root npm ci GREEN 07:15:49Z: 17 package suites 0 failures (cli 986, plugins 119, webui 418), 11 scripts exit 0, push-check 6 mutants KILLED; skips are the queue canonical-root check and the row 244 live provider cases. The landed push-check.sh passed its own push (NO_BINARY, NO_ATTRIBUTION, PUSH_READY). mos-1575: gates install their own node_modules; canonical restored with npm ci (128 packages). + +## 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. diff --git a/agents/lena/work/queue-296/BUILD.md b/agents/lena/work/queue-296/BUILD.md new file mode 100644 index 000000000..8c805ae42 --- /dev/null +++ b/agents/lena/work/queue-296/BUILD.md @@ -0,0 +1,213 @@ +# 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--); 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. diff --git a/agents/lena/work/queue-296/MANIFEST.md b/agents/lena/work/queue-296/MANIFEST.md new file mode 100644 index 000000000..1d8d800cf --- /dev/null +++ b/agents/lena/work/queue-296/MANIFEST.md @@ -0,0 +1,61 @@ +# Row 296 / #1802 — first 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, latest op=revoke | +| 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. +- 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. diff --git a/agents/lena/work/queue-296/MANIFEST.sha256 b/agents/lena/work/queue-296/MANIFEST.sha256 new file mode 100644 index 000000000..6f74005e2 --- /dev/null +++ b/agents/lena/work/queue-296/MANIFEST.sha256 @@ -0,0 +1,9 @@ +6e0193ba91cbd20edd3acfbc57ae9af2f016e6d2c3587b434a9085b3a02c493c BUILD-LOG.md +72029331a22f33433ad14ff08fe8b24d59f79e784009b12fd9c5b74c9e524f13 docs/SESSIONS.md +87239327014c49abbe130c17881bb48e13d96545bf9f3e09b6f4aed46e11453b agents/quackerjack/work/queue-296/BUILD.md +0758cd2dc670e5bce55132e35fb4034968f4d0003e65083e3fe2998d10d8b377 agents/lena/work/queue-296/BUILD.md +bd17468243fdf6a9f2972c89cdad941112e2d25b8d6d5c1320aba71fb13d1d4e agents/lena/work/queue-296/mutants.py +66407bd74b92b0810cd155081145a73b3afde821f34b2eb31bf98e0715d895ee 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 diff --git a/agents/lena/work/queue-296/mutants.py b/agents/lena/work/queue-296/mutants.py new file mode 100644 index 000000000..82f50c6bf --- /dev/null +++ b/agents/lena/work/queue-296/mutants.py @@ -0,0 +1,50 @@ +#!/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. +""" +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, timeout=280) + 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') diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 35f708099..bf75593ad 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -1626,3 +1626,4 @@ are never rewritten or removed; corrections are new entries. 2026-10-24T04:27Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row265 / #1770 C2-09b rooms | Goldie owns build, Boyd reviews under sage-3915. New goldie/row265 worktree at ec86f2c84, claim rev3814; room verbs/parser/new tests prepared, no old tests changed. Sage-3932 slot: new parser14/14 and rooms18/18 passed; whole bus in progress before refusal mutants, stop rule active. 2026-10-24T04:35Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row265 / #1770 verified | First slot stopped at project-runtime import pin. mos-1567 narrowly authorizes pin and pure-source mount correction. sage-3942 rerun: sandbox10/10, parser15/15, rooms18/18, bus957/957, twelve refusal mutants killed/restored SHA71b32ee4, parser15/15 + rooms18/18 again. Slot returned goldie-0034; no further test runs. Boyd reviews RUNNER_FILES alongside rooms; own-branch push/claimant in-review next. 2026-10-24T07:18:53Z | Sage (1ef1e4f8) | Row 327 / #1825 push guard landed (mos-1572 option A, mos-1575) | Gate went red 06:57Z on an empty canonical node_modules (Glomgold's webui npm ci through a link to canonical, glomgold-0011); rerun with its own npm ci GREEN 07:15:49Z on 1bd214e38; pushed 68c08b4e7 07:18:03Z; #1825 closed c31530; canonical restored (128 packages); brief template rule 4db23a51f | landed +- 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. diff --git a/packages/cli/tests/federation-acceptance.test.mjs b/packages/cli/tests/federation-acceptance.test.mjs new file mode 100644 index 000000000..0b5354851 --- /dev/null +++ b/packages/cli/tests/federation-acceptance.test.mjs @@ -0,0 +1,229 @@ +// 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', 'user.email=r7@example.invalid', '-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) : []; + +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); + 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'); + const store = new DatabaseSync(join(hub.dataRoot, 'bus/bus.sqlite'), { readOnly: true }); + try { + const history = store.prepare('SELECT op, reason FROM thread_claims WHERE holder_run=? ORDER BY seq').all(launch.run); + assert.ok(history.some(r => r.op === 'revoke' && r.reason === 'access-revoked'), 'R7 revoke must end the claim as access-revoked'); + assert.equal(history.at(-1).op, 'revoke', 'R7 shutdown must not replace missing revocation with release'); + } finally { store.close(); } + assert.deepEqual(human(factory, 'thread.list'), []); +}); diff --git a/packages/cli/tests/fixtures/federation-host.mjs b/packages/cli/tests/fixtures/federation-host.mjs new file mode 100644 index 000000000..0fb5483a0 --- /dev/null +++ b/packages/cli/tests/fixtures/federation-host.mjs @@ -0,0 +1,67 @@ +// FED R7 fixture, sage-3625: the ancestry walk is a stand-in for Jason's +// shell. Keep this override in a test-only process, never in product code. +// As channel-fixture.mjs, real pid/uid/argv/startTime/nonce checks remain; +// verifyHuman sees ppid=1 instead of walking the development agent's tree. +import { mock } from 'node:test'; +const humanUrl = new URL('../../../bus/src/human.mjs', import.meta.url).href; +const real = await import(humanUrl); +mock.module(humanUrl, { namedExports: { + readProcess: real.readProcess, + verifyHuman: (proof, options) => real.verifyHuman(proof, { + ...options, readProcess: pid => ({ ...real.readProcess(pid), ppid: 1 }), + }), +} }); +const { startBroker } = await import('../../../bus/src/runtime.mjs'); +const { BusError } = await import('../../../bus/src/broker.mjs'); +const { loadSystem, bootConfig } = await import('../../src/config.mjs'); +const { createLauncher } = await import('../../src/launcher.mjs'); +const { fakeHarnesses } = await import('../helpers.mjs'); +const { writeFileSync } = await import('node:fs'); +const [business, adapter, ready] = process.argv.slice(2); +const system = loadSystem(); +const runtime = await startBroker(bootConfig({ system, businessId: business })); +const hooks = [], cleanups = []; +const ops = { + identity: m => runtime.identity(m.cap), + authorizeLaunch: m => runtime.authorizeLaunch(m.cap, m.instance), + refuse: m => runtime.refuse(m.cap, m.code, m.subject), + endLaunch: m => runtime.endLaunch(m.record), + credentialStatus: m => runtime.credentialStatus(m.business, m.role), + threadsState: m => runtime.threadsState(m.business), + threadIdle: m => runtime.threadIdle(m.record), + mayLaunch: m => runtime.mayLaunch(m), + landHeld: m => runtime.landHeld(m.record), + landHolders: m => runtime.landHolders(m.business), + landRelease: m => runtime.landRelease(m.record), + sessionClears: m => runtime.sessionClears(m.business), + lifecycleRequests: m => runtime.lifecycleRequests(m.business), +}; +const wrap = fn => async value => { + try { return fn(value); } + catch (e) { throw Object.assign(new Error(e.message), { code: e instanceof BusError ? e.code : 'fixture-host-refused' }); } +}; +const host = { + business, path: runtime.path, dataRoot: system.dataRoot, + op: wrap(m => { if (!Object.hasOwn(ops, m.op)) throw new BusError('invalid-host-request'); return ops[m.op](m); }), + bindLaunch: wrap(record => runtime.bindLaunch(record)), + beforeClose: fn => void hooks.push(fn), +}; +const launcher = createLauncher({ host, system, + makeLoader: await fakeHarnesses({ after: fn => cleanups.push(fn) }, { pi: adapter }, write => write('channel', 'webui', + `export * from ${JSON.stringify(new URL('../../../plugins/channel/webui/index.mjs', import.meta.url).href)};`)), + readOnly: [new URL('./thread-adapter.mjs', import.meta.url).pathname], + harnessVersion: '0.85.1', sessionDefaults: { pollInterval: 600000 }, threadTickMs: 100, +}); +let closing = false; +async function close() { + if (closing) return; + closing = true; + for (const fn of hooks) await fn(0); + await runtime.close(); + for (const fn of cleanups) await fn(); + process.exit(0); +} +process.once('SIGTERM', close); +process.once('SIGINT', close); +await launcher.listen(); +writeFileSync(ready, 'ready\n', { mode: 0o600 }); diff --git a/packages/cli/tests/fixtures/federation-human.mjs b/packages/cli/tests/fixtures/federation-human.mjs new file mode 100644 index 000000000..bd6febfcf --- /dev/null +++ b/packages/cli/tests/fixtures/federation-human.mjs @@ -0,0 +1,7 @@ +// R7 fixture: actual human-cli path with the fixture host's labelled ancestry override. +// This stands in for Jason's shell (sage-3625); it does not prove real human ancestry. +import { writeFileSync } from 'node:fs'; +import { humanTransport } from '../../src/transport.mjs'; +const [socket, business, verb, args, out] = process.argv.slice(2); +const result = await humanTransport({ socket, business })(verb, JSON.parse(args)); +writeFileSync(out, JSON.stringify(result), { mode: 0o600 }); diff --git a/scripts/test-federation-acceptance.sh b/scripts/test-federation-acceptance.sh new file mode 100755 index 000000000..f013a25b5 --- /dev/null +++ b/scripts/test-federation-acceptance.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# FED R7 / #1802. Scratch acceptance with labelled ancestry stand-in (sage-3625). +# No systemd units. All children run in owned process groups, removed on exit. +set -euo pipefail +R7_INTERRUPT=0 +case "${1:-}" in + '') ;; + --kill-after-start) R7_INTERRUPT=1 ;; + *) echo 'usage: scripts/test-federation-acceptance.sh [--kill-after-start]' >&2; exit 2 ;; +esac +[ "$#" -le 1 ] || exit 2 +for R7_TOOL in ss rg timeout node ps awk; do + command -v "$R7_TOOL" >/dev/null || { echo "R7 requires $R7_TOOL" >&2; exit 1; } +done +cd "$(dirname "${BASH_SOURCE[0]}")/.." +R7_ROOT="$(mktemp -d /mnt/storage/scratch/tmp/r7.XXXXXXXX)" +mkdir -p "$R7_ROOT/home" "$R7_ROOT/tmp" +touch "$R7_ROOT/groups" "$R7_ROOT/ports" "$R7_ROOT/sockets" + +cleanup() { + local result=$? pid start raw port socket listeners cleanup_failed=0 attempt + local -a fields + trap - EXIT INT TERM + if [ -n "${R7_DRIVER:-}" ]; then + kill -TERM "$R7_DRIVER" 2>/dev/null || true + wait "$R7_DRIVER" 2>/dev/null || true + fi + # A detached child is its group's leader. Confirm pid/start time/group before + # signalling only groups in this run's ledger; never a live stack process. + while read -r pid start; do + [ -n "$pid" ] || continue + if [ -r "/proc/$pid/stat" ]; then + raw="$(cat "/proc/$pid/stat")" + read -r -a fields <<< "${raw##*) }" + if [ "${fields[19]}" != "$start" ] || [ "${fields[2]}" != "$pid" ]; then + echo "R7 cleanup failed: group identity changed: $pid" >&2 + result=1; cleanup_failed=1 + continue + fi + kill -TERM -- "-$pid" 2>/dev/null || true + for ((attempt=0; attempt<800; attempt++)); do + ps -eo pgid=,stat= | awk -v g="$pid" '$1==g && $2 !~ /^Z/ {found=1} END {exit !found}' || break + sleep 0.05 + done + kill -KILL -- "-$pid" 2>/dev/null || true + for ((attempt=0; attempt<40; attempt++)); do + ps -eo pgid=,stat= | awk -v g="$pid" '$1==g && $2 !~ /^Z/ {found=1} END {exit !found}' || break + sleep 0.05 + done + fi + # Zombies hold no tools/listeners and are reaped by their parent. The test + # requires no live member of an owned group, including orphan descendants. + if ps -eo pgid=,stat= | awk -v g="$pid" '$1==g && $2 !~ /^Z/ {found=1} END {exit !found}'; then + echo "R7 cleanup failed: group $pid still has a live process" >&2 + result=1; cleanup_failed=1 + fi + done < "$R7_ROOT/groups" + while IFS= read -r port; do + [ -n "$port" ] || continue + if ! listeners="$(ss -H -ltn "sport = :$port")"; then + echo "R7 cleanup failed: cannot inspect port $port" >&2 + result=1; cleanup_failed=1 + elif [ -n "$listeners" ]; then + echo "R7 cleanup failed: port $port still listens" >&2 + result=1; cleanup_failed=1 + fi + done < "$R7_ROOT/ports" + while IFS= read -r socket; do + [ -n "$socket" ] || continue + if ! listeners="$(ss -H -lx)"; then + echo "R7 cleanup failed: cannot inspect socket $socket" >&2 + result=1; cleanup_failed=1 + elif printf '%s\n' "$listeners" | rg -F -- "$socket" >/dev/null; then + echo "R7 cleanup failed: socket $socket still listens" >&2 + result=1; cleanup_failed=1 + fi + done < "$R7_ROOT/sockets" + if listeners="$(ss -H -lx)"; then + if printf '%s\n' "$listeners" | rg -F -- "$R7_ROOT/" >/dev/null; then + echo 'R7 cleanup failed: another scratch Unix listener remains' >&2 + result=1; cleanup_failed=1 + fi + else + result=1; cleanup_failed=1 + fi + if [ "$result" -ne 0 ]; then + for raw in "$R7_ROOT"/*.log; do + [ -f "$raw" ] || continue + echo "R7 diagnostic: ${raw##*/}" >&2 + tail -40 "$raw" >&2 + done + fi + rm -rf -- "$R7_ROOT" + if [ -e "$R7_ROOT" ]; then + echo 'R7 cleanup failed: scratch directory remains' >&2 + result=1; cleanup_failed=1 + fi + if [ "$cleanup_failed" -eq 0 ]; then echo 'R7 cleanup: no child groups, ports or sockets remain; no units created'; fi + exit "$result" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +# No host configuration, preload hooks, credentials or agent env in fixtures. +# The ancestry stand-in lives only in federation-host.mjs, per sage-3625. +timeout -k 10 210 env -i PATH="$PATH" HOME="$R7_ROOT/home" TMPDIR="$R7_ROOT/tmp" \ + MOSAIC_R7_ROOT="$R7_ROOT" MOSAIC_R7_INTERRUPT="$R7_INTERRUPT" \ + node --test --test-concurrency=1 packages/cli/tests/federation-acceptance.test.mjs & +R7_DRIVER=$! +wait "$R7_DRIVER" +R7_DRIVER=