Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6581f750e1 | ||
|
|
137bda3af4 | ||
|
|
a365237133 | ||
|
|
5afff54908 | ||
|
|
f33932921c | ||
|
|
1be656ac97 | ||
|
|
19d3d5f026 | ||
|
|
6c75887b0c | ||
|
|
0636721e60 | ||
|
|
7b9737953a | ||
|
|
9de5a57abd | ||
|
|
8ca064158a | ||
|
|
8a04f4aa4a | ||
|
|
bc579f7a84 | ||
|
|
6de1ebc629 | ||
|
|
540239b6b7 | ||
|
|
6d5545d442 | ||
|
|
856c1826bc | ||
|
|
f8276c5eb3 | ||
|
|
b65f3a0e84 |
@@ -151,3 +151,11 @@ every run behind my busy check and Sage's, chained with `&&`.
|
||||
Summed: 818 node tests passed with 0 failed, and 429 selftests passed with 0 failed. The compose network
|
||||
was removed afterwards, and the sweep found no process left in the worktree, the output directory or the
|
||||
TMPDIR. This file's results are a doc-only commit on top of the build, so nothing that ran has changed.
|
||||
|
||||
## Correction to deviation 6 (follow-up, 2026-10-21)
|
||||
|
||||
Deviation 6 says the node F9 runs is root's own. At 37171f65b the script didn't check that. F9 ran
|
||||
`--node` as root before F10's path check, so the header's "root itself only reads" was false
|
||||
(Duckworth's N1, `agents/duckworth/work/row195/REVIEW-m1.md` at 6afcee2a5). A host installed as designed
|
||||
was safe, because `case2-unit.sh` refused a non-root-owned `--node`. Row 245 carries the fix and its
|
||||
tests: `agents/darkwing/work/queue-245/BUILD.md`.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Row 245 build: Duckworth's N1-N3 on `scripts/case1-accept.sh` (row 195 follow-up)
|
||||
|
||||
Duckworth approved row 195's manifest round 1 (duckworth-0088, `agents/duckworth/work/row195/REVIEW-m1.md`
|
||||
at 6afcee2a5) with three notes that don't block. Row 195's code landed as b65014647, and the notes are in
|
||||
`docs/plans/DEFERRED.md` (d6cff0cdd). Row 245 carries them: brief `docs/plans/2026-10-20_hub-own-uid.md` §
|
||||
"Row 195 follow-up" at 62ddc96cc, owner Darkwing, reviewer Duckworth, gate owner Sage (sage-1923).
|
||||
|
||||
The branch is `darkwing/row245`, on 62ddc96cc. Its first commit is a cherry-pick of `darkwing/row195-n1`
|
||||
1e3c4767c, which was written on 37171f65b and never run. The script, test and TOOLS.md blobs are the same
|
||||
as in 1e3c4767c, and none of those paths changed between 37171f65b and 62ddc96cc.
|
||||
|
||||
## What changes
|
||||
|
||||
N1. F9 ran `$ROOT$NODE` as root (`case1-accept.sh:298` at 37171f65b) before F10 checked that `--node` is
|
||||
the pinned path. Duckworth offered two fixes: F9's parse through `AS_USER`, or F10's path check first. I
|
||||
took the path check, plus one more:
|
||||
- F9 now runs node only when `--node` equals `/opt/mosaic-stack/node-<--node-pin>/bin/node`.
|
||||
- Then `not_root_only` walks from that file up to `/`. Each entry must be the running user's (root's on the
|
||||
host) with no group or other write, the rule `case2-unit.sh` `root_only` applied when it rendered the
|
||||
units. A symlink fails it, because its mode is 777. The first entry that breaks the rule goes in the
|
||||
evidence.
|
||||
|
||||
I didn't run the parse through `AS_USER`. That would hand a check about a root file to the stack's user,
|
||||
and a process running as `mosaic` shares that node's uid. Whether it could change what node prints then
|
||||
turns on the host's ptrace setting, and the root-only check doesn't depend on that. Sage agreed in
|
||||
sage-1877. Rechecking ownership costs one loop and doesn't depend on
|
||||
how the host was installed.
|
||||
|
||||
N2. The header (:9-15) and the `docs/TOOLS.md` entry now list what root runs: `systemctl show` and
|
||||
`is-enabled` (F1, P11), `curl` on the loopback healthz (F2), `sudo -l` (P1, P3), `sudo -u jarvis bwrap
|
||||
--version` and `hostcheck.sh --check` (P9), and node for F9 under the rule above.
|
||||
|
||||
N3. F6 matched `--claude` by prefix, so `/opt/mosaic-stack/claude-<pin>/../claude-2.0.9/...` passed the
|
||||
path check. F6 now fails on any `..` segment, before it runs `--version`. F10 compares for equality, so
|
||||
it needed no change.
|
||||
|
||||
Row 195's BUILD.md gets a correction entry for deviation 6. Row 195's DESIGN.md is unchanged. Its "either
|
||||
works" wording at `DESIGN.md:336-338` is superseded by gosalyn-0087 (root writes `dev.args` at W10), and
|
||||
it stays in place: an approved design isn't edited (sage-1877).
|
||||
|
||||
## Tests
|
||||
|
||||
`packages/cli/tests/case1-accept.test.mjs`:
|
||||
- The node stub touches `node-as-root` in the state directory when something runs it outside the fake
|
||||
`sudo` (which sets HOME to the stack's home). `host()` sets the node file's directories and the scratch
|
||||
root to 0755, so the umask can't fail F9.
|
||||
- "every check passing" also asserts that root ran the pinned node.
|
||||
- New: "F9 won't run --node as root off the pin path or with a writable directory above it". Another node
|
||||
path fails F9 and F10, with no root run. A group-writable `bin/` and a symlinked node each fail F9
|
||||
alone, with the bad path in the evidence and no root run.
|
||||
- The F6 test gains a `..` case: a path under `claude-<pin>/` by prefix that names 2.0.9's binary, which
|
||||
prints the pin. F6 alone fails, with "has a .. segment".
|
||||
|
||||
That makes 23 tests. All 23 pass on Sage's slot (results below).
|
||||
|
||||
## Mutants
|
||||
|
||||
The twelve from row 195 still apply, unchanged. These four are new, each one exact replacement:
|
||||
|
||||
| # | Mutant | Expected killer |
|
||||
|---|---|---|
|
||||
| 13 | F9 runs any `--node` (the pin check becomes `true`) | F9 won't run (another node) |
|
||||
| 14 | F9 skips the ownership walk (`bad=""`) | F9 won't run (writable `bin/`, symlink) |
|
||||
| 15 | `not_root_only` checks only the file, not the directories above it | F9 won't run (writable `bin/`) |
|
||||
| 16 | F6 accepts a `..` segment | F6 stub, path and `..` |
|
||||
|
||||
The brief's gate asks for a mutant for each of N1 and N3. Mutants 13-15 are N1's, and 16 is N3's.
|
||||
|
||||
## Slot plan
|
||||
|
||||
Same shape as row 195's steps, on this row's tip: the single test file, then the sixteen mutants, then
|
||||
the gate (the cli suite and every `scripts/test-*.sh`), each behind the busy checks and chained with
|
||||
`&&`. The scripts are in `/mnt/storage/scratch/darkwing-245-out/`.
|
||||
|
||||
## Slot results (grant sage-1961, 2026-10-21)
|
||||
|
||||
All three steps ran on 0590a29fc with node v26.8.1, launched from `/`. Both busy checks were CLEAR
|
||||
before each step.
|
||||
|
||||
- Singles, 11:27:46-11:27:59Z: `case1-accept.test.mjs` passed 23, failed 0.
|
||||
- Mutants, 11:28:10-11:31:20Z: all 16 killed, each with exit 1. The runner restored the script, and
|
||||
its sha256 875bb5b7 matches the committed blob. Rows 1-12 died as in row 195. 13, 14 and 15 died on
|
||||
"F9 won't run --node as root off the pin path or with a writable directory above it". 16 died on
|
||||
"F6 fails on a stub that prints nothing, on a path outside claude-<pin> and on a .. segment".
|
||||
- Gate, 11:31:20-11:42:51Z: the cli suite passed 664, failed 0, skipped 0. Every `scripts/test-*.sh`
|
||||
exited 0: auth 15, conductor 17, config 31, discord 78, extension-package 18, foundation 44,
|
||||
land-work 86, queue 28 (its node part 156/0), release 14, task 98. That's 429 selftests, none failed.
|
||||
- Afterwards no process had its cwd in the worktree, the out dir or TMPDIR, and the
|
||||
`darkwing-r245_default` network was gone.
|
||||
|
||||
Logs: `/mnt/storage/scratch/darkwing-245-out/` (`step1.log`, `mut.out`, `mut/m1-16.log`, `gate.out`).
|
||||
@@ -0,0 +1,98 @@
|
||||
# Row 249 build notes (Gizmoduck)
|
||||
|
||||
Gitea #1751, Vikunja P19 445 under #436. Brief: docs/plans/2026-10-20_host-credentials.md, section
|
||||
"Machine grant row b follow-up: the undo can't prove the remote add has ended" (6de1ebc62). Reviewer
|
||||
Duckworth, gate owner Sage. Branch gizmoduck/row249-on-8a04, on row 248's landing 8a04f4aa4 (sage-2012).
|
||||
No file under packages/bus/src changed between 6d5545d44 and 8a04f4aa4, so the brief's src line numbers
|
||||
hold on the base. Line numbers below are on this branch.
|
||||
|
||||
## Correction to the brief
|
||||
|
||||
The brief, copying gizmoduck-0139, puts the sweep's host-unknown refusal at broker.mjs:1350. On
|
||||
6d5545d44 it is :1349. On this branch it is :1354.
|
||||
|
||||
## The change
|
||||
|
||||
`#runHostGrant` in packages/bus/src/broker.mjs:
|
||||
|
||||
- :943 adds `installed = false` next to `installing`.
|
||||
- :956 sets `installed = true` on the line after `await this.#hostGrant.install(...)`, before the
|
||||
`stop()` that follows it. An abort that lands after install returned still ends revoked, with no hint,
|
||||
because install returning means every request it sent has exited.
|
||||
- :1025, in the catch, inside `if (installing)` and after the remove: `if (!installed) extra =
|
||||
{ hostLineLeft: tag };`. The remove still runs first and its result doesn't matter. A remove that
|
||||
throws sets the same hint as before (:1021).
|
||||
|
||||
The rule keys on install not returning, not on `signal.aborted`. That covers an abort (host-run.mjs:136,
|
||||
SIGKILL to the local group), a timeout (host-run.mjs:131-134, SIGTERM, then SIGKILL after `graceMs`) and
|
||||
a dropped connection, which fails the request with no signal at all. `#hostGrantFailed` (:845) already
|
||||
adds "A line tagged X may be left on the host. Run: mosaic hosts sweep <host>" when `hostLineLeft` is
|
||||
set (:852), so the message needed no change.
|
||||
|
||||
False hints. install is one call that runs `list`, maybe `remove`, then `add` (host-grant.mjs:329-333).
|
||||
Any failure inside it now records the hint, including a `list` that exits 1 before any add was sent, and
|
||||
an abort that lands before the first ssh starts (the G23 X5 case at "a revoke right after the spend").
|
||||
The brief accepts this for an add that exits nonzero. The other cases are the same kind: the line isn't
|
||||
on the host, and a sweep that doesn't list the tag drops the hint (G24, "a left tag no longer on the
|
||||
host", broker.mjs:1358). Narrowing it would need install to report which request was in flight, which is
|
||||
a change to host-grant.mjs's API that the brief doesn't ask for.
|
||||
|
||||
## Row a's enroll
|
||||
|
||||
The race can't happen there, because enrollment sends nothing to the host. `#runEnrollment`
|
||||
(broker.mjs:772-842) writes the record, calls `this.#hostGrant.enroll(business, host)` (:800) and
|
||||
commits the fingerprint. `enroll` (host-grant.mjs:247-286) runs ssh-keygen locally and writes the
|
||||
private half to its file. It opens no ssh session. The installer line goes on the host by hand: the
|
||||
notice tells ops-01 to write it, then to run `mosaic hosts check`. The undo at :835 is `unenroll`, which
|
||||
deletes the local key file. There is no remote add to outlive a kill, and no remote undo to race with.
|
||||
|
||||
## Tests
|
||||
|
||||
packages/bus/tests/grant-executor.test.mjs:
|
||||
|
||||
- `lateAdd` (:451) is the shared tail. After the grant ends it asserts the code, `hostLineLeft` on
|
||||
grant.failed, the sweep line in the notice, and that the undo's remove was the last request. Then it
|
||||
writes `release`, so the held add writes its line and exits 0, and waits for the line in `granted`. It
|
||||
checks there's no record, `hosts.status` lists the tag in `hostLineLeft`, `hosts.remove` refuses
|
||||
host-line-left, `hosts.sweep` returns `removed: [tag]` and the line is gone, then `hosts.remove`
|
||||
succeeds.
|
||||
- :468, the abort case. `hold-add`, the human approves, the add holds, `abortClaim` aborts the
|
||||
executor's claim. Ends revoked.
|
||||
- :480, the timeout case. A 30 s wait is too long for the file, and making the timeout an option would
|
||||
add a src seam, so the test wraps `install` through `opts.wrap` instead. The wrapper passes install its
|
||||
own AbortController signal, waits for `holding`, aborts it, which SIGKILLs the local ssh group as the
|
||||
timer does, then throws as a timed-out request does. The executor's signal isn't aborted, so it ends
|
||||
host-failed. Nothing under src changes for it.
|
||||
- G8 (:283) changes. fail-list and fail-add now carry `hostLineLeft` and the sweep line. noop-add fails at
|
||||
verify, after install returned, so it keeps no hint and the old message.
|
||||
- The comment at :436 (:432 at 6d5545d44) now says `skip` makes the held add exit without writing and that a
|
||||
killed session's add can still write. G13 has no such comment.
|
||||
|
||||
Both new tests write `release`, so their held stub exits before `t.after` removes the dir. Row 248's dir
|
||||
check (:67, :72) is the backstop.
|
||||
|
||||
## Mutant
|
||||
|
||||
/mnt/storage/scratch/gizmo-235-out/mutants-249.py, on row 235's runner. C1 removes the :1025 line and
|
||||
runs grant-executor.test.mjs. Both new tests and G8 should fail under it. `--check` matches its anchor
|
||||
once.
|
||||
|
||||
## Runs
|
||||
|
||||
On Sage's grant sage-2015, at 8457af076 (7cee1a1ab plus this file), TMPDIR /mnt/storage/scratch/g249t:
|
||||
|
||||
| Run | Window | Result |
|
||||
|---|---|---|
|
||||
| grant-executor.test.mjs whole | 13:29:01-13:29:21Z | 39 of 39 pass |
|
||||
| C1 | 13:29:21-13:29:40Z | killed: both row 249 tests and G8 fail on `hostLineLeft` undefined |
|
||||
|
||||
After C1 the worktree was clean and broker.mjs matched HEAD. After the runs, no node, sshd or sh process had a
|
||||
cwd under the worktree or g249t, and no `fake/add` or `fake/remove` stub was running. The bus suite runs on
|
||||
Sage's gate.
|
||||
|
||||
## Rebase onto 8a04f4aa4 (sage-2012)
|
||||
|
||||
The first commit, 02bfd184b, sat on bc579f7a8 as gizmoduck/row249, which was never pushed. Row 248 landed
|
||||
as 8a04f4aa4, changing grant-executor.test.mjs at :54, :67 and :72. gizmoduck/row249-on-8a04 starts at
|
||||
8a04f4aa4 and cherry-picks 02bfd184b with no conflict. 248's lines stay as landed. The patch-id is the
|
||||
same on both (c33823941e61). Test line numbers moved down by one, and this file was updated for that.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Row 247 build notes, C2-20b persona import follow-up (#1749)
|
||||
|
||||
Brief: docs/plans/2026-10-20_case2-rows.md "C2-20b persona import follow-up: printed keys, the dry run
|
||||
fence check and the mkdir race", landed in 335bb3e30. These are Webby's row 221 notes N1, N2 and N4
|
||||
(webby-0073, REVIEW-r1 a0a00630e on webby/reviews). N3 is in DEFERRED (sage-1941).
|
||||
|
||||
## Base
|
||||
|
||||
Branch gosalyn/c2-20b from 335bb3e30, which is row 221's landing 300766450 plus the brief.
|
||||
|
||||
## What changed
|
||||
|
||||
- N1, `scripts/case2-persona-import.mjs`: the per-channel prompt line prints the platform key, with its
|
||||
fixed `platforms.` or `gateway.platforms.` prefix, and the channel key through `JSON.stringify`. A key
|
||||
holding a newline prints as `\n` inside one quoted line. The profile name on that line is not config
|
||||
text: `pairing` already holds it to `PROFILE_ID`.
|
||||
- N2: the dry run calls `personaTextFault` on each persona after its count line. A fault prints as
|
||||
`profile P: persona <reason>, so C2-08's fence would refuse it`, where the reason is the fixed string
|
||||
`personaTextFault` returns, never persona text or a line number. With any fault the run refuses, exit
|
||||
1, and writes nothing, as stage would. The header comment no longer says a fault is counted, not
|
||||
refused, and now says a SOUL.md over four times the fence in bytes refuses before any count, which is
|
||||
what `soul()` already did (Webby's second point under N2).
|
||||
- N4: `privateDir` catches an EEXIST from its mkdir and refuses with `<path> was made by something else
|
||||
between the check and mkdir`. Any other mkdir error is thrown as before, since the brief names only
|
||||
EEXIST.
|
||||
- Tests, `packages/cli/tests/case2-persona-import.test.mjs`:
|
||||
- The per-channel prompt test expects the quoted keys.
|
||||
- New: a chet config.yaml with a newline in the platform key and in the channel key prints one quoted
|
||||
line, and no output line starts with the forged text.
|
||||
- The dry run heading test now expects the fence fault line, exit 1 and the refusal, still with
|
||||
nothing written. Its persona has two heading lines, which the fence refuses.
|
||||
- New: a persona of 8001 two-byte characters, 16002 bytes and under the 32000 byte cap, makes the dry
|
||||
run print the over 8000 characters fault, write nothing and exit 1. At exactly 8000 it exits 0.
|
||||
- New: the mkdir race refuses by name at stage, for the staging directory, and at install, for
|
||||
`personas/<business>`. Each refused directory is left empty.
|
||||
- New fixture `packages/cli/tests/fixtures/case2-persona-import/mkdir-race.mjs`, an `--import` preload
|
||||
like `trace.mjs`. It makes the directory named by `$PERSONA_MKDIR_RACE` just before the script's own
|
||||
`mkdirSync` of that path, so the script meets a real EEXIST from the kernel, not a thrown stub.
|
||||
|
||||
## Known limit of N1
|
||||
|
||||
`JSON.stringify` escapes C0 controls, `"` and `\`, but leaves DEL, C1, U+2028, U+2029 and the bidi
|
||||
controls raw. None of those ends a line on a terminal, so the forged line N1 names can't happen. A bidi
|
||||
control could still reorder how the rest of that one line displays. The brief asks for `JSON.stringify`,
|
||||
and Sage kept it in sage-1964 because the keys come from Jason's own Hermes config. Note for Webby: row
|
||||
231's `quote()` in packages/library covers the wider set, and this row doesn't import it. If you want it,
|
||||
ask for it in review.
|
||||
|
||||
## Mutants
|
||||
|
||||
One per fix, plus a second for N1 so each of its two keys has its own. Each replaces one exact string
|
||||
in the script, runs the test file, then restores the script from a copy and checks its sha256.
|
||||
|
||||
| Mutant | Fix | Change |
|
||||
|---|---|---|
|
||||
| M1 | N1 | the channel key prints raw |
|
||||
| M2 | N1 | the platform key prints raw |
|
||||
| M3 | N2 | the dry run's refusal after a fault is skipped |
|
||||
| M4 | N4 | privateDir's EEXIST branch never matches, so EEXIST is thrown raw |
|
||||
|
||||
## Outside readers
|
||||
|
||||
`git grep` over packages/*/tests/* and scripts/test-*.sh finds `case2-persona-import` only in its own
|
||||
test file and in two files of its fixture tree, README.md and trace.mjs, which name the test file in a
|
||||
comment. No test pins a hash of the script.
|
||||
|
||||
## Tests
|
||||
|
||||
Two slot runs, `busy.sh && run.sh` in /mnt/storage/scratch/gosalyn-r247, on the test file alone.
|
||||
|
||||
Run 1, grant sage-1969, 11:47:45Z to 11:48:00Z, was red: 14 tests, 12 pass, 2 fail. The fault was in the
|
||||
script. Adding `JSON.stringify` to the per-channel line dropped the space after the comma, so it printed
|
||||
`"-1001",29 bytes`. The two per-channel tests caught it. run.sh also went on to the mutants with a red
|
||||
base, so their counts meant nothing. Sage approved both fixes in sage-1971: the space goes back, and run.sh
|
||||
now exits 3 before the mutants unless the base is rc=0 with fail 0.
|
||||
|
||||
Run 2, grant sage-1971, 11:49:04Z to 11:49:22Z, was green: 14 tests, 14 pass, 0 fail, rc=0.
|
||||
|
||||
| Mutant | Line | rc | Failed | Killed by |
|
||||
|---|---|---|---|---|
|
||||
| M1 | 237 | 1 | 2 | the per-channel prompt test and the newline key test |
|
||||
| M2 | 237 | 1 | 2 | the same two |
|
||||
| M3 | 238 | 1 | 2 | the dry run fence test and the dry run heading test |
|
||||
| M4 | 192 | 1 | 1 | the mkdir race test |
|
||||
|
||||
After the run the script's sha256 was 7031d2c0dc9bd22bc748b364a35b461fc01cb98b795032a2e0ec436f3188d4e4, the
|
||||
same as the copy taken before it. The /proc scan found no process in the worktree or the scratch dir.
|
||||
@@ -0,0 +1,432 @@
|
||||
# Row 197 execution ledger
|
||||
|
||||
Plan: MANIFEST.md r2, approved by Webby ca6b32d66.
|
||||
Build grant: sage-1390. Suites, image builds and paired proof are not granted.
|
||||
Rebased five documentation commits onto origin/refactor aa1bd209e before code.
|
||||
Existing worktree herb/row197 and plain node_modules symlink retained.
|
||||
|
||||
- T1 preflight/steps: in progress.
|
||||
- T2 locks/status/recovery: pending.
|
||||
- T3 image/Docker: pending.
|
||||
- T4 fixtures/timing: pending.
|
||||
- T5 evidence/acceptance: historical timing done; implementation pending.
|
||||
|
||||
Preflight interfaces: T1 RunPaths supplies T2 ownership and T3 image checks.
|
||||
T2 run receipt supplies T4 lane/port state and T5 attribution. T3 image identity
|
||||
is checked before Docker-bearing steps. T4 timings supply T5 pair summaries.
|
||||
No interface conflict found. No product API or assertion changes are authorized.
|
||||
|
||||
Ruling: use this approved BUILD.md as the execution ledger, rather than adding
|
||||
skill workspace paths outside the manifest. This preserves the reviewed path
|
||||
boundary. Missing ledger detail would cost recovery time, not product behavior.
|
||||
|
||||
## Implementation checkpoint 2026-10-20T20:11:58Z
|
||||
|
||||
T1-T5 source is present within the approved manifest. Full acceptance is pending.
|
||||
Fresh single-file checks: preflight/supervisor 6/6; state/recovery 11/11; image and
|
||||
Docker guard 4/4; port leases/attribution/proof summary 3/3. No skips or failures.
|
||||
Existing files checked singly: board serve; WebUI serve, preview, proof-cli,
|
||||
tester-cli and service; harness pi and Claude. Stack-release R1 selected single
|
||||
passed 1/1. WebUI service also passed 31/31 with lane-a leases and a 30-byte root.
|
||||
All existing assert lines are unchanged. Shell syntax and diff checks pass.
|
||||
|
||||
Observed defects fixed during the single-file cycles: cancellation incorrectly
|
||||
returned zero when a TERM handler exited zero; recovery did not supersede an
|
||||
unclean terminal; status omitted the lock-before-receipt window. Evidence logs
|
||||
retain the failing and passing observations. Source still has no commit because
|
||||
applicable suites have not run. No image build, proof, push or live operation.
|
||||
|
||||
Ruling: use an instrumented single-lane author gate on a frozen dirty-source
|
||||
clone before committing source. The new wrapper requires a clean candidate and
|
||||
proven image receipt, while repository rules require green suites before source
|
||||
commits. The author gate retains every suite and assertion, caps concurrency at
|
||||
two, uses own port/state/temp/project roots, and guards Docker against builds.
|
||||
Cost if wrong: the author gate fails; clean-candidate wrapper baselines and the
|
||||
paired proof remain mandatory after source is verified and committed.
|
||||
|
||||
Ruling: publish the initial run directory atomically after the start receipt is
|
||||
complete. This avoids a second lane observing a partially written start. A
|
||||
crash before publication has started no test or mutable Docker operation.
|
||||
Cost if wrong: a lane refuses or quarantines; no protected state is bypassed.
|
||||
|
||||
## Implementation review corrections, 2026-10-20T20:33Z
|
||||
|
||||
Webby-0029 reviewed the first implementation packet at f942bd7d4.
|
||||
B1 identified missing manifest tests. B2 identified a test that leased both real
|
||||
bands through a private lock namespace. The old author-gate clear was withdrawn
|
||||
in herb-0019. SOURCE.json, BUILD.patch and gate.sh still describe the old frozen
|
||||
clone. They are not a clear for the edited working tree. No author gate ran.
|
||||
|
||||
B2 correction: the helper test uses only the active lane and MOSAIC_GATE_STATE.
|
||||
It compares the other band arithmetically and has no unasserted listener. The
|
||||
occupied-port probe first obtains its own real lease, binds that port, and
|
||||
asserts that its sentinel listener survives refusal. No test reserves another
|
||||
lane's band. Exhaustion narrows only the test process's band to one owned port.
|
||||
|
||||
B1 coverage added within the existing approved paths. No case is dropped:
|
||||
|
||||
| Manifest case | Added check |
|
||||
| --- | --- |
|
||||
| T1 canonical and builder checkout, existing output | gate-lane.test: direct validateRun refusals |
|
||||
| T1 92-byte socket and over-107-byte socket | exact design example with a distinct gt- test root; 108-byte refusal |
|
||||
| T1 both candidates carry tooling | validateTooling compares each launcher file, including missing and changed cases |
|
||||
| T1 duplicates and NODE_TEST_CONTEXT | repeated package/script fixture is retained; sanitized environment is exercised by both supervised children |
|
||||
| T2 one-winner race and inherited FDs | two actual shell wrappers race; a controlled supervisor verifies held FDs, and its child checks that no FD points to either lock file |
|
||||
| T2 same checkout and legacy claim refusal | activeConflicts uses synthetic process observations with a live lane and a sibling landing gate |
|
||||
| T2 surviving child and interrupted recovery | locked recovery kills a child proven by the private root, preserves a foreign child, is killed during fake Docker cleanup, leaves quarantine, and resumes under a new fixture grant |
|
||||
| T3 missing receipt and missing image | verifyImage refuses absent provenance and failing inspection |
|
||||
| T3 tag changed mid-gate and at gate end | runGate now uses the same executePinnedSteps boundary exercised by the tests; the next step never runs after a changed ID |
|
||||
| T3 mutable-tag rebuild | fake preparation returns a new image ID with identical input digest; the old receipt refuses it |
|
||||
| T3 cold and warm counters | preparation records one build and its time; warm execution records zero builds; repeated verification never builds |
|
||||
| T4 parallel test-file leases | two generated Node test files hold ports in the same lane/state; locks are checked during child execution and shutdown |
|
||||
| T4 exhausted band, occupied port and foreign listener | a one-port process-local band refuses both a held lease and an existing sentinel listener; the listener survives |
|
||||
| T4 owner death | a killed allocator releases only its own holder; a foreign lease remains held |
|
||||
| T4 lease through child shutdown | parent probes the lock while the fixture test runs and after its child has stopped |
|
||||
| T4 fixtureCacheEnv | replacement child environment writes only under separate tmp, jiti and cache directories |
|
||||
| T5 unrelated session append | a separate process waits for a running-step barrier, appends under a fixture HOME, and remains unrelated in the resulting metadata attribution |
|
||||
|
||||
N1 removes the unused closed binding. All leases reserve an unbound endpoint;
|
||||
existing closed options need no call-site change. N2 makes child TMPDIR a tmp
|
||||
subdirectory. N3 normalizes ss interface scopes before checking port 9. N4 uses
|
||||
gt- for unit run roots. N5 documents the canonical node_modules dependency.
|
||||
N6 is recorded: row 200 also edits stack-release.test.mjs; the second landing
|
||||
must rebase and rerun that file singly. Sage was told in herb-0019.
|
||||
|
||||
Before Sage's hold, expanded preflight tests passed 11/11, state tests 14/14,
|
||||
image tests 7/7, and the pi/Claude singles passed 8/8 each with separate tmp
|
||||
subdirectories. Later source/test edits still need verification. In particular,
|
||||
the new parallel test-file probe had a cleanup-order defect and is unverified.
|
||||
Its root cleanup ran before child shutdown. Cleanup now stops children before
|
||||
removing their root. Output is retained for a useful failure report. The initial
|
||||
FD probe also assumed Node did not reopen FD numbers; it now checks the actual
|
||||
lock targets. The early passing fixture file covered child allocators, before
|
||||
they were changed to actual generated test files. It is not acceptance of the
|
||||
latest file.
|
||||
|
||||
Sage-1448 stopped all node --test runs at 20:30Z for Rocko's browser slot.
|
||||
The remaining owned process tree was stopped at 20:30:32Z. Inspection found no
|
||||
remaining owned test processes afterward. Edits and static inspection continue.
|
||||
20 module syntax checks, shell syntax and git diff checks pass. Existing-file
|
||||
assertion lines remain identical to HEAD. No test is run during the hold.
|
||||
|
||||
Next: after Sage passes Rocko's clear, verify the corrected files singly,
|
||||
including a lane-env helper-file check. Then refresh the frozen clone and all
|
||||
three packet artifacts, send their hashes, and request a new author-gate clear.
|
||||
Suites, image builds and paired proofs still need their own grants. No source
|
||||
commit, push, suite, real Docker build or paired proof has run in this phase.
|
||||
|
||||
## Refrozen review packet, 2026-10-20T20:41Z
|
||||
|
||||
Sage-1454 lifted the single-file hold. Fresh runs passed without failures or
|
||||
skips: review-t1-resume.log 11/11, review-t2-resume.log 14/14,
|
||||
review-t3-resume.log 8/8, review-t4-resume.log 8/8. The helper file also passed
|
||||
8/8 with lane-a environment in review-t4-lane-a.log. Its retained state had only
|
||||
an a port-lock namespace, no b directory, and every lock was released. Generated
|
||||
fixture Node test entries explicitly use --test-concurrency=2. The child-test
|
||||
probe now finishes and cleans up before removing its root. All 41 new tests pass.
|
||||
|
||||
B1 and B2 fixes are ready for Webby's review. No manifest case was dropped.
|
||||
All 25 source paths were copied to a new frozen clone and compared by mode and
|
||||
SHA-256. This supersedes the earlier withdrawn source packet:
|
||||
|
||||
- SOURCE.json: 91ba149f2fc039a8bed0e6d1ad5da44b80fb8cbec84d0fa4e38616040138596b
|
||||
- gate.sh: 48e06e23e45d3484755f8cd79ad5b6dfde5ef4f960081e7c455a2540e707fed1
|
||||
- BUILD.patch: 0b3871bd9848ca223c7cda50266debc04643e5d986eb7f0fece096aaf25ff43e
|
||||
- Frozen checkout: /mnt/storage/scratch/mg197-a-d22dacd92735/wt
|
||||
- Output: /mnt/storage/scratch/mg197-a-d22dacd92735/out
|
||||
- Temporary root: /mnt/storage/scratch/ga-VPXhG7 (30 bytes)
|
||||
- Compose project: mg197-a-d22dacd92735
|
||||
|
||||
After an explicit author-gate grant, run this command with Sage's actual ID:
|
||||
|
||||
```sh
|
||||
env MOSAIC_GATE_GRANT=ACTUAL_SAGE_GRANT_ID bash /home/jwoltje/.t3/worktrees/mosaic-stack/herb-row197/agents/herb/work/queue-197/gate.sh > /mnt/storage/scratch/mg197-a-d22dacd92735/out/gate.log 2>&1
|
||||
```
|
||||
|
||||
No gate or suite has started. Queue order from sage-1454 is 192, 201, 204,
|
||||
200, Gander, then 197. No branch deletion is authorized or attempted. Any later
|
||||
rebase uses a new branch name, and pr-merge.sh -d stays off under #426(a).
|
||||
Whichever of rows 197 and 200 lands second must rerun stack-release singly.
|
||||
Clean-candidate wrapper baselines, image preparation and paired proofs remain
|
||||
separately gated acceptance work. There is still no source commit or push.
|
||||
|
||||
## Implementation approval and final notes, 2026-10-20T20:47Z
|
||||
|
||||
Webby-0031 approved r2 in REVIEW-impl-r2.md at 1f404c973. Their static review
|
||||
verified every source hash/mode and patch result, and all B1/B2 cases. No queue
|
||||
review request comment exists yet; Sage owns creating it if needed.
|
||||
|
||||
Folded all three optional notes into the same approved paths. The helper file's
|
||||
standalone fallback now shares /mnt/storage/scratch/mosaic-gate-lanes with real
|
||||
lane-a leases and leaves its stable lock files in place. Band assertions use the
|
||||
imported BANDS. Both T5 polling children fail after five seconds if their barrier
|
||||
is missed. README records the shared standalone namespace and deadlines.
|
||||
|
||||
Fresh single-file logs review-r3-fixtures.log and review-r3-lane-a.log both pass
|
||||
8/8 with zero skips. The lane-a run has no b lock directory, and every lock was
|
||||
released. Syntax and diff checks pass. No existing test assertions or timeouts
|
||||
changed. Remaining source matches the 11/14/8-file checks from r2.
|
||||
|
||||
Refreshed all 25 source rows in the existing frozen checkout. New digests replace
|
||||
the preceding r2 packet. gate.sh and its command are unchanged:
|
||||
|
||||
- SOURCE.json: 3584763642506e81a532c839bdd07b111d25daa87e35dbe441d8e8c01a2c2aab
|
||||
- gate.sh: 48e06e23e45d3484755f8cd79ad5b6dfde5ef4f960081e7c455a2540e707fed1
|
||||
- BUILD.patch: 55fe390da17ab926a0593d39e79561abe52756a1b1a2c756ec57f45d90f10fde
|
||||
|
||||
Author gate remains unstarted and queued for Sage's explicit grant. No source
|
||||
commit, suite, image build, paired proof, push or branch deletion ran.
|
||||
|
||||
## R3 approval receipt
|
||||
|
||||
Webby-0033 approved r3 in REVIEW-impl-r3.md at a5643f9bd. Static review verified
|
||||
the three packet hashes, all 25 source rows, and the patch result. Their review
|
||||
accepts N1-N3; no additional hang-guard test is requested. No queue review request
|
||||
comment exists yet, so Webby has not recorded a queue review.
|
||||
|
||||
Read-only verification after receipt confirms that the approved packet hashes
|
||||
and all 25 worktree/frozen source hashes and modes still match. Source and gate
|
||||
script stay frozen. The author gate has not started and still needs Sage's grant.
|
||||
|
||||
## Queue review round 1
|
||||
|
||||
Sage-1476 authorized moving row 197 to in-review before the author gate.
|
||||
Created CANDIDATE.sha256 from the same 25 reviewed SOURCE.json rows. Its digest
|
||||
is 6318f0d0bba9ee90ccaa3503cac8c2e04c6397f2ef6c3564f687fbc2092989e3.
|
||||
Ran the queue move from the canonical checkout with op herb-197-review-r3.
|
||||
The move succeeded at revision 2395, opening round 1 on #1695. The request post
|
||||
failed before send because MOSAIC_GITEA_CREDENTIAL_FILE is not set; revision 2396
|
||||
records the failed outcome. Exit code 1 describes the failed post, not a failed
|
||||
row transition. Read-back confirms state in-review and the correct candidate.
|
||||
Sage will post as jarvis and give Webby the request comment number. No credential
|
||||
lookup, alternate post or retry was attempted here.
|
||||
|
||||
SOURCE.json, BUILD.patch, gate.sh and all 25 frozen source rows remain unchanged.
|
||||
Latest slot order is row 190 r2, 201, 204, 200, Gander, then Herb. No gate starts
|
||||
before Sage sends an explicit grant ID.
|
||||
|
||||
## Follow-on assignment
|
||||
|
||||
Sage-1503 assigns row 215, Case 2 C2-04, to Herb after row 197 (queue rev 2455,
|
||||
Gosalyn design r11). Read docs/plans/2026-10-20_case2-rows.md, C2-04 section,
|
||||
at 5b8def2f1 when starting that row. Its required base is origin/refactor
|
||||
305857c8f. This assignment does not replace row 197 or authorize its gate.
|
||||
Keep row 197's approved source frozen while awaiting Sage's grant. Start row
|
||||
215 only after row 197, using a new branch name under the existing fleet rule.
|
||||
|
||||
## Round 2 merge preparation on row 233
|
||||
|
||||
Sage-1757 replaces history replay with one candidate commit on 57f59e0c3.
|
||||
The attempted history replay on herb/row197-r2-233 had already finished when
|
||||
that instruction arrived. Its only conflict was the append-only BUILD-LOG tail.
|
||||
That branch remains for history; none of its replayed commits is in this round.
|
||||
The current branch is herb/row197-r3-233, directly from
|
||||
57f59e0c399ce29b1bb85ca6af686fdc10eb195d.
|
||||
|
||||
Stash correction: shared refs/stash was used incorrectly. The exact entry
|
||||
2767b88549dfd8c7e810908b5416876fa50cb571 is now kept on
|
||||
herb/row197-r3-wip. After verifying stash@{0} still named it, only that entry was
|
||||
dropped. The two other entries remain. No further stash creation, force push,
|
||||
branch deletion or refs/stash mutation is planned. A separate working-copy
|
||||
backup is /mnt/storage/scratch/herb197-merge-W4oiLg.
|
||||
|
||||
Sage-1760 requires preserving every landed hunk on every changed manifest path.
|
||||
Checked each of the 25 r1 paths with git diff aa1bd209e 57f59e0c3 -- PATH.
|
||||
Exactly three changed upstream. Their r2 diff against 57f59e0c3 is captured in
|
||||
r2-merge-only.patch:
|
||||
|
||||
| Merged path | Only row 197 changes against landed base | Landed content retained |
|
||||
| --- | --- | --- |
|
||||
| packages/cli/tests/stack-release.test.mjs | leasePort and laneAfter imports; freePort now calls the lease helper | All landed tests, including row 233's tests after R15 and BACKUP_STORE checks |
|
||||
| packages/webui/tests/service.test.mjs | leasePort and laneAfter imports; closedPort now calls the lease helper | SEND_REFUSAL permits --sso; default unit rendering removes the @CONFIG_ENV@ line |
|
||||
| scripts/test-discord.sh | fixture PI_CODING_AGENT_DIR setup; optional two-worker nested Node cap | Row 190's absent scratch MOSAIC_CONFIG selection and caller-config restoration |
|
||||
|
||||
The other 22 paths equal the r1 reviewed source bytes and modes. r2-MERGE.json
|
||||
lists every path, its r1/r2 digest, mode, upstream-change flag and equality result.
|
||||
Source copies came from the hash-verified r1 frozen checkout. Original r1 digest:
|
||||
6318f0d0bba9ee90ccaa3503cac8c2e04c6397f2ef6c3564f687fbc2092989e3.
|
||||
No existing assertion or timeout line differs from the landed base. Static
|
||||
checks only: 20 module syntax checks, four shell syntax checks and git diff check
|
||||
pass. BUILD-LOG.md equals 57f59e0c3 byte for byte and is outside this commit.
|
||||
|
||||
Sage explicitly requested the local candidate SHA before granting tests. This
|
||||
commit is an untested preparation checkpoint, not gate evidence. No tests or
|
||||
push are authorized yet. r2-TEST-FILES.txt lists the requested grant scope:
|
||||
16 Node test files singly and scripts/test-discord.sh, test-foundation.sh and
|
||||
test-queue.sh. The four stack-release consumers are stack-release.test.mjs,
|
||||
stack-release-external.test.mjs, stack-upgrade.test.mjs and stack-instances.test.mjs.
|
||||
The shared stack-release-fixture.mjs is exercised by its importing test files,
|
||||
not run as a standalone test. This covers all 13 touched Node test files plus
|
||||
three additional stack-release consumers and all three touched shell suites.
|
||||
|
||||
Current slot order from sage-1760: Gizmoduck, Launchpad, Darkwing, then Herb,
|
||||
around 06:30Z. Wait for an explicit grant. After granted checks and authorized
|
||||
push, build the new candidate manifest from that pushed tip and open the
|
||||
merge-scoped round 2. Old SOURCE.json, CANDIDATE.sha256 and gate.sh remain r1
|
||||
historical artifacts and are not a clear to run the merged candidate.
|
||||
|
||||
## Round 2 rebuilt on row 234
|
||||
|
||||
Sage-1776 names landing c771a36f4bd0b6c359282425c668996a90072f93.
|
||||
Built a new branch, herb/row197-r4-234, directly from that landing. No history
|
||||
replay, stash, force push, branch deletion, test run or push is involved.
|
||||
The prior candidate a3b7cbde8 remains untested and is not the queued candidate.
|
||||
|
||||
Checked git diff --name-only 57f59e0c3 c771a36f4 against all 25 source paths.
|
||||
Only packages/cli/tests/stack-release.test.mjs overlaps; the other 24 do not.
|
||||
The full comparison is in r2-on234-OVERLAP.txt. The stack-release file starts
|
||||
from the landing and keeps both T46 untimed entries, host_grants and hosts.
|
||||
Its only changes against the landing are the leasePort and laneAfter imports
|
||||
and freePort's call to the lease helper. r2-on234-merge-only.patch records those
|
||||
hunks. Existing assertions and timeouts match the landing exactly.
|
||||
|
||||
Every other source path equals a3b7cbde8 byte for byte and retains its mode.
|
||||
This includes the previously merged service and test-discord files, preserving
|
||||
all landed --sso, CONFIG_ENV and scratch-config handling. The earlier evidence
|
||||
r2-MERGE.json still records those three pre-234 merge cases. The new
|
||||
r2-on234-MERGE.json records each current digest, prior digest, mode and equality
|
||||
result. BUILD-LOG.md equals the new landing byte for byte and is excluded.
|
||||
|
||||
Static check of the changed Node file and the candidate diff pass. No tests
|
||||
were run. The scope stays r2-TEST-FILES.txt: 16 Node files singly plus the three
|
||||
shell suites. No manifest or queue round is rebuilt until granted checks and
|
||||
authorized push. Old SOURCE.json, CANDIDATE.sha256 and gate.sh remain historical
|
||||
r1 evidence. The test slot follows Darkwing 240 and needs Sage's explicit grant.
|
||||
|
||||
|
||||
## Round 6 preparation (sage-1883, mos-1162)
|
||||
|
||||
New branch herb/row197-r6 starts at fefd54e13, after the standalone row
|
||||
212 landing ee7b2f137. No r5 manifest path changed between e7078e6a8 and
|
||||
this base; all 32 r5 blobs and modes were copied intact before the two
|
||||
umask changes. No history replay, stash, force push or branch deletion.
|
||||
|
||||
executeSteps now launches each argv through /bin/bash with umask 022
|
||||
and exec, preserving the process group and argv boundaries. The supervisor
|
||||
and gate-lane.sh retain 077, including state, locks, private logs and roots.
|
||||
New gate-lane tests assert the actual child umask and created file mode,
|
||||
the unchanged parent mask, private log mode, and actual wrapper-created
|
||||
state/lock modes. No shell, image preparation, credentials test or other
|
||||
existing assertions changed. process.test.mjs equals the landed base;
|
||||
lane-image.mjs equals r5. N7 remains DEFERRED at fefd54e13; rows 243 and
|
||||
244 own the explicit chmod and PI_AUTH_FILE fixtures respectively.
|
||||
|
||||
Read Sage's permitted failed pkg-bus log: process.test startup token refusal
|
||||
saw ready.ok true, consistent with inherited 077 narrowing its fixture mode.
|
||||
No further diagnostic read of the retained ga-8I3iZc root is needed.
|
||||
No r6 tests or probes have run. Source commit, push and new manifest await
|
||||
the explicit granted checks. r6-OVERLAP.json records the empty overlap.
|
||||
|
||||
|
||||
Sage-1892 focused grant: gate-lane.test whole 13/0, process.test whole
|
||||
through executeSteps 8/0, zero skips/cancellations. Syntax checks passed.
|
||||
All new umask/mode assertions passed. No Docker, image, lane or proof run.
|
||||
Empty /proc cwd/argv scan and removal recorded in r6-granted/cleanup.json;
|
||||
clear herb-0068 at 09:21Z. The source matches r5 on 29 of its 32 paths,
|
||||
including modes. Only BUILD.md, gate-lane.test.mjs and gate-lane.mjs differ.
|
||||
The new r6-OVERLAP.json records the base comparison. No process test edit.
|
||||
Sage-1895 authorizes one source commit and seat-only push after range check.
|
||||
|
||||
|
||||
## Round 7 preparation (sage-1951, sage-1955)
|
||||
|
||||
The r6 proof passed all package suites with the original process.test,
|
||||
then failed script-release. The guard injected --no-build into compose run;
|
||||
Compose 5.5.1 rejects it before container creation. The old unit test only
|
||||
asserted the same invalid argv, so it could not detect this compatibility
|
||||
error. This supersedes no-build-flag claims in the historical packet.
|
||||
|
||||
herb/row197-r7 starts at b1fece6f0. No r6 manifest path changed between
|
||||
fefd54e13 and this base; all 33 r6 blobs/modes were copied intact before
|
||||
r7 tests were authored. No history replay, stash, force push or deletion.
|
||||
Sage-1955 accepts the private tempRoot/compose-no-build.yaml override,
|
||||
mode0600, with build: !reset null and image pinned to the receipt's imageId.
|
||||
The guard owns the -f arguments and forces run --pull never --no-deps.
|
||||
Caller-supplied Compose config/build/pull options remain refused.
|
||||
The existing proof removes successful temp roots and retains failed roots;
|
||||
the override stays within that same cleanup ownership.
|
||||
|
||||
Tests first: installed-binary run --help before SERVICE checks parser
|
||||
acceptance without starting a container. A separate compose config --format
|
||||
json check uses the actual candidate compose.yaml, made-up env/auth paths,
|
||||
and an empty Docker config directory, asserting no build key and the exact
|
||||
pinned image. The no-build-restoration mutant must fail that parser test.
|
||||
No test, probe or Docker command has run on r7; waiting for Sage's grant.
|
||||
|
||||
Primary references: Docker's merge reference documents !reset null:
|
||||
https://docs.docker.com/reference/compose-file/merge/#reset-value
|
||||
Docker's run reference documents --pull and --no-deps, with no --no-build:
|
||||
https://docs.docker.com/reference/cli/docker/compose/run/
|
||||
Installed 5.5.1 acceptance and merged configuration are still unverified.
|
||||
|
||||
|
||||
Sage-1956 focused grant, 2026-10-21 11:19-11:21Z: busy check CLEAR.
|
||||
Whole gate-lane-image.test on the r6 guard: 9 pass, 2 fail. The installed
|
||||
Compose parser emitted unknown flag: --no-build; the other expected failure
|
||||
was the missing override helper. No cancellation or skip. After the fix,
|
||||
whole file 11/0. The committed r7-no-build.mutant.patch restores the invalid
|
||||
flag. Applied only for the installed parser regression: 0/1 with the same
|
||||
unknown-flag error, killed. Restored source and whole file passed 11/0 again.
|
||||
Both approved node --check commands passed. Empty /proc cwd/argv scan;
|
||||
clear herb-0073 at 11:21:04Z. No container start, image build, lane, proof or
|
||||
full suite ran. Logs/counts/scan are in r7-granted/ (local run evidence).
|
||||
|
||||
The guard supplies both absolute -f paths, suppressing automatic override
|
||||
selection, pins receipt imageId, removes build via !reset null and adds
|
||||
--pull never --no-deps. Its main entry publishes the override before Docker
|
||||
runs. A unique private staging file plus hard-link publication prevents
|
||||
partial writes or replacement under concurrent guard calls; existing bytes,
|
||||
mode0600, file type and ownership must match, with O_NOFOLLOW on reads.
|
||||
The file stays under the run's short private tempRoot for existing proof
|
||||
cleanup. Caller Compose options remain allowlisted and config/build/pull
|
||||
options refuse. The actual installed Compose config test reports no build
|
||||
key and the exact image ID; parser help is before SERVICE, so no container
|
||||
operation can run. Fixtures supply made-up env/auth paths and empty Docker
|
||||
configuration. Existing image provenance, cleanup and umask behavior remain.
|
||||
|
||||
Range check: one source commit on b1fece6f0, same 33 r6 paths plus the
|
||||
mutant patch and r7 overlap receipt (35 paths). Thirty r6 paths retain the
|
||||
same blob/mode. Only lane-docker.mjs, gate-lane-image.test.mjs and BUILD.md
|
||||
differ from r6. process.test and BUILD-LOG equal the landing. No force push,
|
||||
branch deletion, history replay or other source edits. Sage-1956 authorizes
|
||||
the source commit and seat-only push; fresh proof remains Sage's action.
|
||||
|
||||
|
||||
## Round 8 preparation (sage-1979)
|
||||
|
||||
The r7 D5 proof passed business and bus, then failed the two installed
|
||||
Compose probes in pkg-cli. Their subprocess fixture retained the lane's
|
||||
shim-leading PATH but omitted MOSAIC_GATE_DOCKER_RECORD, so the shim read
|
||||
an undefined path. This is a test context error; the production guard stays
|
||||
byte-identical to r7.
|
||||
|
||||
New herb/row197-r8 starts at 5c6f73ed7. None of r7's 35 manifest paths
|
||||
changed from b1fece6f0 to this base. Tests choose the record's real absolute
|
||||
Docker path when a lane record is set; outside lanes they keep using docker.
|
||||
Only the two read-only help/config probes use this helper. The subprocess
|
||||
env still holds fixture-only configuration; the record need not propagate
|
||||
to it. No run or policy allowlist is widened.
|
||||
|
||||
Sage-1979 grants 215's 16 singles first, then r8's normal and lane-shaped
|
||||
shim-leading PATH whole-file checks and the record-lookup-removal mutant.
|
||||
The shim fixture invokes the actual unchanged guard, with a private made-up
|
||||
record naming the real Docker binary. No image build, container start, lane
|
||||
or proof is part of r8 checks. No r8 checks have started yet.
|
||||
|
||||
|
||||
Sage-1979 r8 checks followed 215's early singles, then clear herb-0078 at
|
||||
2026-10-21 12:09:28Z. Whole gate-lane-image.test normal PATH11/0; whole
|
||||
actual guard-shim-leading PATH plus private record11/0. The committed
|
||||
r8-record-lookup.mutant.patch removes the lookup: installed-Compose argv
|
||||
regression0/1, killed with the original Received undefined path error.
|
||||
Restored code, repeated whole shim-PATH file11/0. All zero skips/cancellations.
|
||||
Logs/counts/fixture metadata/empty /proc cwd+argv scan are in r8-granted/.
|
||||
Both 215 and r8 private roots were removed after an empty process scan.
|
||||
No image build, container start, suite, lane or proof ran for r8.
|
||||
|
||||
Range: one commit on5c6f73ed7. Of r7's35 paths,33 keep the same blob/mode;
|
||||
only gate-lane-image.test and this BUILD.md change. The r8 mutant and overlap
|
||||
receipt are added. The production guard and all other tooling match r7.
|
||||
BUILD-LOG and process.test equal the landing. No history replay, force push,
|
||||
branch deletion, live state change or policy widening. Fresh proof remains
|
||||
Sage's action after Webby's merge review.
|
||||
@@ -0,0 +1,207 @@
|
||||
{
|
||||
"r1Base": "aa1bd209e",
|
||||
"r1Candidate": "6318f0d0bba9ee90ccaa3503cac8c2e04c6397f2ef6c3564f687fbc2092989e3",
|
||||
"r2Base": "57f59e0c399ce29b1bb85ca6af686fdc10eb195d",
|
||||
"rows": [
|
||||
{
|
||||
"path": "packages/cli/tests/fixtures/gate-lane-child.mjs",
|
||||
"r1Sha256": "209682b968e929da4b46dc97c64fcd7aa68c490f3c6be44595f5d85f8d2e1483",
|
||||
"r2Sha256": "209682b968e929da4b46dc97c64fcd7aa68c490f3c6be44595f5d85f8d2e1483",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/gate-lane-fixtures.test.mjs",
|
||||
"r1Sha256": "29afa181804a724c695678eb9459c156e00f1084647c704e814c5bee156c0c25",
|
||||
"r2Sha256": "29afa181804a724c695678eb9459c156e00f1084647c704e814c5bee156c0c25",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/gate-lane-image.test.mjs",
|
||||
"r1Sha256": "0253621a49c2e77ffcdbf32beee7e1f773ec2c52c8487dcc5aa254e61807af7c",
|
||||
"r2Sha256": "0253621a49c2e77ffcdbf32beee7e1f773ec2c52c8487dcc5aa254e61807af7c",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/gate-lane-state.test.mjs",
|
||||
"r1Sha256": "948d62c76447d8269f03518562a261564c202ec9207461fb21d2b1c11c9bb9fc",
|
||||
"r2Sha256": "948d62c76447d8269f03518562a261564c202ec9207461fb21d2b1c11c9bb9fc",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/gate-lane.test.mjs",
|
||||
"r1Sha256": "240bf0c15025ad6f88b251917a3c8d72bd21c2e5df6771e1ceb83a1d90b24a51",
|
||||
"r2Sha256": "240bf0c15025ad6f88b251917a3c8d72bd21c2e5df6771e1ceb83a1d90b24a51",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/stack-release.test.mjs",
|
||||
"r1Sha256": "8ca733a600d5f5534d7e14d8533e1b16d710ebd43080ef8421238b5dd145fade",
|
||||
"r2Sha256": "b95c0c125b6a283961e24adc07d43150bcbd05d2f11005180b836043015c52a6",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": true,
|
||||
"r1BlobEqual": false
|
||||
},
|
||||
{
|
||||
"path": "packages/control-board/tests/serve.test.mjs",
|
||||
"r1Sha256": "4838cb4609da28fc921370c5542a82402fbc9ceb469692a36f0eafc713bb56c8",
|
||||
"r2Sha256": "4838cb4609da28fc921370c5542a82402fbc9ceb469692a36f0eafc713bb56c8",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/harness/tests/claude-session.test.mjs",
|
||||
"r1Sha256": "0a09b0255e9144a039428eb8e9f56181f94ccb2d187e474ea13392b485fdd008",
|
||||
"r2Sha256": "0a09b0255e9144a039428eb8e9f56181f94ccb2d187e474ea13392b485fdd008",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/harness/tests/pi-session.test.mjs",
|
||||
"r1Sha256": "6db5c3b7063baf933061e91e8bdf66740717dbb3066be3b70ac779fb17022094",
|
||||
"r2Sha256": "6db5c3b7063baf933061e91e8bdf66740717dbb3066be3b70ac779fb17022094",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/preview.test.mjs",
|
||||
"r1Sha256": "ada49ec100d75b3e739263593164b2798c378f3df0f38d73d80cf011ef16e234",
|
||||
"r2Sha256": "ada49ec100d75b3e739263593164b2798c378f3df0f38d73d80cf011ef16e234",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/proof-cli.test.mjs",
|
||||
"r1Sha256": "d3cec62f6eb8e2c1e0747f2a1bf1fc3f1a2122e45870d5a416d1830d3b6182cb",
|
||||
"r2Sha256": "d3cec62f6eb8e2c1e0747f2a1bf1fc3f1a2122e45870d5a416d1830d3b6182cb",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/serve.test.mjs",
|
||||
"r1Sha256": "36ecdfcb3d0d9a2411eec751421f770b5bb41ec2fbf9236c5794fb241fd3196b",
|
||||
"r2Sha256": "36ecdfcb3d0d9a2411eec751421f770b5bb41ec2fbf9236c5794fb241fd3196b",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/service.test.mjs",
|
||||
"r1Sha256": "c915239f0c223209baf84c96ec9f5ae2845fce7dd98ad46b073a96033aa7d6df",
|
||||
"r2Sha256": "845313239547ddbc478090b29f96385f0b8853e5087c572393be328e7075f578",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": true,
|
||||
"r1BlobEqual": false
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/tester-cli.test.mjs",
|
||||
"r1Sha256": "8bf4491f7982399b4162cfbe36d0b68888e99ceb2cbaefefc2361732dd95e0c8",
|
||||
"r2Sha256": "8bf4491f7982399b4162cfbe36d0b68888e99ceb2cbaefefc2361732dd95e0c8",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/test-discord.sh",
|
||||
"r1Sha256": "c10af53b0717344026cb22462964f253c7401c5082ac6ef3722a89d9dfa5c661",
|
||||
"r2Sha256": "a5dcf783c51a5d5d184a357f91ebf9fa1c4e054f0cb84a3d1d7668f6156e025e",
|
||||
"mode": "0o755",
|
||||
"landedPathChanged": true,
|
||||
"r1BlobEqual": false
|
||||
},
|
||||
{
|
||||
"path": "scripts/test-foundation.sh",
|
||||
"r1Sha256": "2e11038e7f784ec4f539eb768f63bab0fab9c9940334388a1fe2d47ab9e2b008",
|
||||
"r2Sha256": "2e11038e7f784ec4f539eb768f63bab0fab9c9940334388a1fe2d47ab9e2b008",
|
||||
"mode": "0o755",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/test-queue.sh",
|
||||
"r1Sha256": "c270f181d1fa8b6d42ff4131c339beba786a8fd620bb66f8afdcec05e8b10868",
|
||||
"r2Sha256": "c270f181d1fa8b6d42ff4131c339beba786a8fd620bb66f8afdcec05e8b10868",
|
||||
"mode": "0o755",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/README.md",
|
||||
"r1Sha256": "845db994e648f29f10efb5e6a4ddcf4be4e28798fc27f1eddf9d150f0e8b5319",
|
||||
"r2Sha256": "845db994e648f29f10efb5e6a4ddcf4be4e28798fc27f1eddf9d150f0e8b5319",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/gate-lane.mjs",
|
||||
"r1Sha256": "2e5decf42a546b1441c199337c9a3162693051f2299878f617dd039d6231f226",
|
||||
"r2Sha256": "2e5decf42a546b1441c199337c9a3162693051f2299878f617dd039d6231f226",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/gate-lane.sh",
|
||||
"r1Sha256": "41804d964c55dd07b6224855aae9dc44037f4e80c515c10f98fe04d6d3fa0084",
|
||||
"r2Sha256": "41804d964c55dd07b6224855aae9dc44037f4e80c515c10f98fe04d6d3fa0084",
|
||||
"mode": "0o755",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/lane-docker.mjs",
|
||||
"r1Sha256": "fbe54165439923a852f64d69a6188ef29d237755289e9e21ddbfdab00e9ef933",
|
||||
"r2Sha256": "fbe54165439923a852f64d69a6188ef29d237755289e9e21ddbfdab00e9ef933",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/lane-evidence.mjs",
|
||||
"r1Sha256": "51d4dcff4ab01730b923e0a59c218f9a3d33e6ba729be1def03703bc457fb37e",
|
||||
"r2Sha256": "51d4dcff4ab01730b923e0a59c218f9a3d33e6ba729be1def03703bc457fb37e",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/lane-fixtures.mjs",
|
||||
"r1Sha256": "8634070d68bca694c8a58911eda9576991ed0eca1eb055a2c1bbf10261305939",
|
||||
"r2Sha256": "8634070d68bca694c8a58911eda9576991ed0eca1eb055a2c1bbf10261305939",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/lane-image.mjs",
|
||||
"r1Sha256": "2259045913b4e3042b7afd3ff6a0fb9c2cc9b4cf359f943e4dce9b8658b91982",
|
||||
"r2Sha256": "2259045913b4e3042b7afd3ff6a0fb9c2cc9b4cf359f943e4dce9b8658b91982",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/lane-state.mjs",
|
||||
"r1Sha256": "c37b3d5ec3025948dd8150d18d4ccc4eeb3539739ca37e4d86edede5bcafd1a8",
|
||||
"r2Sha256": "c37b3d5ec3025948dd8150d18d4ccc4eeb3539739ca37e4d86edede5bcafd1a8",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
packages/cli/tests/gate-lane-fixtures.test.mjs
|
||||
packages/cli/tests/gate-lane-image.test.mjs
|
||||
packages/cli/tests/gate-lane-state.test.mjs
|
||||
packages/cli/tests/gate-lane.test.mjs
|
||||
packages/cli/tests/stack-instances.test.mjs
|
||||
packages/cli/tests/stack-release-external.test.mjs
|
||||
packages/cli/tests/stack-release.test.mjs
|
||||
packages/cli/tests/stack-upgrade.test.mjs
|
||||
packages/control-board/tests/serve.test.mjs
|
||||
packages/harness/tests/claude-session.test.mjs
|
||||
packages/harness/tests/pi-session.test.mjs
|
||||
packages/webui/tests/preview.test.mjs
|
||||
packages/webui/tests/proof-cli.test.mjs
|
||||
packages/webui/tests/serve.test.mjs
|
||||
packages/webui/tests/service.test.mjs
|
||||
packages/webui/tests/tester-cli.test.mjs
|
||||
scripts/test-discord.sh
|
||||
scripts/test-foundation.sh
|
||||
scripts/test-queue.sh
|
||||
@@ -0,0 +1,45 @@
|
||||
diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs
|
||||
index 8788158c3..a78f52062 100644
|
||||
--- a/packages/cli/tests/stack-release.test.mjs
|
||||
+++ b/packages/cli/tests/stack-release.test.mjs
|
||||
@@ -0,0 +1,2 @@
|
||||
+import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
+import { after as laneAfter } from 'node:test';
|
||||
@@ -1777 +1779 @@ const TESTER='7499';
|
||||
-const freePort=async()=>{const s=createServer();s.listen(0,'127.0.0.1');await once(s,'listening');const p=String(s.address().port);await new Promise((r)=>s.close(r));return p};
|
||||
+const freePort=async()=>String(await leasePort({after:laneAfter},{closed:true}));
|
||||
diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs
|
||||
index a6e66608a..adb460d07 100644
|
||||
--- a/packages/webui/tests/service.test.mjs
|
||||
+++ b/packages/webui/tests/service.test.mjs
|
||||
@@ -0,0 +1,2 @@
|
||||
+import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
+import { after as laneAfter } from 'node:test';
|
||||
@@ -48,9 +50 @@ function filled(flags, root = REPO) {
|
||||
-async function closedPort() {
|
||||
- const server = createServer();
|
||||
- server.listen(0, '127.0.0.1');
|
||||
- await once(server, 'listening');
|
||||
- const { port } = server.address();
|
||||
- server.close();
|
||||
- await once(server, 'close');
|
||||
- return port;
|
||||
-}
|
||||
+async function closedPort() { return leasePort({after:laneAfter}, {closed:true}); }
|
||||
diff --git a/scripts/test-discord.sh b/scripts/test-discord.sh
|
||||
index 114994936..4daf0d143 100755
|
||||
--- a/scripts/test-discord.sh
|
||||
+++ b/scripts/test-discord.sh
|
||||
@@ -73,0 +74,2 @@ TOOLS_JSON="{\"roots\":[{\"name\":\"docs\",\"path\":\"$SANDBOX/toolroot\"}],\"ma
|
||||
+export PI_CODING_AGENT_DIR="$SANDBOX/pi-agent"
|
||||
+mkdir -p "$PI_CODING_AGENT_DIR"
|
||||
@@ -145 +147,8 @@ fi
|
||||
-node_tests() { env -u NODE_TEST_CONTEXT node --test "$@"; }
|
||||
+node_tests() {
|
||||
+ local lane_concurrency=()
|
||||
+ if [ -n "${MOSAIC_TEST_CONCURRENCY:-}" ]; then
|
||||
+ [ "$MOSAIC_TEST_CONCURRENCY" = 2 ] || return 2
|
||||
+ lane_concurrency=(--test-concurrency=2)
|
||||
+ fi
|
||||
+ env -u NODE_TEST_CONTEXT node --test "${lane_concurrency[@]}" "$@"
|
||||
+}
|
||||
@@ -0,0 +1,235 @@
|
||||
{
|
||||
"base": "c771a36f4bd0b6c359282425c668996a90072f93",
|
||||
"priorCandidate": "a3b7cbde8ef51c206a83769a7c272e4f9a1de5bc",
|
||||
"originalCandidate": "6318f0d0bba9ee90ccaa3503cac8c2e04c6397f2ef6c3564f687fbc2092989e3",
|
||||
"overlap": [
|
||||
"packages/cli/tests/stack-release.test.mjs"
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"path": "packages/cli/tests/fixtures/gate-lane-child.mjs",
|
||||
"r1Sha256": "209682b968e929da4b46dc97c64fcd7aa68c490f3c6be44595f5d85f8d2e1483",
|
||||
"priorCandidateSha256": "209682b968e929da4b46dc97c64fcd7aa68c490f3c6be44595f5d85f8d2e1483",
|
||||
"newSha256": "209682b968e929da4b46dc97c64fcd7aa68c490f3c6be44595f5d85f8d2e1483",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/gate-lane-fixtures.test.mjs",
|
||||
"r1Sha256": "29afa181804a724c695678eb9459c156e00f1084647c704e814c5bee156c0c25",
|
||||
"priorCandidateSha256": "29afa181804a724c695678eb9459c156e00f1084647c704e814c5bee156c0c25",
|
||||
"newSha256": "29afa181804a724c695678eb9459c156e00f1084647c704e814c5bee156c0c25",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/gate-lane-image.test.mjs",
|
||||
"r1Sha256": "0253621a49c2e77ffcdbf32beee7e1f773ec2c52c8487dcc5aa254e61807af7c",
|
||||
"priorCandidateSha256": "0253621a49c2e77ffcdbf32beee7e1f773ec2c52c8487dcc5aa254e61807af7c",
|
||||
"newSha256": "0253621a49c2e77ffcdbf32beee7e1f773ec2c52c8487dcc5aa254e61807af7c",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/gate-lane-state.test.mjs",
|
||||
"r1Sha256": "948d62c76447d8269f03518562a261564c202ec9207461fb21d2b1c11c9bb9fc",
|
||||
"priorCandidateSha256": "948d62c76447d8269f03518562a261564c202ec9207461fb21d2b1c11c9bb9fc",
|
||||
"newSha256": "948d62c76447d8269f03518562a261564c202ec9207461fb21d2b1c11c9bb9fc",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/gate-lane.test.mjs",
|
||||
"r1Sha256": "240bf0c15025ad6f88b251917a3c8d72bd21c2e5df6771e1ceb83a1d90b24a51",
|
||||
"priorCandidateSha256": "240bf0c15025ad6f88b251917a3c8d72bd21c2e5df6771e1ceb83a1d90b24a51",
|
||||
"newSha256": "240bf0c15025ad6f88b251917a3c8d72bd21c2e5df6771e1ceb83a1d90b24a51",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/stack-release.test.mjs",
|
||||
"r1Sha256": "8ca733a600d5f5534d7e14d8533e1b16d710ebd43080ef8421238b5dd145fade",
|
||||
"priorCandidateSha256": "b95c0c125b6a283961e24adc07d43150bcbd05d2f11005180b836043015c52a6",
|
||||
"newSha256": "071ea2803b8c7e0450bb4c86ac9b49ae7c180af1599dca52bfce6d0274ca205b",
|
||||
"unchangedFromPrior": false,
|
||||
"overlaps234": true,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/control-board/tests/serve.test.mjs",
|
||||
"r1Sha256": "4838cb4609da28fc921370c5542a82402fbc9ceb469692a36f0eafc713bb56c8",
|
||||
"priorCandidateSha256": "4838cb4609da28fc921370c5542a82402fbc9ceb469692a36f0eafc713bb56c8",
|
||||
"newSha256": "4838cb4609da28fc921370c5542a82402fbc9ceb469692a36f0eafc713bb56c8",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/harness/tests/claude-session.test.mjs",
|
||||
"r1Sha256": "0a09b0255e9144a039428eb8e9f56181f94ccb2d187e474ea13392b485fdd008",
|
||||
"priorCandidateSha256": "0a09b0255e9144a039428eb8e9f56181f94ccb2d187e474ea13392b485fdd008",
|
||||
"newSha256": "0a09b0255e9144a039428eb8e9f56181f94ccb2d187e474ea13392b485fdd008",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/harness/tests/pi-session.test.mjs",
|
||||
"r1Sha256": "6db5c3b7063baf933061e91e8bdf66740717dbb3066be3b70ac779fb17022094",
|
||||
"priorCandidateSha256": "6db5c3b7063baf933061e91e8bdf66740717dbb3066be3b70ac779fb17022094",
|
||||
"newSha256": "6db5c3b7063baf933061e91e8bdf66740717dbb3066be3b70ac779fb17022094",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/preview.test.mjs",
|
||||
"r1Sha256": "ada49ec100d75b3e739263593164b2798c378f3df0f38d73d80cf011ef16e234",
|
||||
"priorCandidateSha256": "ada49ec100d75b3e739263593164b2798c378f3df0f38d73d80cf011ef16e234",
|
||||
"newSha256": "ada49ec100d75b3e739263593164b2798c378f3df0f38d73d80cf011ef16e234",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/proof-cli.test.mjs",
|
||||
"r1Sha256": "d3cec62f6eb8e2c1e0747f2a1bf1fc3f1a2122e45870d5a416d1830d3b6182cb",
|
||||
"priorCandidateSha256": "d3cec62f6eb8e2c1e0747f2a1bf1fc3f1a2122e45870d5a416d1830d3b6182cb",
|
||||
"newSha256": "d3cec62f6eb8e2c1e0747f2a1bf1fc3f1a2122e45870d5a416d1830d3b6182cb",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/serve.test.mjs",
|
||||
"r1Sha256": "36ecdfcb3d0d9a2411eec751421f770b5bb41ec2fbf9236c5794fb241fd3196b",
|
||||
"priorCandidateSha256": "36ecdfcb3d0d9a2411eec751421f770b5bb41ec2fbf9236c5794fb241fd3196b",
|
||||
"newSha256": "36ecdfcb3d0d9a2411eec751421f770b5bb41ec2fbf9236c5794fb241fd3196b",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/service.test.mjs",
|
||||
"r1Sha256": "c915239f0c223209baf84c96ec9f5ae2845fce7dd98ad46b073a96033aa7d6df",
|
||||
"priorCandidateSha256": "845313239547ddbc478090b29f96385f0b8853e5087c572393be328e7075f578",
|
||||
"newSha256": "845313239547ddbc478090b29f96385f0b8853e5087c572393be328e7075f578",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/tester-cli.test.mjs",
|
||||
"r1Sha256": "8bf4491f7982399b4162cfbe36d0b68888e99ceb2cbaefefc2361732dd95e0c8",
|
||||
"priorCandidateSha256": "8bf4491f7982399b4162cfbe36d0b68888e99ceb2cbaefefc2361732dd95e0c8",
|
||||
"newSha256": "8bf4491f7982399b4162cfbe36d0b68888e99ceb2cbaefefc2361732dd95e0c8",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "scripts/test-discord.sh",
|
||||
"r1Sha256": "c10af53b0717344026cb22462964f253c7401c5082ac6ef3722a89d9dfa5c661",
|
||||
"priorCandidateSha256": "a5dcf783c51a5d5d184a357f91ebf9fa1c4e054f0cb84a3d1d7668f6156e025e",
|
||||
"newSha256": "a5dcf783c51a5d5d184a357f91ebf9fa1c4e054f0cb84a3d1d7668f6156e025e",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o755"
|
||||
},
|
||||
{
|
||||
"path": "scripts/test-foundation.sh",
|
||||
"r1Sha256": "2e11038e7f784ec4f539eb768f63bab0fab9c9940334388a1fe2d47ab9e2b008",
|
||||
"priorCandidateSha256": "2e11038e7f784ec4f539eb768f63bab0fab9c9940334388a1fe2d47ab9e2b008",
|
||||
"newSha256": "2e11038e7f784ec4f539eb768f63bab0fab9c9940334388a1fe2d47ab9e2b008",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o755"
|
||||
},
|
||||
{
|
||||
"path": "scripts/test-queue.sh",
|
||||
"r1Sha256": "c270f181d1fa8b6d42ff4131c339beba786a8fd620bb66f8afdcec05e8b10868",
|
||||
"priorCandidateSha256": "c270f181d1fa8b6d42ff4131c339beba786a8fd620bb66f8afdcec05e8b10868",
|
||||
"newSha256": "c270f181d1fa8b6d42ff4131c339beba786a8fd620bb66f8afdcec05e8b10868",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o755"
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/README.md",
|
||||
"r1Sha256": "845db994e648f29f10efb5e6a4ddcf4be4e28798fc27f1eddf9d150f0e8b5319",
|
||||
"priorCandidateSha256": "845db994e648f29f10efb5e6a4ddcf4be4e28798fc27f1eddf9d150f0e8b5319",
|
||||
"newSha256": "845db994e648f29f10efb5e6a4ddcf4be4e28798fc27f1eddf9d150f0e8b5319",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/gate-lane.mjs",
|
||||
"r1Sha256": "2e5decf42a546b1441c199337c9a3162693051f2299878f617dd039d6231f226",
|
||||
"priorCandidateSha256": "2e5decf42a546b1441c199337c9a3162693051f2299878f617dd039d6231f226",
|
||||
"newSha256": "2e5decf42a546b1441c199337c9a3162693051f2299878f617dd039d6231f226",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/gate-lane.sh",
|
||||
"r1Sha256": "41804d964c55dd07b6224855aae9dc44037f4e80c515c10f98fe04d6d3fa0084",
|
||||
"priorCandidateSha256": "41804d964c55dd07b6224855aae9dc44037f4e80c515c10f98fe04d6d3fa0084",
|
||||
"newSha256": "41804d964c55dd07b6224855aae9dc44037f4e80c515c10f98fe04d6d3fa0084",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o755"
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/lane-docker.mjs",
|
||||
"r1Sha256": "fbe54165439923a852f64d69a6188ef29d237755289e9e21ddbfdab00e9ef933",
|
||||
"priorCandidateSha256": "fbe54165439923a852f64d69a6188ef29d237755289e9e21ddbfdab00e9ef933",
|
||||
"newSha256": "fbe54165439923a852f64d69a6188ef29d237755289e9e21ddbfdab00e9ef933",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/lane-evidence.mjs",
|
||||
"r1Sha256": "51d4dcff4ab01730b923e0a59c218f9a3d33e6ba729be1def03703bc457fb37e",
|
||||
"priorCandidateSha256": "51d4dcff4ab01730b923e0a59c218f9a3d33e6ba729be1def03703bc457fb37e",
|
||||
"newSha256": "51d4dcff4ab01730b923e0a59c218f9a3d33e6ba729be1def03703bc457fb37e",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/lane-fixtures.mjs",
|
||||
"r1Sha256": "8634070d68bca694c8a58911eda9576991ed0eca1eb055a2c1bbf10261305939",
|
||||
"priorCandidateSha256": "8634070d68bca694c8a58911eda9576991ed0eca1eb055a2c1bbf10261305939",
|
||||
"newSha256": "8634070d68bca694c8a58911eda9576991ed0eca1eb055a2c1bbf10261305939",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/lane-image.mjs",
|
||||
"r1Sha256": "2259045913b4e3042b7afd3ff6a0fb9c2cc9b4cf359f943e4dce9b8658b91982",
|
||||
"priorCandidateSha256": "2259045913b4e3042b7afd3ff6a0fb9c2cc9b4cf359f943e4dce9b8658b91982",
|
||||
"newSha256": "2259045913b4e3042b7afd3ff6a0fb9c2cc9b4cf359f943e4dce9b8658b91982",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/lane-state.mjs",
|
||||
"r1Sha256": "c37b3d5ec3025948dd8150d18d4ccc4eeb3539739ca37e4d86edede5bcafd1a8",
|
||||
"priorCandidateSha256": "c37b3d5ec3025948dd8150d18d4ccc4eeb3539739ca37e4d86edede5bcafd1a8",
|
||||
"newSha256": "c37b3d5ec3025948dd8150d18d4ccc4eeb3539739ca37e4d86edede5bcafd1a8",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
Compared 57f59e0c3 to c771a36f4bd0b6c359282425c668996a90072f93
|
||||
agents/gizmoduck/work/queue-234/BUILD.md
|
||||
docs/SESSIONS.md
|
||||
packages/bus/README.md
|
||||
packages/bus/schema.sql
|
||||
packages/bus/src/broker.mjs
|
||||
packages/bus/src/credentials.mjs
|
||||
packages/bus/src/host-grant.mjs
|
||||
packages/bus/src/host-run.mjs
|
||||
packages/bus/src/migrations/3i-3j.sql
|
||||
packages/bus/src/runtime.mjs
|
||||
packages/bus/src/server.mjs
|
||||
packages/bus/src/store.mjs
|
||||
packages/bus/tests/account-migration.test.mjs
|
||||
packages/bus/tests/chief-of-staff.test.mjs
|
||||
packages/bus/tests/comment-migration.test.mjs
|
||||
packages/bus/tests/fixtures/schema-3i.sql
|
||||
packages/bus/tests/grant-enroll.test.mjs
|
||||
packages/bus/tests/grant-migration.test.mjs
|
||||
packages/bus/tests/hard-waits.test.mjs
|
||||
packages/bus/tests/host-fixtures.mjs
|
||||
packages/bus/tests/host-run.test.mjs
|
||||
packages/bus/tests/host-tools.test.mjs
|
||||
packages/bus/tests/name-migration.test.mjs
|
||||
packages/bus/tests/project-runtime.test.mjs
|
||||
packages/bus/tests/schema.test.mjs
|
||||
packages/bus/tests/spec-fixtures.mjs
|
||||
packages/bus/tests/spec-migration.test.mjs
|
||||
packages/bus/tests/thread-migration.test.mjs
|
||||
packages/bus/tests/usage-migration.test.mjs
|
||||
packages/business/src/business.mjs
|
||||
packages/business/src/hosts.mjs
|
||||
packages/business/tests/hosts.test.mjs
|
||||
packages/cli/README.md
|
||||
packages/cli/src/cli.mjs
|
||||
packages/cli/src/hosts.mjs
|
||||
packages/cli/tests/hosts.test.mjs
|
||||
packages/cli/tests/stack-release.test.mjs
|
||||
scripts/mosaic
|
||||
tools/host/mosaic-keys
|
||||
tools/host/mosaic-keys-write
|
||||
tools/host/mosaic-keys.sshd.conf
|
||||
tools/host/mosaic-keys.sudoers
|
||||
|
||||
Overlap with row 197 source manifest:
|
||||
packages/cli/tests/stack-release.test.mjs
|
||||
Other 24 source paths: no overlap.
|
||||
@@ -0,0 +1,10 @@
|
||||
diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs
|
||||
index daae6efb7..910785bc4 100644
|
||||
--- a/packages/cli/tests/stack-release.test.mjs
|
||||
+++ b/packages/cli/tests/stack-release.test.mjs
|
||||
@@ -0,0 +1,2 @@
|
||||
+import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
+import { after as laneAfter } from 'node:test';
|
||||
@@ -1779 +1781 @@ const TESTER='7499';
|
||||
-const freePort=async()=>{const s=createServer();s.listen(0,'127.0.0.1');await once(s,'listening');const p=String(s.address().port);await new Promise((r)=>s.close(r));return p};
|
||||
+const freePort=async()=>String(await leasePort({after:laneAfter},{closed:true}));
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"base": "fefd54e13",
|
||||
"prior": "9aa6d52d9",
|
||||
"overlaps": []
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"base": "b1fece6f0",
|
||||
"r6": "df0c59608",
|
||||
"r6Base": "fefd54e13",
|
||||
"overlaps": []
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
--- a/scripts/testing/lane-docker.mjs
|
||||
+++ b/scripts/testing/lane-docker.mjs
|
||||
@@ -34,7 +34,7 @@
|
||||
const service=argv.indexOf('mosaic-agent',2);
|
||||
if(service<0||argv.slice(2,service).some(a=>!['--rm','-T','--no-deps','--interactive','-i'].includes(a)))refuse('Docker Compose run options not allowed');
|
||||
const [base,override]=composeFiles(run);
|
||||
- return ['compose','-f',base,'-f',override,'run','--pull','never','--no-deps',...argv.slice(2)];
|
||||
+ return ['compose','-f',base,'-f',override,'run','--no-build','--pull','never','--no-deps',...argv.slice(2)];
|
||||
}
|
||||
refuse('Docker mutation or unowned command');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"base": "5c6f73ed7",
|
||||
"r7": "9c3166915",
|
||||
"r7Base": "b1fece6f0",
|
||||
"overlaps": []
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
--- a/packages/cli/tests/gate-lane-image.test.mjs
|
||||
+++ b/packages/cli/tests/gate-lane-image.test.mjs
|
||||
@@ -85,7 +85,7 @@
|
||||
// including when the lane's guard is first on PATH. Child fixture env stays
|
||||
// isolated; passing the real absolute path avoids re-entering that guard.
|
||||
function installedDocker(){
|
||||
- const file=process.env.MOSAIC_GATE_DOCKER_RECORD;
|
||||
+ const file=undefined; // mutant: lose the lane's real-binary lookup
|
||||
if(!file)return 'docker';
|
||||
const binary=JSON.parse(readFileSync(file,'utf8')).docker;
|
||||
assert.ok(typeof binary==='string'&&isAbsolute(binary),'lane record must name the real absolute Docker binary');
|
||||
@@ -0,0 +1,128 @@
|
||||
# Row 243: too-open mode refusal tests set their mode with chmod
|
||||
|
||||
Brief: `docs/plans/2026-10-21_gate-env-rows.md` § "The startup token refusal test sets its file mode"
|
||||
(Sage, sage-1909). Reviewer Webby. Lands after row 197 (mos-1162). Design notes written before 197
|
||||
lands, read at `origin/refactor` 01e1fe512. Recheck the line cites against 197's landing before code
|
||||
starts.
|
||||
|
||||
## The defect
|
||||
|
||||
`writeFileSync(file, data, { mode })` and `mkdirSync(dir, { mode })` apply the process umask to the
|
||||
mode. A test that creates a "too open" file this way and expects a refusal passes under `umask 022`
|
||||
(0644 stays 0644) and fails under `umask 077` (0644 becomes 0600, and the source accepts it).
|
||||
|
||||
Row 197 r6 (df0c59608) runs each gate step under `umask 022` (`gate-lane.mjs:46`), so the lane no
|
||||
longer hits this. Row 243 still makes the tests independent of the umask, as the brief asks.
|
||||
|
||||
## Sweep
|
||||
|
||||
Method: every source-side mode check in `packages/*/src` and `scripts/` (grep for `mode & 0o...`), then
|
||||
the tests that reach each check's refusal branch, deciding whether the too-open object gets its mode at
|
||||
creation or from an explicit chmod. Also a scan of every `writeFileSync`/`mkdirSync`/`openSync` in
|
||||
`packages/*/tests` with a group or other bit in its mode (50 hits, 41 without a following chmod), and a
|
||||
grep of the refusal messages for objects created with no mode or by a shell-out. Read at
|
||||
`origin/refactor` with `git show` and `git grep`; nothing run.
|
||||
|
||||
Affected refusal tests, all three verified by reading the test (the fourth file, an accept test, follows the table):
|
||||
|
||||
| Creation | Test | Source check | Expected refusal |
|
||||
|---|---|---|---|
|
||||
| `packages/bus/tests/process.test.mjs:76` `writeFileSync(file, secret, { mode: 0o644 })` | startup token refusal returns safe code... | `bus/src/credentials.mjs:205` (must be 0600) | `credential-file`, exit 2 (:83-84) |
|
||||
| `packages/webui/tests/proof-cli.test.mjs:28` `writeFileSync(file, value, { mode })`, mode 0o644 from the `mode` case at :27 | proof startup refuses partial flags... | `webui/src/serve.mjs:234` (0600 or 0400) | status 2 (:38) |
|
||||
| `packages/webui/tests/sso.test.mjs:360` `writeFileSync(.../secrets/mosaic-stack/a, ..., { mode: 0o644 })` | 11: the CLI refuses --sso... | `webui/src/sso.mjs:45` (no group or other bits) | status 2 and the "no group or other permission bits" line (:361-363); the chmod to 0600 at :364 comes after |
|
||||
|
||||
Fix for each: keep the write, then `chmodSync(<path>, 0o644)` right after it. For `proof-cli.test.mjs`
|
||||
the chmod goes inside the case map after the write, with the case's own mode, so the 0o600 cases are set
|
||||
explicitly too.
|
||||
|
||||
Not affected (each sets the mode with a chmod or a chmodding helper before the code under test reads it):
|
||||
bus credentials.test :52, credential-store.test :55-61; business access.test :225-226, business.test
|
||||
:159/161/174, credentials.test :60, cli.test :151, helpers `writeJson`/`tokenFile`; cli cli.test :176,
|
||||
notifier.test :318/:321, stack-release.test :1220/1239/1294/1313/1552, stack-upgrade.test :426-430,
|
||||
case2-unit.test :340/:510/:635; control-board discord.test :79; discord binding.test :106/118/140/277,
|
||||
notify.test :76, setspark.test :110, git.test :138/:360, offline-check.test :21; mosaic safety.test :80;
|
||||
queue review.test :214/:228; ledger gitea-helper-raw.test :113; webui service.test :797, sso.test :310;
|
||||
bus host-tools.test :182/:266. The 0o644/0o755 adapter and fake-binary writes (launcher, thread-launch,
|
||||
session, sandbox, checkout, commit, etc.) aren't read by a mode check, or are only checked for the exec
|
||||
bit, which 0700 keeps. `host-tools.test.mjs:205`, `:271` and the confDir at `:77` expect acceptance and
|
||||
only need "not group-writable".
|
||||
|
||||
Mode checks with no refusal test at all (not this row; listed for the record): `bus/src/store.mjs:121`,
|
||||
`bus/src/host-run.mjs:71`, `business/src/organization.mjs:28/45/109/141`,
|
||||
`conversation/src/controller.mjs:299`, `mosaic/src/materialize-fixture.mjs:16`,
|
||||
`mosaic/src/refresh-fixture.mjs:64`, `seat/src/session.mjs:72`, `scripts/git-credential-mosaic.mjs:49`,
|
||||
and the mode branch of `scripts/stack-release.sh:1952`.
|
||||
|
||||
Fourth file, folded in by Sage (sage-1918): `packages/cli/tests/stack-instances.test.mjs:84` and `:89`
|
||||
assert 0644 on the config and `instance.json`. `scripts/mosaic-config.mjs:337` creates them with
|
||||
`openSync(file, "wx", 0o644)` and no chmod, so under `umask 077` they come out 0600 and the test fails.
|
||||
This is an accept test of a source mode, not a refusal test.
|
||||
|
||||
| Creation | Test | Source | Ruling |
|
||||
|---|---|---|---|
|
||||
| `scripts/mosaic-config.mjs:337` via `bootstrap()` (`stack-instances.test.mjs:41`, a `spawnSync` of `scripts/bootstrap.sh`) | the `--instance` create test, :79-89 | `createExclusive` passes 0o644 and the umask narrows it | test-only (sage-1918) |
|
||||
|
||||
- No change to `mosaic-config.mjs`. Letting the operator's umask narrow the config is the source's
|
||||
choice; a chmod to 0644 would make the config wider than that umask. No row asks for it.
|
||||
- `bootstrap()` runs its child under an explicit `umask 022`, the pattern from `gate-lane.mjs:46`:
|
||||
`run("/bin/bash", ["-c", 'umask 022; exec "$@"', "bash", BOOTSTRAP, ...args], homeEnv(home))`. Every
|
||||
`bootstrap()` call goes through it (:79, :99, :102, :119, :127, :136, :140, :152, :165, :169), so the
|
||||
0644 assertions still prove the mode the source asks for, whatever the gate's umask.
|
||||
- New assertion: a `bootstrap --instance` run under `umask 077` makes the config and `instance.json`
|
||||
0600. That pins that the source honors the umask, so a later chmod "fix" fails a test. The directories
|
||||
stay 0700 in both runs (they're created with `mode: 0o700`, which 077 can't narrow).
|
||||
- The `config()` helper (:42) has no mode assertion and stays as it is.
|
||||
|
||||
Tests outside `packages/*/tests`: the five `scripts/foundation/*.test.mjs` files have no open-mode
|
||||
write. `cli.test.mjs:144/148` chmods to 0o000 and 0o600, which doesn't depend on the umask. The
|
||||
`agents/*/work` probe tests are review evidence, not gate steps (`discoverSteps` doesn't run them), so
|
||||
they're out of scope.
|
||||
|
||||
## Tests
|
||||
|
||||
- Each of the four files runs whole twice, once under `umask 022` and once under `umask 077`
|
||||
(`bash -c 'umask 077; node --test <file>'`). Both pass. The brief requires this for process.test.mjs;
|
||||
Sage extended it to all four (sage-1918).
|
||||
- Proof that the change matters: before the change, the same four files under `umask 077` fail on the
|
||||
named assertions. I record that run too, on the slot, so the 077 pass after the change is meaningful.
|
||||
- Order (mos-1162): this lands after 197 r6, so r6's proof ran on the unchanged test.
|
||||
|
||||
## Files (expected)
|
||||
|
||||
`packages/bus/tests/process.test.mjs`, `packages/webui/tests/proof-cli.test.mjs`,
|
||||
`packages/webui/tests/sso.test.mjs`, `packages/cli/tests/stack-instances.test.mjs`. Test-only. No
|
||||
source change.
|
||||
|
||||
## Build (sage-2056: start on row 197's landing a36523713)
|
||||
|
||||
Branch `morgana/row243`, one commit on a36523713. Cites rechecked there: all hold except proof-cli's
|
||||
write, which is now `proof-cli.test.mjs:30` (was :28).
|
||||
|
||||
- `bus/tests/process.test.mjs`: imports `chmodSync`; `chmodSync(file, 0o644)` after the write at :76.
|
||||
- `webui/tests/proof-cli.test.mjs:30`: `chmodSync(file, mode)` after the write, with each case's own
|
||||
mode, so the 0o600 cases are set explicitly too.
|
||||
- `webui/tests/sso.test.mjs:360`: `chmodSync(..., 0o644)` after the write, before the `--sso` start
|
||||
that must refuse.
|
||||
- `cli/tests/stack-instances.test.mjs`: `underUmask(mask, home, args)` runs `scripts/bootstrap.sh`
|
||||
through `/bin/bash -c 'umask <mask>; exec "$@"'`; `bootstrap()` is `underUmask("022", ...)`. New
|
||||
subtest in T-I1: under umask 077 the config dir and root are 0700, and the config and `instance.json`
|
||||
are 0600.
|
||||
- Hash pins: no test or script names any of the four files (a grep for the basenames; positive control
|
||||
`stack-release.test` found in two files). `stack-release.test.mjs:2122` mentions
|
||||
`stack-instances.test.mjs` only in a comment.
|
||||
|
||||
Tests: on Sage's slot (sage-2062, plan approved in sage-2059), 18:55Z. My busy check read CLEAR before
|
||||
and after. Each file ran whole with `node --test --test-concurrency=2` from its package, under
|
||||
`bash -c 'umask <mask>; exec ...'`, with TMPDIR from `mktemp -d /mnt/storage/scratch/tmp/g243XXXX`.
|
||||
Script `/mnt/storage/scratch/morgana-r243/slot243.sh`, logs in `logs/` next to it.
|
||||
|
||||
| Tree | umask | process | proof-cli | sso | stack-instances |
|
||||
|---|---|---|---|---|---|
|
||||
| base a36523713 | 077 | 7/1 | 2/1 | 27/1 | 21/2 |
|
||||
| candidate 137bda3af | 022 | 8/0 | 3/0 | 28/0 | 24/0 |
|
||||
| candidate 137bda3af | 077 | 8/0 | 3/0 | 28/0 | 24/0 |
|
||||
|
||||
(pass/fail.) The base fails are the predicted ones: "startup token refusal returns safe code...",
|
||||
"proof startup refuses partial flags...", "11: the CLI refuses --sso...", and in stack-instances the
|
||||
0644 subtest "alpha and beta get a 0700 config dir..." plus its parent T-I1 (so 2). stack-instances has
|
||||
one more test on the candidate (the umask 077 subtest). No unpredicted failure.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Row 197 proof observations (Sage)
|
||||
|
||||
Kept here, not in Herb's BUILD.md, because that file is one of row 197's 37 paths and the landing check
|
||||
(mos-1174, mos-1185 item 5) requires them equal to c45ddeb6b.
|
||||
|
||||
## Open observation (mos-1185 item 3), 2026-10-21
|
||||
|
||||
Proofs 4 and 5 each stopped on a test outside 197's paths, in a pair wave, and both times in lane b:
|
||||
|
||||
- Proof 4, pair1-b, pkg-conversation, K12 (`cohort.test.mjs:292`): `populated` undefined. Row 250, Vikunja #450.
|
||||
- Proof 5, pair2-b, pkg-bus, T27 (`host-run.test.mjs:469`): no identities right after `ssh-add -t 1`. Vikunja #453.
|
||||
|
||||
Mos checked the step log birth times: the twin step started in both lanes within half a second each time
|
||||
(proof 4 pkg-conversation a 11:03:59.343, b .855 CDT; proof 5 pkg-bus a 12:07:25.354, b .573). Waves run in
|
||||
lockstep, so that holds for every pair wave, the passing ones too. Lane b is the twin that starts later.
|
||||
Mos's reading: contention exposing fragile tests, not a 197 defect. Paired lanes add contention, and that
|
||||
is 197's cost, covered by #451.
|
||||
@@ -0,0 +1,149 @@
|
||||
# Row 218 draft notes: claims and sources
|
||||
|
||||
Drafts for `docs/case2/SETUP-SHEET.md` and `docs/case2/SURVEY-RUN.md`. Repo base: `refactor` at
|
||||
947101d8a, which holds 29ce1e360 (row 231). "Design" means
|
||||
`git show 75fb3e10e:agents/gosalyn/work/case2/DESIGN.md`, cited as `D:<line>`. "Brief" means
|
||||
`docs/plans/2026-10-20_case2-rows.md`. Code paths are relative to the repo root. Nothing was run
|
||||
except reads, greps and `git merge-base --is-ancestor 29ce1e360 HEAD`.
|
||||
|
||||
## SETUP-SHEET.md
|
||||
|
||||
| Claim | Source |
|
||||
|---|---|
|
||||
| She keeps writing in Obsidian, obsidian-git pushes to git.mosaicstack.dev, nothing changes on her machine, nothing commits to her repo | D:939-940 (7.1) |
|
||||
| Agents read what she pushed; a push reaches them in about a minute | D:1006-1007 (7.5); D:972-973 (60-second sync timer) |
|
||||
| Agents read `.md` notes inside book folders only | D:457-461 (1.2); `packages/library/src/scope.mjs:9-19` |
|
||||
| Not read: top-level notes, `.canvas`, `.base`, attachments, anything outside a book folder | D:461 |
|
||||
| Other repos under `~/src` are not read | D:489 (1.3), D:494-497 (N4) |
|
||||
| Agents can't write to her repo: canon, playground, anything | D:389 (D3); D:1008 |
|
||||
| Folder roles live in the `projects` entry of the business file, hand-edited, agents can't change it | D:409-429, D:443-451 (1.1, M9) |
|
||||
| Jason asks her to confirm the mapping first | D:431; D:1324-1328 (10 step 7) |
|
||||
| A folder named in `playground` is her playground | Brief:22; D:434 |
|
||||
| Each playground prefix sits inside one book folder | D:434-436; `packages/library/src/mapping.mjs:77-80` |
|
||||
| One per book, e.g. `<book>/playground/`; until then everything mapped is canon | D:438-439 |
|
||||
| Agents search canon unless they ask for the playground | D:463 |
|
||||
| Playground may back an idea or question, not a fact, finding or correction; refused before she sees it | D:858 (`playground-cited-as-canon`); D:378-382, D:835-839 (guard runs before store) |
|
||||
| Only the folder decides; a tag or property can't change scope | D:388 (D2); D:440-441; `scope.mjs:1` |
|
||||
| Graph colour group via Groups and a `path:` query, her own vault setting, changes nothing for agents | Brief:23; D:941-943. Obsidian UI steps from the task prompt, not the design |
|
||||
| Path starts with the books repo folder because the vault is `~/src` | D:939 |
|
||||
| N2: moving a note into a book folder makes it canon, agents may cite it as fact | D:944-945; D:259 |
|
||||
| Rough drafts outside the playground are canon | Inference from D:460 (scope rule) |
|
||||
| N4: Obsidian resolves short links across `~/src`, the reader sees one repo, the two can differ, the reader can't tell | D:494-497; D:262-263 |
|
||||
| A name only in another repo leads nowhere for agents | D:489 (R4); `packages/library/src/links.mjs:61-87` |
|
||||
| A full-path link resolves the same in both | D:497-498. Hub side: `links.mjs:77-78` (`vault-prefix` step) |
|
||||
| "New link format: Absolute path in vault" setting | Obsidian UI knowledge. Not in design or code. Reviewer may strike it |
|
||||
| `appears_in` and `relationships` links are read as links | D:489-490; `packages/library/src/reader.mjs:15`, `:153-162` |
|
||||
| Quoted flow list `["[[A]]", "[[B]]"]` parses | `reader.mjs:81-96` (flow), `:66-73` (quoted scalar), `:159` |
|
||||
| Block list with quoted items parses | `reader.mjs:117-122`, `:66-73` |
|
||||
| Unquoted `[[[A]], [[B]]]` is R3 | Brief:28-29; `reader.mjs:87` (bracket inside a flow list stops parsing), `:137` |
|
||||
| R3 effect: whole front matter unread, note still indexed by path, name and body, listed as a problem | D:480; `reader.mjs:103-105`, `:193`; `packages/library/README.md:69` |
|
||||
| Problems show in Mosaic's status | D:473-474; D:999-1000 (7.4 status line) |
|
||||
| A value without `[[ ]]` is a name | D:489-490; `reader.mjs:159` |
|
||||
| Bean and Chet keep names and personalities | D:660-661 (Q180) |
|
||||
| Hermes: free text and story text. Mosaic: short cited items, a prose request gets a question or idea | D:778-781 (design 4) |
|
||||
| Bean and Chet's item kinds: idea, question, finding, fact; web cites allowed | D:649 (`companion`); D:860 (`citation-unfetched`) |
|
||||
| Limits 10 items, 5 ideas of 200, others 300 | D:819 (M5) |
|
||||
| She or Jason can ask for lower limits | D:1329-1330 (10 step 8); D:831 |
|
||||
| "No cited answer" notice | D:870-871, D:996-997 |
|
||||
| One item can't mix two worlds | D:859 (`citation-cross-universe`) |
|
||||
| She talks to them in the Console; Hermes keeps running until she moves over; stopping it is her choice | D:381-382; D:1331 |
|
||||
| Five more helpers; she chooses the start set and can add one later | D:647-654; D:783-784; D:1327-1328 |
|
||||
| What Jason will ask | D:1325-1327 (10 step 7) |
|
||||
|
||||
## SURVEY-RUN.md
|
||||
|
||||
| Claim | Source |
|
||||
|---|---|
|
||||
| Runs on `<host>` 10.1.1.54 under design 10 step 7, after step 4 | D:1324; D:1266 (host) |
|
||||
| Prints names and counts, never note text, property values, file names or link targets | `packages/library/src/survey.mjs:1-6`; README:118-125 |
|
||||
| Sending to Melanie is Jason's call | Brief:36; D:1324-1328 |
|
||||
| Token slot `library-gitea` is step 4 | D:1314-1316 |
|
||||
| Run as `sudo -u mosaic -H` from `localadmin`; `mosaic` has no login shell | D:1195-1197; D:1281-1282 |
|
||||
| Copy at `/srv/mosaic/.local/share/mosaic-stack/copies/<id>`, `<id>` = commit in Jason's go | D:1286-1292 (2c); `docs/TOOLS.md:417`; home `/srv/mosaic` per D:1314-1315 |
|
||||
| The copy carries `packages/library/src` | `scripts/stack-copy.sh:36` |
|
||||
| Row 231 adds the control and bidi escapes | `git log` 29ce1e360; `survey.mjs:78-82`; README:121-122 |
|
||||
| `<node dir>` is the `--node` directory of step 2c; `scripts/mosaic` execs `node` from PATH | D:1290-1291; `scripts/mosaic:41` |
|
||||
| Dispatch: `mosaic library survey` goes to `survey.mjs` with `survey <args>` | `scripts/mosaic:37-41` (one `shift` leaves `survey` as argv[0]); `survey.mjs:102-103` |
|
||||
| Command and flags: `<repo>` plus optional `--mapping <file>` once; anything starting with `-` refuses | `survey.mjs:16`, `:104-110` |
|
||||
| No fetch; reads `HEAD` | `survey.mjs:5-6`, `:27`; `reader.mjs:22-24` |
|
||||
| Top folder only: ceiling is the path's parent | `packages/project/src/git.mjs:12`, `:62` |
|
||||
| Owner must be `mosaic`: git gets no global config | `packages/seat/src/checkout.mjs:79-89` (`GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_NOSYSTEM=1`). The dubious-ownership refusal is git's own behaviour |
|
||||
| Bus mirror path `<dataRoot>/projects/<business>/<project>` | Brief:300; `packages/project/src/paths.mjs:34-40` |
|
||||
| C2-04 and C2-03a not landed | `grep` of `packages/*/src` finds no library entry handling or `refs/heads/served`; `docs/guides/host-run-sheet.md:269` says "not landed" |
|
||||
| Served ref `refs/heads/served` | D:963 (7.2); Brief:676 |
|
||||
| Token kept out of URL, config, argv, logs | D:955-956 (7.2) |
|
||||
| Mapping file is the entry alone; `remote`, `branch` required | `survey.mjs:114-116`; `mapping.mjs:38-47` |
|
||||
| Output lines and fields | `survey.mjs:83-97`; data from `:26-76` |
|
||||
| Folder lines list folders that directly hold an entry, full path | `survey.mjs:30-35` |
|
||||
| Key names: short identifiers print, others as `(other key)` | `survey.mjs:17-20`, `:47` |
|
||||
| `unresolved` equals the R4 count | `survey.mjs:72` |
|
||||
| Scope counts cover all tree entries | `survey.mjs:66` |
|
||||
| Refusal exit codes 0, 2, 3, 4 | `survey.mjs:99-124`; README:127-129 |
|
||||
| Mapping checked before repo | `survey.mjs:112-121` |
|
||||
| stderr format `library survey: <message>` | `survey.mjs:101`; `problems.mjs:3-5` |
|
||||
| `(project-git)` suffix on git failures | `reader.mjs:18-20`; `paths.mjs:4-6` |
|
||||
| `library-playground-outside-book` message | `mapping.mjs:79-80` |
|
||||
| 100-second git timeout | `checkout.mjs:49`, `:52`; `git.mjs:68` |
|
||||
| Exit 127 when `node` is missing | `scripts/mosaic:18`, `:41` (bash `exec` failure). Not run |
|
||||
| Other errors rethrow as a stack trace | `survey.mjs:117`, `:123`, `:128-131` |
|
||||
| Don't work around a refusal | AGENTS.md invariant 6 |
|
||||
|
||||
## Code and design disagreements
|
||||
|
||||
1. Design 1.1 shows the mapping nested in `"projects": {"books": {...}}` (D:412-429). The survey's
|
||||
`--mapping` file must be the inner entry alone (`survey.mjs:114-116`). The run sheet follows the code.
|
||||
2. Design 1.3 calls `vaultPrefix` part of the mapping (D:418). The code makes it optional
|
||||
(`mapping.mjs:48`, README:30). Without it, a full vault path link, the N4 advice, resolves to
|
||||
nothing in the hub (`links.mjs:73-87`: `root` fails, `vault-prefix` is skipped, `shortest` refuses a
|
||||
`/`). The setup sheet's full-path advice holds only when Jason sets `vaultPrefix` to her books repo
|
||||
folder name.
|
||||
3. The task prompt calls the unquoted form "refused". R3 is not a refusal. It is a reported problem,
|
||||
and the note stays indexed (D:473, D:480). The sheet says what happens instead.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Where `<repo>` comes from on .54. The survey reads a local repo's `HEAD` and never fetches. The bus
|
||||
mirror needs C2-04 and C2-03a, neither landed. C2-03a moves `refs/heads/served`, not `HEAD`, so the
|
||||
mirror's `HEAD` may name no commit and the survey would refuse with exit 2. The sync also needs a
|
||||
checked mapping with at least one book (`mapping.mjs:59`, `:70`), and the mapping's values are
|
||||
meant to come from this survey (D:431). Sage needs to rule: a placeholder mapping for the first
|
||||
sync, a `HEAD` the sync sets, a `--commit` flag on the survey, or another source.
|
||||
2. The brief says Obsidian's property editor writes `appears_in: ["[[A]]", "[[B]]"]` (Brief:28-29,
|
||||
webby-0041 N4). I didn't read webby-0041 or check Obsidian's output. Both the flow and the block
|
||||
quoted forms parse, so the sheet shows both and says only that the editor adds the quotes.
|
||||
3. Whether `/srv/mosaic/.npm-global/bin/mosaic` exists on .54. The run sheet uses the copy's
|
||||
`scripts/mosaic` by full path to avoid depending on a wrapper.
|
||||
|
||||
## Sage's rulings on the open questions (2026-10-21)
|
||||
|
||||
1. Survey source: the bus's bare mirror after the first `library.sync`, the only copy on the host
|
||||
(design 2.1). The C2-03a brief now requires the mirror's `HEAD` to be a symbolic ref to
|
||||
`refs/heads/served`, with a test (5c6f73ed7), so the survey reads the served commit with no ref flag.
|
||||
2. Property list forms: the sheet shows both quoted forms, since both parse. Webby checks the claim
|
||||
that Obsidian's property editor writes the quoted form.
|
||||
3. The "New link format: Absolute path in vault" tip stays. It is an Obsidian setting, not a design
|
||||
claim, and Webby may strike it.
|
||||
4. The run sheet calls the copy's `scripts/mosaic` by full path. That holds whether or not a wrapper
|
||||
is on `PATH`.
|
||||
5. `vaultPrefix`: the run sheet's step 4 tells Jason to set it, because the setup sheet's full-path
|
||||
link advice depends on it.
|
||||
|
||||
## Round 2 (2026-10-21), from Webby's round 1 notes (webby-0075, rev 2848)
|
||||
|
||||
- N1, by Mos's ruling mos-1165 (fix before landing): SETUP-SHEET.md now gives the 1,200 character
|
||||
total of item text per reply (design M5, step 8). The word "Today" is gone. No code enforces these
|
||||
caps until C2-06 lands, so my ruling is that the sheet goes to Melanie only after C2-06 lands, and
|
||||
then only on Jason's go (#419). The wording stays as is, because by then it holds.
|
||||
- N2: SURVEY-RUN.md says tab, newline, carriage return, backspace and form feed print as `\t`, `\n`,
|
||||
`\r`, `\b` and `\f`, and other control and bidi characters as `\uXXXX` (survey.mjs quote()).
|
||||
- N3: SURVEY-RUN.md has a refusal row for `library-commit:` (reader.mjs listTree). survey.mjs turns
|
||||
every LibraryError into exit 2 with its message.
|
||||
- N4: SURVEY-RUN.md's new step 2 checks `command -v node` under the same sudo and PATH before the
|
||||
survey runs.
|
||||
|
||||
## Round 3 (2026-10-21), from Webby's round 2 note N5 (webby-0077, rev 2856)
|
||||
|
||||
- N5: the library-commit row no longer says a moved `HEAD` causes it. The survey reads `HEAD` once,
|
||||
and listTree raises library-commit only for an id that isn't 40 hex or an object that isn't a
|
||||
commit (reader.mjs:29, :31). In practice that means a SHA-256 repository, so a rerun doesn't help.
|
||||
The row now says "for example in a SHA-256 repository" and "Report to Sage".
|
||||
@@ -1410,3 +1410,13 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-21T09:20:46Z | Sage (Opus 5.5, 1ef1e4f8) | 212+197 combined landing gate (node 981/0, selftests 429/0, 08:51-09:03Z); 197 lane proof stopped after two failures (try 1 lane-image base inspect, N7; try 3 solo-a process.test.mjs:82 under umask 077), recovered cf48efccf083; mos-1162: row 212 landed alone as ee7b2f137 (paths match a2c72ac95, patch-id ea84d3e35470), #1713 closed 29996, revs 2759-2760; DEFERRED N7 plus brief docs/plans/2026-10-21_gate-env-rows.md at fefd54e13, rows 243 (chmod) and 244 (fixture PI_AUTH_FILE) revs 2761-2762; row 66 rebuilt as sage/row66-r4b 49ad61963 on fefd54e13 (manifest 99cab8275af4, not yet requested); slots: Herb 215 focused 26/0, Gosalyn 231 singles 60/0, Herb 197 r6 focused granted; kept root ga-8I3iZc removed (sage-1827..1893) | in progress
|
||||
2026-10-21T10:12:45Z | Darkwing (Opus 5.5, 7525c8e4) | row 245 (#1693) built, slot asked (darkwing-0105) | claim rev 2795; darkwing/row245 0590a29fc pushed, on 62ddc96cc: 5273de0b7 cherry-pick of 1e3c4767c (case1-accept.sh, test, TOOLS.md blobs identical) and 0590a29fc notes to queue-245/BUILD.md (DESIGN.md:336-338 superseded by gosalyn-0087, unedited, sage-1877); slot scripts in /mnt/storage/scratch/darkwing-245-out pinned to 0590a29fc, nothing run; told Sage the untracked queue-195 DESIGN.md was r2 (fcbb4aa9b = 818b79696), nothing lost; flagged 245 and 246 both closing #1693
|
||||
2026-10-21T11:28:49Z | Sage (Opus 5.5, 1ef1e4f8) | 235 landing gate (bus 596/0, business 200/0, cli 666/0, scripts green) and landing b1fece6f0, #1741 closed 30026; 197 r6 D5 proof stopped at solo-a script-release (Compose run has no --no-build, reproduced in lane env), #1695 30031; 221 landed before 197 r7 (sage-1954): gate 11:07-11:19Z (test 11/0, harness 140/0, cli 677/0, scripts green), 300766450, #1722 closed 30033, revs 2812-2813; C2-20b brief and DEFERRED N3 in 335bb3e30, row 247 #1749 briefed; 197 r7 9c3166915 focused run on my slot (red 9/2, green 11/0, mutant killed), request rev 2819 (30036), proof candidates A 2be6d07fa and B cceb258ff ready; slots Herb sage-1956, Darkwing 245 sage-1961 (sage-1894..1962) | in progress
|
||||
2026-10-21T11:50:41Z | Darkwing (Opus 5.5, 7525c8e4) | row 245 (#1693) slot green, round 1 in review (rev 2842, request comment 30040) | grant sage-1961 on 0590a29fc: singles 23/0, mutants 16/16 killed (13-15 N1, 16 N3), gate cli 664/0 and every scripts/test-*.sh green (429/0), /proc scan empty; results commit ef8b4439e pushed on darkwing/row245; manifest ~/darkwing-scratch/r245/manifest-ef8b4439e*.sha256, 5 paths; slot handed back (darkwing-0107); Duckworth woken (darkwing-0108)
|
||||
2026-10-21T13:43:38Z | Filbert (Opus 5.5, 048f3c63) | row 215 C2-04 candidate review r1 (herb-0083/0085, sage-2019) | APPROVE on 8d5695d7 tip 14a03d10f, static, no tests; #1716 c30071, queue rev 2897; N1-N4 non-blocking
|
||||
2026-10-20T23:34:58Z | Dewey (Opus 5.5, 84fb346c) | #436b design r4 review (megavolt-0055, DESIGN.md 3e66f5acf) | CHANGES: revoke on an open rotation deletes one of two live tokens; N8 refuse a new grant while held; items 1 (sage-1578 b), 2, N7, duckworth N1 settled; verdict design-r4-verdict.md 19fdb59a; dewey-0761 Megavolt, dewey-0762 Sage
|
||||
2026-10-20T23:35:58Z | Dewey (Opus 5.5, 84fb346c) | #436b design r5 review (megavolt-0057, DESIGN.md 1be535aca) | APPROVE; build carries N9 (settle row skips a revoked rotation, 404 delete is done) and N10 (serviceLeft list); verdict design-r5-verdict.md de52be1b; dewey-0763 Megavolt, dewey-0764 Sage
|
||||
2026-10-20T23:41:36Z | Dewey (Opus 5.5, 84fb346c) | row 222 askpass vs 199 helper (megavolt-0061, design 7.2 8614ba87c) | accepted (a) http.followRedirects=false and (b) host/user-checked helper, plus exact prompt match, credential.helper reset or mirror config check, tests and mutants listed; dewey-0765 Megavolt, -0766 Gosalyn, -0767 Sage
|
||||
2026-10-21T05:03:38Z | researcher | row 228 (#1729) round 2 in review (revs 2667, 2668) | round 1 4fa2ed1e3 (Webby 29677) drifted on packages/cli/README.md; rebased onto 9b5362018 as researcher/row228-r2 (3a2b547c3 + 5f2aad593 carrying the N1 trim), pushed; manifest ab40b92f (3 files); slot sage-1720: singles 94/0 (13+10+42+29), mutant M1 killed, restore sha OK; busy-check copy used for the 16-hour launchpad-test-dex container (Sage ok, sage-1729); request failed (no token), Sage posts as jarvis; packet agents/researcher/work/queue-228/r2
|
||||
2026-10-21T06:47:54Z | researcher | row 228 (#1729) round 3 in review (rev 2702; start rev 2700) | rebase of r2 onto the 234 landing c771a36f4 as researcher/row228-r3 cf18c42b9 (one commit, pushed) with Webby's round 2 N1 (:711 and :718); manifest 82cf98c6 (3 files); slot sage-1796: session.test 13 + host.test 42 = 55/0, manifest OK before and after, busy228.sh copy named in the clear; r2 94/0 and M1 killed stand (seat blobs unchanged); request attempt left in requesting state (lock held by sage-238-review-2b when the outcome rev was written, exit 3; transport said failed pre-send, no token), Sage resolves and posts as jarvis; packet agents/researcher/work/queue-228/r3
|
||||
2026-10-21T07:29:23Z | researcher | row 228 (#1729) landed as 86249ac55 (pushed 07:25Z by Sage) | patch-id b6ffe31feb0d matches my r3 candidate cf18c42b9 (checked here against origin/refactor); Sage combined gate with 238: seat 165/0, cli 640/0, scripts 585/0; queue note rev 2725, done rev 2727, #1729 closed comment 29966 (sage-1824); q228r2 worktree removed, branches researcher/row228, -r2 and -r3 stay on origin and locally; packets agents/researcher/work/queue-228 r1 r2 r3 uncommitted
|
||||
2026-10-21T15:19:14Z | Sage (Opus 5.5, 1ef1e4f8) | MVP push 10-21 afternoon: rows 249, 197 proof 3/4, 222 singles, 66 r4d, queue-commit | 249 landed 9de5a57ab (+7b9737953 DEFERRED N1, #1751 closed, Vikunja 445 c442); 197 proof 3 rc=1 on a driver defect, Herb's fix a5b636750 to Webby, proof 4 set up in sage-r197p4; 222 singles 34/34 and 5 mutants killed (sage-2028); 66 round 4 in review (82c830779, c30083/30084); queue revs 2642-2904 committed 0636721e6
|
||||
2026-10-21T15:20:58Z | Filbert (Opus 5.5, 048f3c63) | row 66 r4 review (sage-2037, sage/row66-r4d 82c830779, manifest 6cfd812da4ca) | APPROVE 30086, rev 2906; static, no tests; rebase only, row 66 +/- lines identical to r3 next to rows 212/213/232/245; N1 code-only patch-id d67275ac4251 (508bb684eade includes BUILD.md), N2 BUILD.md cap-sentence wording; packet-only notes
|
||||
|
||||
+7
-3
@@ -905,9 +905,13 @@ args file (`/etc/mosaic-stack/dev.args` on .53), which must pass
|
||||
`stack-upgrade.sh`'s rules, say `--profile factory` and name `--business`.
|
||||
Every command that runs the stack's code runs as
|
||||
`sudo -u USER -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin` from `/`.
|
||||
Root only reads systemd, `ss`, `/proc`, the account database and file
|
||||
metadata. The one write is F3's message and its reply, and it never opens
|
||||
`claude-session.env`.
|
||||
Root itself reads `ss`, `/proc`, the account database and file metadata. It
|
||||
runs `systemctl show` and `is-enabled`, `curl` on the Console's loopback
|
||||
`/healthz`, `sudo -l`, `sudo -u jarvis bwrap --version`, `hostcheck.sh
|
||||
--check`, and node to parse `managed-settings.json` for F9. F9 runs node only
|
||||
when `--node` is the `--node-pin` path, and the file and every directory above
|
||||
it are root's with no group or other write. The one write is F3's message and
|
||||
its reply, and it never opens `claude-session.env`.
|
||||
|
||||
It prints one line per check, `<id> <path> <mark> <evidence>`, then
|
||||
`count <mark> N` for each mark and a result line. The functional checks
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Your books in Mosaic: a setup sheet
|
||||
|
||||
This sheet is for you, Melanie. You keep writing in Obsidian the way you do now. Mosaic reads your
|
||||
books repo after obsidian-git pushes it to git.mosaicstack.dev. It changes nothing on your machine and
|
||||
commits nothing to your repo.
|
||||
|
||||
## What the agents read
|
||||
|
||||
The agents read your books repo as you last pushed it. A push reaches them in about a minute.
|
||||
|
||||
They read notes, the `.md` files, inside your book folders. They don't read:
|
||||
- notes at the top of the repo, outside any book folder
|
||||
- `.canvas` and `.base` files, even inside a book folder
|
||||
- images and other attachments
|
||||
- anything in your other repos under `~/src`
|
||||
|
||||
They can't write to your repo at all. That covers canon, the playground and everything else.
|
||||
|
||||
Which folders are book folders, and which hold characters, places, research, your tracker or chapter
|
||||
summaries, is set in one place. It is the `projects` entry of your business file in Mosaic. Jason
|
||||
edits it by hand, and the agents can't change it. He will ask you to confirm it before the agents start.
|
||||
|
||||
## Your playground
|
||||
|
||||
A folder named in the `playground` list of that entry is your playground. Each playground folder sits
|
||||
inside one book folder, so one per book works well, for example `<book>/playground/`. You choose the
|
||||
names and tell Jason. Until you have one, every note in a book folder is canon.
|
||||
|
||||
What the playground means for the agents:
|
||||
- They search canon unless they ask for the playground on purpose.
|
||||
- An agent may quote a playground note in an idea or a question. It may not use one to back a fact,
|
||||
a finding or a correction. Mosaic refuses that reply before you see it.
|
||||
- Only the folder decides. A tag or a property can't make a note playground or canon.
|
||||
|
||||
### Colour the playground in your graph
|
||||
|
||||
This is a setting of your own vault. It changes nothing for the agents.
|
||||
|
||||
1. Open the graph view and open its settings panel.
|
||||
2. Under Groups, choose New group.
|
||||
3. In the query box, type the playground's path with `path:`. Your vault is `~/src`, so the path
|
||||
starts with your books repo folder: `path:"<books repo folder>/<book>/<playground folder>"`.
|
||||
4. Pick a colour.
|
||||
|
||||
Add one group per playground folder.
|
||||
|
||||
## Two things you might not expect
|
||||
|
||||
### Moving a note into a book folder makes it canon
|
||||
|
||||
The folder decides, so a move changes what the agents may do with a note. Once you move a note out of
|
||||
the playground into a book folder and push, it is canon. The agents may then cite it as fact. A note
|
||||
that isn't settled yet belongs in the playground. Any note in a book folder outside the playground is
|
||||
canon, rough drafts included.
|
||||
|
||||
### A short link can point somewhere else for the agents
|
||||
|
||||
Obsidian looks up a short link like `[[Mara]]` across your whole vault, `~/src`. The agents see only
|
||||
your books repo. Say a note called `Mara` exists in your books repo and also in another of your repos.
|
||||
Obsidian and the agents can then open different notes for the same link, and the agents can't tell.
|
||||
If the name exists only in the other repo, the link leads nowhere for the agents.
|
||||
|
||||
A link with the full path from the top of your vault reaches the same note in both:
|
||||
|
||||
```
|
||||
[[<books repo folder>/<book>/<characters folder>/Mara]]
|
||||
```
|
||||
|
||||
Obsidian can write new links this way. In Settings, open Files and links and set New link format to
|
||||
Absolute path in vault.
|
||||
|
||||
## Lists of links in properties
|
||||
|
||||
The agents read the links in two properties, `appears_in` and `relationships`. Put quotes around each
|
||||
link in the list:
|
||||
|
||||
```yaml
|
||||
appears_in: ["[[A]]", "[[B]]"]
|
||||
```
|
||||
|
||||
One link per line works too:
|
||||
|
||||
```yaml
|
||||
appears_in:
|
||||
- "[[A]]"
|
||||
- "[[B]]"
|
||||
```
|
||||
|
||||
Obsidian's property editor adds the quotes for you. If you type the list by hand, don't leave them out:
|
||||
|
||||
```yaml
|
||||
appears_in: [[[A]], [[B]]]
|
||||
```
|
||||
|
||||
The agents' reader can't read that form. It then ignores every property of that note and lists the
|
||||
note as a problem in Mosaic's status. It still finds the note by its path, its name and its text. A
|
||||
value written without `[[ ]]` counts as a plain name, not a link.
|
||||
|
||||
## What changes for Bean and Chet
|
||||
|
||||
They keep their names and personalities. How they answer changes.
|
||||
|
||||
In Hermes they answer in free text and may write story text when you ask. In Mosaic every reply is a
|
||||
short list of items. Each item is an idea, a question, a finding or a fact. Each one quotes a line
|
||||
from your repo or from a web page the agent read for you.
|
||||
|
||||
They don't write passages. If you ask for prose, you get a question or an idea instead.
|
||||
|
||||
A reply holds at most 10 items and at most 1,200 characters of item text in all. It has at most 5
|
||||
ideas of up to 200 characters each. A question, finding or fact runs up to 300 characters. You or
|
||||
Jason can ask for lower limits. When an agent can't back an answer with a quote, you see "No cited
|
||||
answer" instead.
|
||||
|
||||
Each book world stays separate. One item can't mix quotes from two worlds.
|
||||
|
||||
You talk to them in the Console. Hermes keeps running until you have moved over, and stopping it is
|
||||
your choice.
|
||||
|
||||
Five more helpers can join them: ideas, research, proofreader, fact-checker and continuity. You
|
||||
choose which ones to start with, and you can add one later.
|
||||
|
||||
## What Jason will ask you
|
||||
|
||||
He will ask you to confirm your book folders and type folders, which books share a world, the name of
|
||||
each playground folder, and which helpers to start with and when.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Survey run sheet for the library host
|
||||
|
||||
This sheet is for whoever runs `mosaic library survey` on `<host>`, 10.1.1.54, under design 10 step
|
||||
7. The survey reads one commit of a git repo and prints names and counts. It never prints note text,
|
||||
property values, file names or link targets. Jason fills in the mapping for Melanie's books repo
|
||||
from its output and her answers.
|
||||
|
||||
Out of scope: sending anything to Melanie. Design 10 step 7 makes that Jason's call.
|
||||
|
||||
## Before you start
|
||||
|
||||
- Jason's go for step 7. Step 4, the bot token in the `library-gitea` slot, comes first.
|
||||
- You run as `mosaic` with `sudo -u mosaic -H` from `localadmin`. `mosaic` has no login shell.
|
||||
- The stack copy from step 2 is in place at `/srv/mosaic/.local/share/mosaic-stack/copies/<id>`.
|
||||
`<id>` is the full commit id in Jason's go. It must hold row 231, which escapes control and bidi
|
||||
characters in folder names. In a canonical checkout, `git merge-base --is-ancestor 29ce1e360 <id>`
|
||||
exits 0 when it does.
|
||||
- `<node dir>`, the directory that holds `node`. It is the directory of the `--node` path given to
|
||||
`case2-unit.sh` in step 2c. `scripts/mosaic` runs `node` from `PATH`.
|
||||
- `<repo>`, an absolute path. See the next section.
|
||||
|
||||
## The repo to survey
|
||||
|
||||
The survey reads a local repo. It doesn't fetch. `<repo>` must:
|
||||
- be the repo's top folder, or a bare repo's own folder. A folder inside a repo refuses, because the
|
||||
survey stops git from looking above the path's parent.
|
||||
- belong to `mosaic`. The survey gives git no global config, so a `safe.directory` entry can't
|
||||
admit a repo another user owns.
|
||||
- have a `HEAD` that names a commit. The survey reads `HEAD` and has no flag for another ref.
|
||||
|
||||
`<repo>` is the bus's bare mirror at `<dataRoot>/projects/<business>/<project>`, which C2-04 (row 215)
|
||||
creates and C2-03a (row 222) fetches into. It is the only copy of her repo on the host (design 2.1), and
|
||||
`mosaic` owns it. Survey it after the first `library.sync` succeeds. The mirror's `HEAD` points at
|
||||
`refs/heads/served` (C2-03a brief), so the survey reads the commit the agents are served.
|
||||
|
||||
Don't survey anything else. A hand clone with the bot token would put the token where design 7.2 keeps
|
||||
it out, and the mirror's refs belong to the bus. If `HEAD` names no commit, stop and report to Sage.
|
||||
|
||||
## Steps
|
||||
|
||||
1. `ssh [email protected]`.
|
||||
2. Check which `node` runs. `PATH` ends in `/usr/bin:/bin`, so a wrong `<node dir>` would quietly run
|
||||
another `node`:
|
||||
|
||||
```sh
|
||||
cd /
|
||||
sudo -u mosaic -H env PATH="<node dir>:/usr/bin:/bin" sh -c 'command -v node; node --version'
|
||||
```
|
||||
|
||||
The first line must be `<node dir>/node`. If it isn't, fix `<node dir>` before going on.
|
||||
3. Run the survey without a mapping:
|
||||
|
||||
```sh
|
||||
cd /
|
||||
sudo -u mosaic -H env PATH="<node dir>:/usr/bin:/bin" \
|
||||
/srv/mosaic/.local/share/mosaic-stack/copies/<id>/scripts/mosaic library survey <repo>
|
||||
echo "exit $?"
|
||||
```
|
||||
|
||||
4. On exit 0, give the output to Jason. On any other exit, see "Refusals" below.
|
||||
5. When Jason has a draft mapping, run it again with `--mapping`. The file holds the library entry
|
||||
alone, `{"kind": "library", "remote": ..., "branch": ..., ...}`, without the business file or the
|
||||
`"projects"` wrapper around it. `remote` and `branch` are required even though the survey doesn't
|
||||
fetch. `mosaic` must be able to read the file. Set `vaultPrefix` to her books repo's folder name
|
||||
in her vault. Without it, a link with the full path from the top of her vault resolves to nothing,
|
||||
and the setup sheet tells her to write links that way.
|
||||
|
||||
```sh
|
||||
sudo -u mosaic -H env PATH="<node dir>:/usr/bin:/bin" \
|
||||
/srv/mosaic/.local/share/mosaic-stack/copies/<id>/scripts/mosaic library survey <repo> --mapping <file>
|
||||
echo "exit $?"
|
||||
```
|
||||
|
||||
The usage line is `mosaic library survey <repo> [--mapping <file>]`. `--mapping <file>` is the only
|
||||
flag, and it may appear once. `--mapping=<file>` and every other flag refuse.
|
||||
|
||||
## What the output holds
|
||||
|
||||
Plain text on stdout, one line per item:
|
||||
|
||||
| Line | What it holds |
|
||||
|---|---|
|
||||
| `commit <id>` | the 40-hex commit `HEAD` names |
|
||||
| `entries <n>` | every entry in that commit's tree |
|
||||
| `folders (direct children: md canvas base other)` | then one line per folder that holds a file directly: its path as a JSON string and four counts. `(root)` is the repo's top |
|
||||
| `markdown <n> files, <n> read, ...` | `.md` files, how many were read, how many have no front matter, how many have front matter the reader can't fully parse |
|
||||
| `front matter key sets` | then `<count> [<keys>]` per set of property names. A name that isn't a short identifier prints as `(other key)` |
|
||||
| `body links plain .., label .., heading .., embed .., self .., markdown ..; attachment ..` | link forms in note bodies, and embeds of a target with an extension other than `.md` |
|
||||
| `field appears_in wikilink .., name ..` | values written as links and as plain names. A `relationships` line follows |
|
||||
|
||||
With `--mapping` three lines follow:
|
||||
|
||||
| Line | What it holds |
|
||||
|---|---|
|
||||
| `scopes canon .., playground .., unmapped ..; indexed ..` | tree entries per scope, and notes indexed |
|
||||
| `resolved root .., vault-prefix .., relative .., shortest .., unresolved ..` | links per resolver step. `unresolved` is the R4 count |
|
||||
| `problems R1 .., ..., R7 ..` | problems per rule |
|
||||
|
||||
Folder paths and short property names are the only words from her repo in the output. In a folder
|
||||
name, tab, newline, carriage return, backspace and form feed print as `\t`, `\n`, `\r`, `\b` and `\f`.
|
||||
Any other control or bidi character prints as `\uXXXX`.
|
||||
|
||||
## Refusals
|
||||
|
||||
A refusal prints nothing on stdout and one line on stderr. The survey checks the mapping before the
|
||||
repo, so a bad mapping gives exit 3 even when the repo path is wrong too.
|
||||
|
||||
| Exit | stderr | Cause | What to do |
|
||||
|---|---|---|---|
|
||||
| 4 | `library survey: usage: mosaic library survey <repo> [--mapping <file>]` | no repo, two repos, an unknown flag, `--mapping` without a file or twice | Fix the command |
|
||||
| 3 | `library survey: library-mapping-invalid: <file> is not a readable JSON file` | missing, unreadable by `mosaic`, or not JSON | Fix the file or its mode |
|
||||
| 3 | `library survey: library-mapping-invalid: <file> <reason>` | the entry breaks the schema. The reason names the key | Give it to Jason for the draft |
|
||||
| 3 | `library survey: library-playground-outside-book: <file> universes.<u>.playground "<prefix>" is not inside one of its books` | a playground prefix outside its universe's books | Give it to Jason for the draft |
|
||||
| 2 | `library survey: library-git: <repo> is not a directory` | wrong path | Check `<repo>` |
|
||||
| 2 | `library survey: library-git: repository has no readable HEAD commit (project-git)` | not a repo, a folder inside one, another owner, no commits, a `HEAD` with no commit, or no `git` on `PATH` | Check each. If `<repo>` is the bus mirror, stop and report to Sage |
|
||||
| 2 | `library survey: library-git: <other reason>`, such as `tree listing refused (project-git)` | git failed or ran past 100 seconds | Report to Sage |
|
||||
| 2 | `library survey: library-commit: <reason>` | `HEAD` names something the reader can't take as a commit, for example in a SHA-256 repository | Report to Sage |
|
||||
| 127 | a line ending `exec: node: not found` | `node` isn't in `<node dir>` | Fix `<node dir>` |
|
||||
| other | a Node stack trace | a fault in the survey | Stop and report the exit code to Sage |
|
||||
|
||||
A refusal is the answer, not an obstacle. Don't run as another user, change modes on her repo or edit
|
||||
its refs to get past one.
|
||||
@@ -106,11 +106,17 @@ apt-get download apparmor-profiles=4.0.1really4.0.1-0ubuntu0.24.04.9
|
||||
dpkg-deb --fsys-tarfile apparmor-profiles_*.deb | tar -x ./usr/share/apparmor/extra-profiles/bwrap-userns-restrict
|
||||
echo "11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9 usr/share/apparmor/extra-profiles/bwrap-userns-restrict" | sha256sum -c -
|
||||
install -o root -g root -m 0644 usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict
|
||||
echo "11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9 /etc/apparmor.d/bwrap-userns-restrict" | sha256sum -c -
|
||||
apparmor_parser -r /etc/apparmor.d/bwrap-userns-restrict
|
||||
|
||||
dpkg-statoverride --update --add root mosaic 0750 /usr/bin/bwrap
|
||||
```
|
||||
|
||||
Run as root in a `mktemp -d` dir, `apt-get download` prints a line starting `W: Download is performed
|
||||
unsandboxed as root`, because the `_apt` user can't write that dir. The line is expected and the
|
||||
download still succeeds. Each `sha256sum -c -` line must print `OK`. The second one checks the copy in
|
||||
`/etc/apparmor.d`, the file that loads. Stop on any other result.
|
||||
|
||||
`aa-status` then lists `bwrap` and `unpriv_bwrap` in enforce mode. Install the one profile file, never the
|
||||
whole `apparmor-profiles` package, whose 20 other profiles would load at the next boot. Rollback, in
|
||||
reverse order: `dpkg-statoverride --remove /usr/bin/bwrap`, then `chown` and `chmod` to the values in
|
||||
|
||||
@@ -121,3 +121,53 @@ tells Mos before row b lands.
|
||||
|
||||
Gate: as row a, with section 10's row b tests and mutants, G27, G28 and the upsert mutant included, and
|
||||
the creative refusal tests and mutants (writer and spend).
|
||||
|
||||
## Machine grant row b follow-up: the held stubs end with their test
|
||||
|
||||
The row 197 r8 D5 proof stopped on a leftover process on 2026-10-21. In
|
||||
`packages/bus/tests/grant-executor.test.mjs`, the `add` stub waits for `release` when `hold-add`
|
||||
is set. G23 ("a revoke during install") and G13 write `release`, finish, and `t.after` removes the
|
||||
test dir. The stub polls every 50 ms, so it usually misses `release` and then loops forever on a
|
||||
dir that is gone. Four runs of the file on Sage's slot left 7 stubs, every run 37/0.
|
||||
|
||||
Build: each hold loop in that file, `hold-add` and `hold-remove`, also stops when `$D` no longer
|
||||
exists. The test file is the only change. Nothing under `src` changes.
|
||||
|
||||
Gate: Webby approves the commit. On Sage's gate the whole file passes three times in a row with no
|
||||
stub left, checked by pgrep on the run's TMPDIR, and the bus suite is green. A mutant that drops the
|
||||
dir check from `hold-add` leaves a stub behind.
|
||||
|
||||
## Machine grant row b follow-up: the undo can't prove the remote add has ended
|
||||
|
||||
Mos asked this in mos-1166. Gizmoduck answered in gizmoduck-0137 and gizmoduck-0139, and Duckworth
|
||||
checked the answer in duckworth-0091. Line numbers are at 6d5545d44.
|
||||
|
||||
An abort during install SIGKILLs the local ssh process group (host-run.mjs:136). A timeout does the
|
||||
same (host-run.mjs:131-134). sshd gives a non-pty command no SIGHUP when the client dies, so the
|
||||
remote `add` can keep running. The undo runs `remove <user> <tag>` once (broker.mjs:1017). If the add
|
||||
writes its line after that, the remove has already exited 0 and `hostLineLeft` stays unset (:1019).
|
||||
Then hosts.status shows the host clean (:1396), reconcile skips a grant that already ended
|
||||
(:1042), and nothing prompts a sweep. hosts.remove's host-line-left check (:1362) passes, and once
|
||||
the record is gone a sweep refuses host-unknown (:1350), so no verb reaches the line. The line
|
||||
authorizes a key nobody holds, under the installer's restrict prefix. No test covers this case. G24
|
||||
seeds its failed grant with `hostLineLeft` already set.
|
||||
|
||||
Build:
|
||||
- In `#runHostGrant`, mark install done right after `await install(...)` returns. In the catch, when
|
||||
install started and never returned, record `hostLineLeft: tag` whatever the remove's exit was. The
|
||||
rule keys on install not returning. It covers an abort, a timeout and a dropped connection, and
|
||||
doesn't depend on `signal.aborted`. The message names `mosaic hosts sweep <host>`.
|
||||
- An add that exits nonzero on the host also records the hint. That hint is false but harmless,
|
||||
since a sweep that doesn't list the tag drops it (G24, "a left tag no longer on the host").
|
||||
- Fix the comment at grant-executor.test.mjs:432, which says a killed session's add exits.
|
||||
- Row a's enroll add: check it for the same race, then fix it or record in BUILD.md why it can't
|
||||
happen.
|
||||
- Owner Gizmoduck. Reviewer Duckworth.
|
||||
|
||||
Tests: one abort case and one timeout case. Each uses a held stub add that writes after the remove
|
||||
and exits 0. Each asserts `hostLineLeft` on grant.failed and in hosts.status, that hosts.remove
|
||||
refuses host-line-left before the sweep, that the sweep removes the line, and that hosts.remove then
|
||||
succeeds. A mutant that drops the new catch branch fails a test.
|
||||
|
||||
Gate: Duckworth approves the manifest. On Sage's gate the bus suite and the whole touched test files
|
||||
are green, and the mutant is killed.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Brief: K12 reads cgroup.events after cgroup.kill under load
|
||||
|
||||
Sage, 2026-10-21, for Mos's ruling mos-1182 item 3. Vikunja #450. Gate owner Sage. Owner and reviewer are
|
||||
named when the row is briefed. Base: refactor at the brief commit. The row comes after row 197, unless
|
||||
proof 5 fails on K12 again (mos-1182 item 4). Then it goes ahead of 197, and 197's candidates rebase on it.
|
||||
|
||||
## Why
|
||||
|
||||
`packages/conversation/tests/cohort.test.mjs` K12 (line 268) force-stops a member that forks in a loop.
|
||||
Then it asks the shim for `events` and asserts `ev.populated === 0` (line 292). It never checks `ev.ok`.
|
||||
`events()` in `packages/conversation/src/shim.mjs:83-96` returns `{ ok: false, unavailable }` when
|
||||
`engine/cgroup.events` can't be read or has no `populated` field. Under load the test then fails on
|
||||
`undefined !== 0` and never says why.
|
||||
|
||||
This has happened twice:
|
||||
|
||||
- BUILD-LOG.md:4092: conversation went 181/1 at a load of about 12 while mutants ran, and 182/0 alone.
|
||||
- Row 197 proof 4, wave pair1-b, 2026-10-21T16:04Z: 147/1 with two conversation suites running at once.
|
||||
The log is `/mnt/storage/scratch/sage-r197p4/proof-run/pair1-b/out/pkg-conversation.log`.
|
||||
|
||||
## Steps, in order
|
||||
|
||||
1. Make the failure name its cause. The K12 assertion prints `ev` (with `ok` and `unavailable`) when it
|
||||
fails. Commit this first, on its own.
|
||||
2. Find why the file is unreadable or lacks `populated` after `cgroup.kill` under load, and fix that at the
|
||||
source. Use the shim, the cohort controller or the test's setup and teardown, whichever the evidence points
|
||||
to. Record the evidence in `agents/OWNER/work/queue-ROW/BUILD.md`.
|
||||
3. Do not loosen the assertion. Do not treat `unavailable` as empty, or `populated` as 0 when it can't be
|
||||
read. Retrying a read that is still in progress is allowed only if step 2 shows a race in the read
|
||||
itself, and the retry has a bound and a test.
|
||||
|
||||
## Files owned
|
||||
|
||||
`packages/conversation/src/shim.mjs`, `packages/conversation/src/cohort.mjs` if step 2 needs it, and
|
||||
`packages/conversation/tests/cohort.test.mjs`. Nothing in row 197's 37 paths.
|
||||
|
||||
## Tests
|
||||
|
||||
- K12 still asserts `populated` 0 and an empty member list.
|
||||
- A new case shows `events` returning `unavailable` with its reason, and that the controller treats that as
|
||||
unavailable, not as empty.
|
||||
- The conversation suite passes whole three times in a row, and once while a second conversation suite
|
||||
runs at the same time. That is the load that broke it in proof 4.
|
||||
|
||||
## Gate
|
||||
|
||||
The reviewer approves on the row, naming the candidate manifest. The conversation suite and the
|
||||
control-board suite pass whole on a gate rerun in a clean worktree, on Sage's slot.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Brief: T27 lists no identities right after ssh-add -t 1
|
||||
|
||||
Sage, 2026-10-21, for Mos's ruling mos-1185 item 2. Vikunja #453. Gate owner Sage. Owner and reviewer are
|
||||
named when the row is briefed. Base: refactor at the brief commit. The row comes after row 197, unless
|
||||
proof 6 fails on a test outside 197's paths in a pair wave (mos-1185 item 4). Then this row and row 250 go
|
||||
ahead of 197, and 197's candidates rebase on them. The pattern is row 250's
|
||||
(`docs/plans/2026-10-21_k12-cohort-events.md`).
|
||||
|
||||
## Why
|
||||
|
||||
`packages/bus/tests/host-run.test.mjs:453`, "T27: ssh-add -t drops the key from an agent the test keeps
|
||||
alive", starts its own `ssh-agent -D -a SOCK`, calls `addKey(sock, key, 1)` from
|
||||
`packages/bus/src/host-run.mjs:87`, and asserts that `ssh-add -l` lists the key (line 469). Then it sleeps
|
||||
2.5 s and asserts the key is gone.
|
||||
|
||||
Row 197 proof 5, wave pair2-b, 2026-10-21T17:07Z: the first assertion got "The agent has no identities."
|
||||
31.9 ms into the test. The bus suite went 289/1. The log is
|
||||
`/mnt/storage/scratch/sage-r197p5/proof-run/pair2-b/out/pkg-bus.log`. The same test passed in solo-b and
|
||||
pair1-b of that run.
|
||||
|
||||
The cause is not known yet. One lead, unverified (Mos, mos-1185): ssh-agent may count lifetimes in whole
|
||||
seconds of CLOCK_MONOTONIC, so a key added just before a monotonic second boundary expires within
|
||||
milliseconds. A test aligned to wall-clock seconds did not test that idea.
|
||||
|
||||
Sage checked the lead on dragon-lin, OpenSSH 10.5p1, 2026-10-21T17:19Z (`/mnt/storage/scratch/sage-r64/t27-mono.mjs`,
|
||||
outside any gate). It made 80 adds of `ssh-add -t 1`, each at a chosen fraction of a CLOCK_MONOTONIC second
|
||||
(`process.hrtime`), and listed at once. 8 of 80 missed. Every miss was an add made at 0.9947-0.9973 of a
|
||||
monotonic second that finished at 0.998-0.0008. No add earlier in the second missed. So a 1-second lifetime
|
||||
can end within milliseconds of the add. Step 2 still confirms this against the T27 failure itself.
|
||||
|
||||
## Steps, in order
|
||||
|
||||
1. Make the failure name its cause. When the first assertion fails, print the time from addKey's start
|
||||
to its resolve and to the `ssh-add -l` call, the monotonic clock (`process.hrtime.bigint()` or
|
||||
`/proc/uptime`) at each, and the agent's state (alive, socket present). Commit this first, on its own.
|
||||
2. Find the cause and fix it at the source: the test's lifetime choice, `addKey`, or how the agent is
|
||||
started. Record the evidence in `agents/OWNER/work/queue-ROW/BUILD.md`.
|
||||
3. Do not loosen the check. The test still proves the key is listed after the add and gone after its
|
||||
lifetime. A longer lifetime is a fix only if step 2 shows whole-second rounding is the cause, and then
|
||||
the sleep grows with it.
|
||||
|
||||
## Files owned
|
||||
|
||||
`packages/bus/tests/host-run.test.mjs`, and `packages/bus/src/host-run.mjs` only if step 2 points there.
|
||||
Nothing in row 197's 37 paths.
|
||||
|
||||
## Tests
|
||||
|
||||
- T27 still asserts the key is listed, then gone.
|
||||
- The bus suite passes whole three times in a row, and once while a second bus suite runs at the same time.
|
||||
|
||||
## Gate
|
||||
|
||||
The reviewer approves on the row, naming the candidate manifest. The bus suite passes whole on a gate
|
||||
rerun in a clean worktree, on Sage's slot.
|
||||
@@ -322,6 +322,17 @@ at every gate. Started 2026-09-12 during the control board MVP.
|
||||
base. This matters only if someone reruns that set. Fix the anchor
|
||||
in that tooling before any rerun, and check that a stale anchor
|
||||
refuses instead of skipping the mutant (sage-1941). (2026-10-21)
|
||||
- **Grant executor test X5 starts a `sleep 30` that can outlive its test.**
|
||||
(Webby N1 on row 248 r1, webby-0080; deferred by Duckworth in the row
|
||||
249 review, REVIEW-r1 28174fe08, #1751 comment 30070.) The test at
|
||||
`packages/bus/tests/grant-executor.test.mjs:989` (:934 before row
|
||||
249) runs `sleep 30` on the sshd side, where an abort doesn't reach it. Fix it with a stub
|
||||
that exits once its test dir is gone, as row 248 did for the hold
|
||||
stubs, or with a shorter sleep. This binds the next row that touches
|
||||
that test file. Until then, a lane cleanup that runs after this file
|
||||
waits 30 s before it calls a lone `sleep 30` a leak. The row 197
|
||||
lanes scan only at lane end, about 15 minutes after pkg-bus, so they
|
||||
can't see it (mos-1169). (2026-10-21)
|
||||
|
||||
## Queue
|
||||
|
||||
|
||||
+25
-18
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
|
||||
|
||||
<!-- mosaic-queue:begin -->
|
||||
|
||||
Generated from `docs/plans/queue.json` revision 2641 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 2904 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -102,7 +102,7 @@ Generated from `docs/plans/queue.json` revision 2641 by `queue render`. Do not e
|
||||
| 63 | Multi-project verbs: task verbs across the tree | darkwing; reviewers dewey, filbert | #1556 | done | 62 done | dewey and filbert approve on the issue naming the candidate manifest; tasks, harness and every scripts/test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-11_multi-project-sync.md` § Multi-project verbs: task verbs across the tree (row B) | lands before row 90 (decision 125): both share packages/tasks/README.md and tests/verbs.test.mjs, so row 90 rebases after this lands |
|
||||
| 64 | Managed sessions get their role's skills on both harnesses | sage; reviewers darkwing, filbert | #1557 | done | 65 done | darkwing and filbert approve on #1557 naming the candidate manifest; business, harness, cli and every test-*.sh green on Sage's gate rerun; Sage's landing session on each harness shows the role's skills (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Managed sessions get their role's skills on both harnesses | Landed (mos-0863/0864): 9f0039711 = c6d7d538f (range-diff =, patch-id 7e1948e2f2e9), F1 doc 6b6567b8b, packet ebfc23c43; gate-land GATE_PASSED 2384/0 on 6b6567b8b. D2 on #1557 comment 29228: claude-code coder rc13bcd555652 replied with its 4 skills, pi coder r3455505dd5da launch only (no pi credential on host, to Mos); both manifests list the 4 coder skills, sha256 = landed files. D3 window step 46de60893, D4 #1672 row 174. |
|
||||
| 65 | Model routing table: sessions resolve harness, model and thinking from one table | sage; reviewers darkwing, dewey | #1558 | done | 49 done, 131 done | Mos clears the routing table's public/ diff (decision 123, mos-0840) before in-review; darkwing and dewey approve on #1558; plugins, bus, business, harness, seat, cli, webui, test-*.sh green on Sage's rerun; landing session shows module, route, counted model, thinking; report names the mos-0840 view (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Model routing table: sessions resolve harness, model and thinking from one table | Decision 94 hold ended (cutover live 17:21Z); restarted under decision 111 and Mos sage-0091. |
|
||||
| 66 | Managed sessions run the unslop check on both harnesses | sage; reviewers dewey, filbert | #1559 | in-review, round 3, request posted | 64 done | dewey and filbert approve on #1559 naming the candidate manifest; unslop, harness, cli and every test-*.sh green on Sage's gate rerun; Sage's landing session on each harness shows the notice, the correction and the counts (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Managed sessions run the unslop check on both harnesses | Decision 94 hold ended (cutover live 17:21Z); restarted under decision 111 and Mos sage-0091. |
|
||||
| 66 | Managed sessions run the unslop check on both harnesses | sage; reviewers dewey, filbert | #1559 | in-review, round 4, request posted | 64 done | dewey and filbert approve on #1559 naming the candidate manifest; unslop, harness, cli and every test-*.sh green on Sage's gate rerun; Sage's landing session on each harness shows the notice, the correction and the counts (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Managed sessions run the unslop check on both harnesses | Drift: row 245 (6d5545d44) changed docs/TOOLS.md, which is in this manifest. r4c must take 245's TOOLS.md hunks before its gate and new manifest; the approved TOOLS.md blob is now stale (sage-1992 sweep). |
|
||||
| 67 | Decisions read follow-up: seen-then-resolve, list-before-503 and full-row tests | filbert; reviewers darkwing, dewey | #1560 | done | — | darkwing and dewey approve on #1560 naming the candidate manifest; bus, webui and every scripts/test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-11_decisions-read-follow-up.md` § Decisions read follow-up: seen-then-resolve, list-before-503 and full-row tests | — |
|
||||
| 68 | Bus host runs an explicit Claude Code binary | sage; reviewers dewey, filbert | #1561 | done | — | Filbert and Dewey approve; cli, seat, harness suites and every scripts/test-*.sh green on Sage's gate rerun; unit reinstalled and opus probe through MOSAIC_CLAUDE_BIN (sage) | `docs/plans/2026-10-11_bus-claude-binary.md` § Bus host runs an explicit Claude Code binary | — |
|
||||
| 69 | Terminal app follow-up: narrow-terminal guard test, hidden-box way out and width tests | darkwing; reviewers dewey, filbert | #1562 | done | 55 done | dewey and filbert approve on the issue naming the candidate manifest; cli and queue node suites plus every scripts/test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-11_terminal-app-follow-up.md` § Terminal app follow-up: narrow-terminal guard test, hidden-box way out and width tests | Sage accepts the two shortened texts in round 1 (hidden box way out, status line), both forced by the 40x10 frame. Follow-up at landing: when help is cut to fit, draw() points at mosaic inbox --json though help has no whole text; open it as its own row (build.md Found, not fixed) |
|
||||
@@ -231,9 +231,9 @@ Generated from `docs/plans/queue.json` revision 2641 by `queue render`. Do not e
|
||||
| 192 | Q175 phase 1: the Console verifies SSO itself (--sso), OIDC RP with oauth4webapi 3.8.8, no live change (MVP T1.2, #412) | launchpad; reviewers webby | #1690 | done | — | Webby approves the manifest; webui and cli suites, scripts/test-config.sh and every scripts/test-*.sh green on Sage's gate; design section 9 mutants killed (sage) | `docs/plans/2026-10-20_sso-phase1.md` § Brief: Q175 phase 1, the Console verifies SSO itself (--sso) | Landed 4429a104f on refactor (squash of launchpad/row192 53bb59462..37582e820, round 2 manifest 6f8cee14...7787). patch-id 4a4869026 matches the reviewed diff on all 18 paths. Gate on 088087ac9: package suites 2758/0, scripts 429/0, mutants 22 of 23 killed (#23 equivalent, sage-1407). npm ci in canonical, oauth4webapi 3.8.8. Live re-pin is a window with Mos (mos-1099 item 3). |
|
||||
| 193 | many humans per business, human roles, private and shared threads, one access.json store (MVP T1.3, Vikunja #414/#415) | morgana; reviewers duckworth | #1691 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, business, cli and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users.md` § Brief: many humans per business, human roles, private and shared threads | — |
|
||||
| 194 | agent lifecycle from the Console and the CLI (create, launch, terminate, regenerate, delete) with a per-agent hire/fire veto (MVP T1.5, Vikunja #414/#415) | honker; reviewers webby | #1692 | in-progress | — | Webby approves DESIGN.md then the manifest; bus, cli, business and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: agent lifecycle from the Console and the CLI, with a per-agent hire/fire veto | sage-1610 (honker-0049): a human add takes tracker {bot, botId}, required for a vikunja definition, refused otherwise, checked by checkBot and bot uniqueness; CLI and API only. Delete accepts a tracker bot when all credentials are store refs, and its result names the bot for removal by hand in Vikunja. No Vikunja account is created (Q188 untouched). Mutants: drop the requirement, refuse the bot on delete, skip checkBot on add. |
|
||||
| 195 | row 195: the Case 1 install on 10.1.1.53 as mosaic under /srv/mosaic, reusing C2-13's scripts by input under row 190's canon (Q181, Q182/Q183, Q185; ops-01 root steps under #423 step 1) | darkwing; reviewers duckworth | #1693 | briefed | 190 done, 233 done | Duckworth approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Brief: the Case 1 install on 10.1.1.53 (row 195) | sage-1439: rewritten to Case 1 on .53 (f24f2cd40). Chains on C2-13's candidate too once C2-13 is queued. Darkwing starts DESIGN.md after row 190's mutants. |
|
||||
| 195 | row 195: the Case 1 install on 10.1.1.53 as mosaic under /srv/mosaic, reusing C2-13's scripts by input under row 190's canon (Q181, Q182/Q183, Q185; ops-01 root steps under #423 step 1) | darkwing; reviewers duckworth | #1693 | done | 190 done, 226 done, 233 done | Duckworth approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Brief: the Case 1 install on 10.1.1.53 (row 195) | LANDED b65014647 (pushed 361ba2387 tip). Patch-id 656e43ff25de = 4ced5e63d..37171f65b; verify-commit ok, 5 paths. Combined gate with 213 on 38b83ea5c+paths 09:45:52-09:57:52Z: business 200/0 bus 542/0 harness 140/0 cli 663/0, all 10 scripts green, L1 killed. N1-N3 to DEFERRED next; follow-up darkwing/row195-n1 1e3c4767c. |
|
||||
| 196 | the human boundary across uids and humans: Console, tester listener, broker.sock and the path-bound human proof (P19 333, before federation R3, mos-0989) | gander; reviewers duckworth | #1694 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-boundary.md` § Brief: the human boundary across uids and humans (P19 333) | — |
|
||||
| 197 | a second gate lane: two full gates at once on dragon-lin, design first (mos-0977 item 6) | herb; reviewers webby | #1695 | in-review, round 1, request posted | — | Webby approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate, singly and in the two-lane proof (sage) | `docs/plans/2026-10-20_second-gate-lane.md` § Brief: a second gate lane, two full gates at once on dragon-lin | Order with row 198 (Sage, mos-1040 N1): 197 lands first. After B1 the shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. Row 198 rebases onto 197's landing before its manifest review. |
|
||||
| 197 | a second gate lane: two full gates at once on dragon-lin, design first (mos-0977 item 6) | herb; reviewers webby | #1695 | in-review, round 6, request posted | — | Webby approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate, singly and in the two-lane proof (sage) | `docs/plans/2026-10-20_second-gate-lane.md` § Brief: a second gate lane, two full gates at once on dragon-lin | r7 APPROVED (webby-0074 rev 2822). r7 proof sage-1973 11:50-11:51Z STOPPED at solo-a pkg-cli (#1695 c30044): gate-lane-image.test.mjs:93 and :103 call docker via PATH, which in a lane is the guard shim; composeFixture env drops MOSAIC_GATE_DOCKER_RECORD, shim exits 2. business 200/0, bus 596/0, cli 197/2. r8 Herb: test-only fix + run under a lane-shaped PATH. N10 still open. |
|
||||
| 198 | retire the control board writer on 7331 (unauthenticated loopback writer, Q175; mos-1008) | bushroot; reviewers webby | #1696 | in-progress | 192 done, 197 done | Webby approves INVENTORY.md then the manifest; control-board, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_retire-control-board.md` § Brief: retire the control board writer on 7331 | Order with row 197 (Sage, mos-1040 N1): 197 lands first. Row 198 rebases onto 197's landing before its manifest review; shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. |
|
||||
| 199 | MVP T1 item 6: a credential store per agent, own git identity, pushes to a remote (G11, G13; mos-0974) | megavolt; reviewers duckworth | #1697 | done | — | Duckworth approves DESIGN.md; Mos rules on the authority model; Duckworth approves the manifest; bus, business, cli and tasks suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: a credential store per agent, own git identity, pushes to a remote | Landed cb5fdb064 on refactor (base 76134dfde), pushed. Patch-id 828fa21a matches git diff 00d4cc1f6..86963d281 on the 37 manifest paths; docs/TOOLS.md 3-way with row 200, hunk unchanged. Manifest 35f900fd, Duckworth approve r1 29779. Gate on 9e1085816+patch: packages 2735/0, scripts 429/0; landed blobs equal the gate tree. |
|
||||
| 200 | stack-release.sh activate --sso for the Q175 phase 2 window (row 192 F3; chained on 192) | launchpad; reviewers webby | #1698 | done | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: stack-release.sh activates a Console with --sso (Q175 phase 2 prerequisite) | Landed 9e1085816 on refactor (base 722136772), pushed. Patch-id 7ebfa6a5 matches git diff 00d4cc1f6..9df40878f on the 3 manifest paths. Manifest b3ead55c, Webby approve r3 29772. Gate on 9df40878f: packages 2675/0, scripts 429/0. Mutants 13/14, M12 ruled equivalent (sage-1446). |
|
||||
@@ -248,37 +248,44 @@ Generated from `docs/plans/queue.json` revision 2641 by `queue render`. Do not e
|
||||
| 209 | FED R8: the hub window on 10.1.1.54, a recorded acceptance (#414, r4.11 b93f098f0; live window run by Jason and Mos, after the Case 2 install #416, Bean and Chet live and both Hermes gateways stopped) | sage; reviewers duckworth | — | queued | — | Every entry check's output is in the R8 record with who ran it; Jason signs in to the hub and sends one message, Melanie revokes, his next request is refused; Duckworth checks the record (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R8, the hub window on 10.1.1.54 | Stays queued until its entry gates near: Case 2 install on .54 (#416, by 2026-10-27), Bean and Chet live, both Hermes gateways on .54 stopped, R7, #412 phase 2, #419 answer. Filed per mos-1073 after duckworth-0034 (Sage) |
|
||||
| 210 | Agent host access with sudo through the credential store: grant per agent and host, the stack does ssh and sudo, the agent never sees the key (#1707, Vikunja #424, Q183/Q184, due 10-27; chained on row 199's candidate) | gizmoduck; reviewers duckworth | #1707 | done | — | Duckworth approves DESIGN.md, then the manifest; bus, business, cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_host-credentials.md` § Brief: agent host access with sudo through the credential store (#424) | Landed f5753c0e8 on refactor (base 9d165be8c), pushed. Patch-id 723b1c83 matches git diff ad95c888c..0485384e9 on the 23 manifest paths; landed files pass r5 manifest 688d38b9. Duckworth approve r2 rev 2592, r3 29851. Mutants 52/53, M22b equivalent (rev 2584). Gate on ad95c888c+patch: packages 2796/0, scripts 429/0; landed blobs equal the gate tree. |
|
||||
| 211 | Force-push guard: scripts/git-hooks/pre-push refuses non-fast-forward and delete pushes from every worktree, and scripts/slot-busy.sh exits 1 on BUSY (#1709, Vikunja #426, mos-1078/1083) | rocko; reviewers dewey | #1709 | briefed | — | Dewey approves the manifest; the new or changed scripts/test-*.sh and every other scripts/test-*.sh green on Sage's gate; the mutants killed; hook installed in the canonical checkout with a scripted refused force-push (sage) | `docs/plans/2026-10-20_force-push-guard.md` § Brief: a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY (#426) | — |
|
||||
| 212 | Case 2 C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey | gosalyn; reviewers webby | #1713 | in-review, round 1, request posted | — | Webby approves the manifest. The library tests and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey | — |
|
||||
| 213 | Case 2 C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | morgana; reviewers webby | #1714 | in-review, round 2, request posted | — | Webby approves the manifest. The business, bus and harness suites, `scripts/test-config.sh` and every `scripts/test-*.sh` are green on Sage's gate, and the `models` mutant is killed. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | — |
|
||||
| 212 | Case 2 C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey | gosalyn; reviewers webby | #1713 | done | — | Webby approves the manifest. The library tests and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey | Landed ee7b2f137 (pushed 09:13Z) on f6c70f944, alone per mos-1162: the 21 manifest paths from a2c72ac95. Patch-id ea84d3e35470 equals the candidate's; blobs and modes match; verify-commit ok (21 paths). No 212 path changed after c771a36f4. Gate: Sage's landing gate 08:51-09:03Z on 4ced5e63d plus 212's and 197's paths, GATE_PASSED, node 981/0, selftests 429/0. Gosalyn r3 slot 451/0, test-queue 28/0. Row 197 waits for r6 (lane proof stopped, sage-1881). |
|
||||
| 213 | Case 2 C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | morgana; reviewers webby | #1714 | done | — | Webby approves the manifest. The business, bus and harness suites, `scripts/test-config.sh` and every `scripts/test-*.sh` are green on Sage's gate, and the `models` mutant is killed. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | LANDED 361ba2387 (39 paths incl. 4 packet files). Patch-id f313e69b6f7e = c771a36f4..2b3e67e36; verify-commit ok, 35 manifest paths. Same combined gate as 195: business 200/0 bus 542/0 harness 140/0 cli 663/0, test-config 31/0 and all 10 scripts green, models mutant L1 killed. Webby N2 is row 235 acceptance (mos-1163). |
|
||||
| 214 | Case 2 C2-11a editor wikilink node, after measuring whether [[ round-trips | rocko; reviewers filbert | #1715 | briefed | — | Filbert approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11a editor wikilink node, after measuring whether [[ round-trips | — |
|
||||
| 215 | Case 2 C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | herb; reviewers filbert | #1716 | briefed | — | Filbert approves the manifest. The business, seat, cli, project, bus and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | — |
|
||||
| 215 | Case 2 C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | herb; reviewers filbert | #1716 | in-review, round 1, request posted | — | Filbert approves the manifest. The business, seat, cli, project, bus and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | Sage 15:01Z drift sweep after 249 landed 9de5a57ab (+7b9737953): manifest 8d5695d7 shares packages/bus/src/broker.mjs with 249, merge-tree was clean. Order stays 197 then 215 (sage-2029, sage-2030): after 197 lands Herb rebases 215 on that tip for a merge-scoped round with Filbert (broker.mjs from 249, stack-release.test.mjs from 197), then Sage's full landing gate. Rows 13, 66, 197, 202 have no overlap. |
|
||||
| 216 | Case 2 C2-05a lookup index and queries | researcher; reviewers filbert | #1717 | briefed | — | Filbert approves the manifest. The library suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05a lookup index and queries | — |
|
||||
| 217 | Case 2 C2-06 the cited-reply guard, the page store and the three notices | launchpad; reviewers webby | #1718 | briefed | — | Webby approves the manifest. The library, bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-06 the cited-reply guard, the page store and the three notices | — |
|
||||
| 218 | Case 2 C2-12 the setup sheet for Melanie and the survey run sheet | sage; reviewers webby | #1719 | briefed | — | Doc only. Webby approves it against C2-01's real flags. Before landing, grep `packages/*/tests` and `scripts/test-*.sh` to confirm no test reads either file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12 the setup sheet for Melanie and the survey run sheet | — |
|
||||
| 218 | Case 2 C2-12 the setup sheet for Melanie and the survey run sheet | sage; reviewers webby | #1719 | done | — | Doc only. Webby approves it against C2-01's real flags. Before landing, grep `packages/*/tests` and `scripts/test-*.sh` to confirm no test reads either file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12 the setup sheet for Melanie and the survey run sheet | LANDED f8276c5eb (pushed, on Darkwing sessions b65f3a0e8). Webby APPROVE r1-r3 (revs 2848, 2856, 2860; r3 candidate 1a38baf5af2a = 89ba79d94). Patch-id 18ba1e40ad29 seat 5c6f73ed7..89ba79d94 equals landed b65f3a0e8..f8276c5eb; three blobs equal 89ba79d94. Gate: doc only, 0 test readers (control 3). Melanie handover waits on C2-06 and Jason (#419). |
|
||||
| 219 | Case 2 C2-09a one thread per creative instance | rocko; reviewers dewey | #1720 | briefed | — | Dewey approves the manifest. The bus, cli and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. Watch for hash pins on `bus.js` and `broker.mjs` (`packages/bus/tests/channel-proof.test.mjs` pins a webui file). (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-09a one thread per creative instance | — |
|
||||
| 220 | Case 2 C2-10a no raw session view (M7) and no raw Discord engine (M8) | researcher; reviewers filbert | #1721 | briefed | — | Filbert approves the manifest. The conversation and discord suites and `scripts/test-discord.sh` are green on Sage's gate, along with every `scripts/test-*.sh`. No test reaches a Discord server. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10a no raw session view (M7) and no raw Discord engine (M8) | — |
|
||||
| 221 | Case 2 C2-20 Bean and Chet persona import, with a count-only dry run | gosalyn; reviewers webby | #1722 | briefed | — | Webby approves the manifest. The test file runs green on its own and the cli suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-20 Bean and Chet persona import, with a count-only dry run | — |
|
||||
| 222 | Case 2 C2-03a fetch-only sync | megavolt; reviewers dewey | #1723 | in-progress | — | Dewey approves the manifest. The library and bus suites are green on Sage's gate, along with every `scripts/test-*.sh`, and the mutant is killed. A search of the test run's output for the fixture token finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-03a fetch-only sync | — |
|
||||
| 221 | Case 2 C2-20 Bean and Chet persona import, with a count-only dry run | gosalyn; reviewers webby | #1722 | done | — | Webby approves the manifest. The test file runs green on its own and the cli suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-20 Bean and Chet persona import, with a count-only dry run | LANDED 300766450, pushed. Cand 8f403d0d4, manifest 20f05e3a6ad5, 22 paths, verify-commit ok, patch-id dfbb754c6fb4 equals 62ddc96cc..8f403d0d4. Gate 11:07:50-11:19Z on b1fece6f0: test file alone 11/0, harness 140/0, cli 677/0, all scripts green. Brief Gate met: webby-0073 approve, test file alone, cli, scripts. No live run in the Gate; the import itself waits on Jason. N1 N2 N4 go to C2-20b, N3 to DEFERRED. |
|
||||
| 222 | Case 2 C2-03a fetch-only sync | megavolt; reviewers dewey | #1723 | in-progress | — | Dewey approves the manifest. The library and bus suites are green on Sage's gate, along with every `scripts/test-*.sh`, and the mutant is killed. A search of the test run's output for the fixture token finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-03a fetch-only sync | sage-2024 on megavolt-0089: core.createObject=rename in the fetch argv is accepted as a brief departure, since C2-04 inspectMirror refuses nlink 2. Dewey checks it. The askpass env keys, export SAFE in project/src/git.mjs, bus/tests/library-sync.test.mjs and the SURVEY-RUN user line are accepted. The 5 s client timeout residual goes in BUILD.md and Dewey rules fix or defer. Slot after 197 proof 3 and the 249 gate. |
|
||||
| 223 | Case 2 C2-07 runner retry on a guard refusal, and the runner's notices | researcher; reviewers filbert | #1724 | briefed | — | Filbert approves the manifest. The harness suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-07 runner retry on a guard refusal, and the runner's notices | — |
|
||||
| 224 | Case 2 C2-05b broker lookup verbs and typed tools | rocko; reviewers dewey | #1725 | briefed | — | Dewey approves the manifest. The bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05b broker lookup verbs and typed tools | — |
|
||||
| 225 | Case 2 C2-10b Console chat: cited items and the status line | researcher; reviewers dewey | #1726 | briefed | — | Dewey approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. Grep for hash pins on `bus.js` and `serve.mjs` before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10b Console chat: cited items and the status line | — |
|
||||
| 226 | Case 2 C2-13 install script and precheck for the host | gosalyn; reviewers webby | #1727 | briefed | — | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. The run on the host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13b install script and library precheck | — |
|
||||
| 226 | Case 2 C2-13 install script and precheck for the host | gosalyn; reviewers webby | #1727 | briefed | 215 done, 221 done, 222 done, 230 done | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. The run on the host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13b install script and library precheck | — |
|
||||
| 227 | Case 2 C2-14 end-to-end acceptance on a scratch root | launchpad; reviewers dewey | #1728 | briefed | — | Dewey runs the script and approves. It is green on Sage's gate with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-14 end-to-end acceptance on a scratch root | — |
|
||||
| 228 | Row A: ZAI_API_KEY passes through ENV_ALLOW to sessions, README line, pass-through test (XS, Haiku high) | researcher; reviewers webby | #1729 | in-review, round 1, request posted | — | Webby approves the manifest. The mutant is killed. The seat suite and the cli suite are green on Sage's gate with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § Brief: row A, ZAI_API_KEY in the session env allowlist | — |
|
||||
| 228 | Row A: ZAI_API_KEY passes through ENV_ALLOW to sessions, README line, pass-through test (XS, Haiku high) | researcher; reviewers webby | #1729 | done | — | Webby approves the manifest. The mutant is killed. The seat suite and the cli suite are green on Sage's gate with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § Brief: row A, ZAI_API_KEY in the session env allowlist | Landed 86249ac55 (pushed 07:25Z) on 59f817c32: the 3 manifest paths from cf18c42b9. Patch-id b6ffe31feb0d equals the candidate's; modes and blobs match; verify-commit ok (3 paths). Sage's combined 228+238 gate on c515273db plus both rows' 6 paths, 07:11:30Z to 07:23:34Z: seat 165/0, cli 640/0, all ten scripts/test-*.sh 585/0. |
|
||||
| 229 | Row B: per-instance pi agent dir for Chet's Codex login, design only, starts after 10-27 (#428) | gosalyn; reviewers webby | #1730 | queued | — | A live check, with Jason's or Mos's go, that two clients on one ChatGPT login don't revoke each other's refresh tokens. No build row starts before it passes. Held until after 10-27. (sage) | `docs/plans/2026-10-20_case2-rows.md` § Brief: row B, a per-instance pi agent dir for Chet's Codex login (#428) | — |
|
||||
| 230 | Case 2 C2-13a unit render and host precheck (case2-unit.sh, case2-precheck.sh) | gosalyn; reviewers webby | #1732 | done | — | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every scripts/test-*.sh. A run on a host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13a unit render and host precheck | Landed c8bc5cf4c on refactor (base 390ea5261), pushed. Patch-id f4b49eb68997 matches git diff 722136772..96768766c on the 5 manifest paths (manifest fbb0d7e42c67, Webby approve r2 rev 2563). packages/cli/README.md 3-way with row 199, hunks apart. Gosalyn gate on 96768766c: cli 541/0, scripts 429/0, mutants 10/10. Merged tree check: case2-unit 30/0, host 42/0. |
|
||||
| 231 | Case 2 C2-01b survey escapes, list item lines and attachment embeds (row 212 N1-N3) | gosalyn; reviewers webby | #1733 | briefed | 212 done | Webby approves the manifest. The library tests are green on Sage's gate, along with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01b survey escapes, list item lines and attachment embeds | — |
|
||||
| 231 | Case 2 C2-01b survey escapes, list item lines and attachment embeds (row 212 N1-N3) | gosalyn; reviewers webby | #1733 | done | 212 done | Webby approves the manifest. The library tests are green on Sage's gate, along with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01b survey escapes, list item lines and attachment embeds | LANDED 29ce1e360 (sage gate 10:05:31-10:07:52Z GREEN on 62ddc96cc: library 45/0, every scripts/test-*.sh). verify-commit ok, 9 paths; patch-id 59b98b1b3e00 equals fefd54e13..f37a65857. Pushed. Webby N4 stays as restored (sage-1917), doc-only. |
|
||||
| 232 | Installer I1: stack-release external units and stack-copy bundle copies (MVP, Q187, Vikunja #432) | darkwing; reviewers filbert | #1734 | done | — | Filbert approves the manifest. The cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) | `docs/plans/2026-10-20_installer-rows.md` § I1 stack-release external units and bundle copies | Landed e65a3ab4d on refactor (base 0444acd08), pushed. Patch-id 5adfbab9 matches git diff 8fede5fc6..da2da2f6c on the 9 manifest paths; tree entries (modes too) equal da2da2f6c, files pass manifest 7af3c943. Filbert approve r1 29829, r2 29865. Darkwing's landing gate on da2da2f6c: cli 593/0, scripts 429/0 (+156 queue node tests). Mutants 17/17. |
|
||||
| 233 | Installer I3: stack-upgrade.sh root driver and the ops-01 host run sheet (MVP, Q187, Vikunja #432) | darkwing; reviewers filbert | #1735 | in-progress | 232 done | Filbert approves the manifest. The test file, the cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) | `docs/plans/2026-10-20_installer-rows.md` § I3 the upgrade command and the host run sheet | — |
|
||||
| 234 | Machine grant row a: host enrollment, store records and migration 3i-3j (#436a, design r6 f9039682e, lands by 10-25 before the .53/.54 installs) | gizmoduck; reviewers duckworth | #1740 | in-progress | 210 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. The design's section 10 row a tests and mutants pass, the migration test included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row a: enrollment, store records and migration 3i-3j | — |
|
||||
| 235 | Machine grant row b: raise binding, executor, reconcile and revoke (#436, design r6 f9039682e, C1, N1, G27, G28) | gizmoduck; reviewers duckworth | #1741 | queued | 234 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 10's row b tests and mutants pass, G27, G28 and the upsert mutant included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b: raise binding, executor, reconcile and revoke | — |
|
||||
| 233 | Installer I3: stack-upgrade.sh root driver and the ops-01 host run sheet (MVP, Q187, Vikunja #432) | darkwing; reviewers filbert | #1735 | done | 232 done | Filbert approves the manifest. The test file, the cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) | `docs/plans/2026-10-20_installer-rows.md` § I3 the upgrade command and the host run sheet | Landed 57f59e0c3 on 5465fd52d (pushed 05:39Z). verify-commit matches the manifest (7 paths). Patch-id b16ac924 equals the candidate's (1a1dc80ca vs e65a3ab4d). Modes match. Sage's gate on the landing tree: cli 617/0, scripts/test-*.sh 585/0. Filbert N1, N4 and N5 go to row 240, N2 and N3 to DEFERRED. |
|
||||
| 234 | Machine grant row a: host enrollment, store records and migration 3i-3j (#436a, design r6 f9039682e, lands by 10-25 before the .53/.54 installs) | gizmoduck; reviewers duckworth | #1740 | done | 210 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. The design's section 10 row a tests and mutants pass, the migration test included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row a: enrollment, store records and migration 3i-3j | LANDED c771a36f4 (pushed 06:16Z 10-21) on caa4b5944: r3 c783d5e39 41 paths via temp index, modes from ls-tree. patch-id 32bbbea2bd6f equals the candidate's; verify-commit ok 41 paths. Gate on the c783d5e39 tree 06:02:12Z-06:15:26Z: 15 packages 2890/0, ten scripts/test-*.sh 585/0. Duckworth r3 approve 29935 rev 2683. N10, N12 to row 235. |
|
||||
| 235 | Machine grant row b: raise binding, executor, reconcile and revoke (#436, design r6 f9039682e, C1, N1, G27, G28) | gizmoduck; reviewers duckworth | #1741 | done | 234 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 10's row b tests and mutants pass, G27, G28 and the upsert mutant included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b: raise binding, executor, reconcile and revoke | LANDED b1fece6f0, pushed (rode Darkwing SESSIONS aa19a2464). Cand ed4193316, manifest dad377ca8fe4, 13 paths, verify-commit ok, patch-id c355d2fc457d. Gate 10:30:55-10:42:43Z: bus 596/0, business 200/0, cli 666/0, all scripts green. Brief Gate met: duckworth-0089 approve, upsert mutant equivalent, row b and creative tests and mutants on Gizmoduck slot runs (38 of 39 killed, B17 equivalent). |
|
||||
| 236 | #436b Gitea grant path build: seat accounts and tokens through an approved grant, schema 3j-3k (design r5 1be535aca, due 10-27 at risk; live waits for Jason's section 8 approval and bootstrap step) | megavolt; reviewers dewey | #1742 | queued | 210 done, 234 done | Dewey approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 9's mutants are killed, N9's restart test included. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: #436b Gitea grant path, build (schema 3j-3k) | — |
|
||||
| 237 | Row 199 follow-up N1: one decision publishes once (publish-busy or #spent recheck) | megavolt; reviewers duckworth | #1743 | done | 199 done | Duckworth approves the manifest. The bus suite and every scripts/test-*.sh are green on Sage's gate, and the mutant is killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 199 follow-up N1, one decision publishes once | Landed d7d943199 on refactor (local, push pending), patch-id c5f4beb4 = e42415935, manifest bb777cb0 blobs checked. Sage gate 01:31-01:44Z on 816d48b03+4 paths: M1 killed (bus 1/13, cli 1/8), every package suite and scripts/test-*.sh green (bus 478, cli 564). |
|
||||
| 238 | row 200 follow-up N1: the sso-config hint carries --instance NAME (#1744) | launchpad; reviewers webby | #1744 | in-review, round 1, request posted | 200 done | Webby approves the manifest; stack-release.test.mjs whole, cli suite and every scripts/test-*.sh green on Sage's gate; the mutant killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: row 200 follow-up N1, the sso-config hint names the instance | — |
|
||||
| 238 | row 200 follow-up N1: the sso-config hint carries --instance NAME (#1744) | launchpad; reviewers webby | #1744 | done | 200 done | Webby approves the manifest; stack-release.test.mjs whole, cli suite and every scripts/test-*.sh green on Sage's gate; the mutant killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: row 200 follow-up N1, the sso-config hint names the instance | Landed d49b1f2bd (pushed 07:25Z) on 86249ac55: the 3 manifest paths from 4c90099ea. Patch-id b9d0bb0ee3c7 equals the candidate's; modes and blobs match; verify-commit ok (3 paths). Sage's combined 228+238 gate on c515273db plus both rows' 6 paths, 07:11:30Z to 07:23:34Z: seat 165/0, cli 640/0, all ten scripts/test-*.sh 585/0. Mutant (sso-config drops --instance) killed, survived none. Round 1 N1 stays on the #1744 carry list. |
|
||||
| 239 | Row 194 follow-up: the credential revoke runs under the writer's lock, refusals after a revoke say so, and a stale-revision fire test (duckworth-0074, mos-1158) | honker; reviewers duckworth | #1745 | queued | 194 done | Duckworth approves the manifest. The bus, business, cli and webui suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: row 194 follow-up, the revoke under the writer's lock and a fire test | — |
|
||||
| 240 | Installer I4: hostcheck, the bwrap profile check, pins and boot unit, from r6 B2 (MVP, Q187, Vikunja #432; split from I3) | darkwing; reviewers filbert | #1746 | queued | 233 done | Filbert approves the manifest. The test file, the cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) (sage) | `docs/plans/2026-10-20_installer-rows.md` § I4 hostcheck: the bwrap profile check, pins and boot unit | — |
|
||||
| 240 | Installer I4: hostcheck, the bwrap profile check, pins and boot unit, from r6 B2 (MVP, Q187, Vikunja #432; split from I3) | darkwing; reviewers filbert | #1746 | done | 233 done | Filbert approves the manifest. The test file, the cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) (sage) | `docs/plans/2026-10-20_installer-rows.md` § I4 hostcheck: the bwrap profile check, pins and boot unit | Landed c515273db (pushed 06:57Z) on da3051ccc: the 9 manifest paths from ad1b23ab2. Patch-id 48e376efe8a4 equals the candidate's; modes and blobs match; verify-commit ok (9 paths). Sage's gate on 060a17b54 plus the 9 paths, 06:45:05Z to 06:56:21Z: cli 639/0, all ten scripts/test-*.sh 585/0. Darkwing's r2: singles 37/0, M1-M8 8/8. Filbert N1 and N2 in DEFERRED (da3051ccc); N3 by design. |
|
||||
| 241 | Row 237 follow-up: one decision lands once, a two-ticket broker land test and three mutants on the landing guards (megavolt-0069, sage-1651, P19 441) | megavolt; reviewers duckworth | #1747 | done | 237 done | Duckworth approves the manifest. The bus, seat and cli suites and every scripts/test-*.sh are green on Sage's gate, and the three mutants are killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 237 follow-up, one decision lands once, with tests at the broker | Landed 5d4a61776 on refactor, pushed (base e65a3ab4d). Blobs from 3e3ed303e on 2 paths; patch-id a1649f80 equals git diff f5753c0e8..3e3ed303e on those paths; ls-tree modes equal; manifest e8266864 verifies on the landed files. Sage slot on bbf3b1ee8: M1-M3 killed; gate 15 packages 2797/0, 10 script suites 429/0. Duckworth approve 29870 rev 2628. |
|
||||
| 242 | Row 237 follow-up N1: a retry after publish-timeout pushes once; hold the decision or kill and wait for the push, a slow-push retry test and a mutant (duckworth-0069, P19 440) | megavolt; reviewers duckworth | #1748 | done | 237 done | Duckworth approves the manifest. The bus and cli suites and every scripts/test-*.sh are green on Sage's gate, and the mutant is killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 237 follow-up N1, a retry after publish-timeout pushes once | Landed 5fb736b92 on refactor, pushed (base 96bac588f). Blobs from 635c10924 on 3 paths; patch-id ceb741ee equals git diff 8fede5fc6..635c10924 on those paths; ls-tree modes equal; manifest 1fbfab6c verifies on the landed files. Megavolt M1/M2 killed on Sage's slot; Sage gate 15 packages 2826/0, 10 script suites 429/0. Duckworth approve 29889 rev 2639. |
|
||||
| 243 | process.test.mjs startup token refusal sets its file mode (chmod), umask sweep of packages/*/tests | morgana; reviewers webby | — | briefed | 197 done | Webby approves the manifest. The test file passes whole under umask 022 and under umask 077 on Sage's slot. (sage) | `docs/plans/2026-10-21_gate-env-rows.md` § The startup token refusal test sets its file mode | mos-1162: lands after 197 r6, whose proof runs on the unchanged test. |
|
||||
| 244 | Gate runs use a fixture PI_AUTH_FILE (DEFERRED 059577886, Webby N4) | morgana; reviewers webby | — | briefed | 197 done | Webby approves the manifest. The new test and every touched file are green on Sage's slot, along with test-task.sh and test-release.sh. (sage) | `docs/plans/2026-10-21_gate-env-rows.md` § Gate runs use a fixture pi auth file | — |
|
||||
| 245 | row 195 follow-up: Duckworth's N1-N3 on scripts/case1-accept.sh (root never runs an unchecked --node; the header lists what root runs; F6 refuses .. after the claude pin dir) | darkwing; reviewers duckworth | #1693; closes none | done | — | Duckworth approves the manifest. The cli suite and every scripts/test-*.sh are green on Sage's gate, and a mutant for each of N1 and N3 is killed. (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Row 195 follow-up: Duckworth's N1-N3 on the Case 1 acceptance script | LANDED 6d5545d44 on 856c1826b, pushed. Patch-id 36a621ac3966 equals 62ddc96cc..ef8b4439e. Gate: Duckworth r1 APPROVE rev 2849; N1 mutants 13-15 and N3 mutant 16 killed (sage-1961, darkwing-0107); Sage gate 12:24-12:35Z: case1-accept alone 23/0, cli 681/0, scripts/test-*.sh 429/0. Lands before row 246 as the brief asks. #1693 stays open for 246. |
|
||||
| 246 | row 195 window: the Case 1 acceptance run on 10.1.1.53 (carries row 195's done condition; needs a Jason or Mos go) | darkwing; reviewers duckworth | #1693 | queued | 226 done, 245 done | The window's acceptance run passes on .53, Duckworth reads its output, and Mos confirms. (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Row 195 window: the Case 1 acceptance run on 10.1.1.53 | — |
|
||||
| 247 | Case 2 C2-20b persona import follow-up: printed keys, the dry run fence check and the mkdir race | gosalyn; reviewers webby | #1749 | done | 221 done | Webby approves the manifest. The test file runs green on its own, and the three mutants are killed on Sage's slot. The cli suite and every scripts/test-*.sh are green on Sage's gate. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-20b persona import follow-up: printed keys, the dry run fence check and the mkdir race | LANDED 856c1826b on f8276c5eb, pushed. Patch-id 373ab2cc4c50 equals 6d43618a3's. Gate: Webby r1 APPROVE rev 2852; test file alone 14/0 and M1-M4 killed (sage-1971); Sage gate 12:10-12:21Z: file 14/0, cli 680/0, scripts/test-*.sh 429/0. M1/M2 at :248 not :237 (gosalyn-0111). |
|
||||
| 248 | row 235 follow-up: the grant-executor hold-add and hold-remove stubs stop when their test dir is gone (found by the 197 r8 proof) | sage; reviewers webby | #1750 | done | — | Webby approves the commit. On Sage's gate the whole grant-executor.test.mjs passes three times with no stub left, the bus suite is green, and a mutant that drops the dir check leaves a stub. (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b follow-up: the held stubs end with their test | LANDED 8a04f4aa4 on refactor, pushed. Temp-index landing of candidate 4d99c178b's one path onto bc579f7a8. No drift on the path since 540239b6b. Patch-id 84974d6b08d6 matches the candidate's. Gate per the brief: Webby APPROVE r1 rev 2883, Sage's gate 13:05-13:07Z (file 3x 37/0 no stub, bus 596/0, mutant left 2). |
|
||||
| 249 | row 235 follow-up: the grant undo records hostLineLeft when install never returned, since the remote add can outlive an abort or timeout (mos-1166) | gizmoduck; reviewers duckworth | #1751 | done | — | Duckworth approves the manifest. On Sage's gate the bus suite and the whole touched test files are green, and a mutant that drops the new catch branch is killed. (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b follow-up: the undo can't prove the remote add has ended | LANDED 9de5a57ab on refactor, pushed with 7b9737953 (DEFERRED N1). Range-diff: 8a04f4aa4..05595794b vs 8ca064158..9de5a57ab, same 3 paths, patch-id 99551b122e66 on both. Gate sage-2026 14:53Z: grant-executor whole 39/0, bus 598/0, no stub left, C1 (drop :1025) killed by G8 and both G23. Duckworth approve c30070. |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+8614
-114
File diff suppressed because one or more lines are too long
@@ -940,6 +940,7 @@ export class Broker {
|
||||
if (signal.aborted) fail('revoked');
|
||||
};
|
||||
let installing = false,
|
||||
installed = false,
|
||||
registered = false,
|
||||
committed = false;
|
||||
try {
|
||||
@@ -952,6 +953,7 @@ export class Broker {
|
||||
step('install');
|
||||
installing = true;
|
||||
await this.#hostGrant.install(business, g.host, record, tag, key.publicLine, signal);
|
||||
installed = true;
|
||||
stop();
|
||||
await this.#hostGrant.verify(g.host, record, key, signal);
|
||||
stop();
|
||||
@@ -1018,6 +1020,9 @@ export class Broker {
|
||||
} catch {
|
||||
extra = { hostLineLeft: tag };
|
||||
}
|
||||
// An abort or a timeout kills only the local ssh, so an add that hasn't returned may still write
|
||||
// its line after the remove has run. Only install returning proves it ended (row 249).
|
||||
if (!installed) extra = { hostLineLeft: tag };
|
||||
}
|
||||
try {
|
||||
this.#store.transaction(() => this.#hostGrantFailed(business, decision, g, code, extra));
|
||||
|
||||
@@ -51,6 +51,7 @@ async function until(fn, ms = 10000) {
|
||||
|
||||
// The stubs read flag files in the test dir: fail-<verb> exits 1, noop-add exits 0 and writes nothing,
|
||||
// hold-add waits for `release` (then exits 1 if `skip` is there), and hold-remove waits for `release`.
|
||||
// Both also stop when the test dir is gone, since a held stub on the sshd side outlives its test (#1750).
|
||||
// wrap.sh logs every request. The sudo stub logs its arguments to sudo.log and runs the command as the
|
||||
// test's user.
|
||||
async function sshd(t, dir) {
|
||||
@@ -63,12 +64,12 @@ async function sshd(t, dir) {
|
||||
stub('list', '[ -e "$D/fail-list" ] && exit 1\n[ -e "$D/granted" ] && grep " mosaic-[0-9a-f]*$" "$D/granted"\nexit 0');
|
||||
stub(
|
||||
'add',
|
||||
'if [ -e "$D/hold-add" ]; then : > "$D/holding"; while [ ! -e "$D/release" ]; do sleep 0.05; done; [ -e "$D/skip" ] && exit 1; fi\n' +
|
||||
'if [ -e "$D/hold-add" ]; then : > "$D/holding"; while [ ! -e "$D/release" ] && [ -d "$D" ]; do sleep 0.05; done; [ -e "$D/skip" ] && exit 1; fi\n' +
|
||||
'[ -e "$D/fail-add" ] && exit 1\n[ -e "$D/noop-add" ] && exit 0\n' +
|
||||
'grep -q " $4\\$" "$D/granted" 2>/dev/null && exit 0\necho "restrict $2 $3 $4" >> "$D/granted"',
|
||||
);
|
||||
stub('sudo', 'printf "%s\\n" "$*" >> "$D/sudo.log"\nshift 2\nexec "$@"');
|
||||
stub('remove', 'if [ -e "$D/hold-remove" ]; then : > "$D/holding-remove"; while [ ! -e "$D/release" ]; do sleep 0.05; done; fi\n' +
|
||||
stub('remove', 'if [ -e "$D/hold-remove" ]; then : > "$D/holding-remove"; while [ ! -e "$D/release" ] && [ -d "$D" ]; do sleep 0.05; done; fi\n' +
|
||||
'[ -e "$D/fail-remove" ] && exit 1\n[ -e "$D/granted" ] || exit 0\ngrep -v " $2\\$" "$D/granted" > "$D/granted.tmp"\nmv "$D/granted.tmp" "$D/granted"');
|
||||
writeFileSync(
|
||||
join(dir, 'wrap.sh'),
|
||||
@@ -286,14 +287,17 @@ test('G8: a failure at install or verify removes the line and leaves no record,
|
||||
const { id, end } = await f.approve();
|
||||
assert.equal(end.code, 'host-failed', flag);
|
||||
assert.equal(end.tag, f.tag);
|
||||
assert.equal('hostLineLeft' in end, false);
|
||||
// An install that didn't return keeps the sweep hint, since its add may still be running (row 249).
|
||||
// Here the hint is false, and a sweep drops it (G24). noop-add fails at verify, after install returned.
|
||||
const returned = flag === 'noop-add';
|
||||
assert.equal(end.hostLineLeft, returned ? undefined : f.tag, flag);
|
||||
assert.equal(f.requests().at(-1), `remove ${USER} ${f.tag}`, flag);
|
||||
assert.deepEqual(f.granted(), []);
|
||||
assert.equal(f.record(), undefined);
|
||||
assert.equal(existsSync(f.keyFile), false);
|
||||
assert.equal(f.state(), undefined);
|
||||
assert.deepEqual(f.grantEvents().filter((e) => e.grantDecision === id).map((e) => e.action), ['grant.step', 'grant.failed']);
|
||||
assert.match(f.messages(id)[0].body, /failed: host-failed\.$/);
|
||||
assert.match(f.messages(id)[0].body, returned ? /failed: host-failed\.$/ : /Run: mosaic hosts sweep web1$/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -429,7 +433,8 @@ test('G23: a revoke during install ends grant.failed revoked with no key file, n
|
||||
await until(() => existsSync(join(f.dir, 'holding')));
|
||||
assert.deepEqual(f.credentials.abortClaim('demo/ops', 'host.web1'), { pending: true });
|
||||
await until(() => f.ended(d.id));
|
||||
// The held add then exits without writing, as a killed session's would.
|
||||
// `skip` makes the held add exit without writing. A killed session's add can still write; the row 249
|
||||
// tests below cover that.
|
||||
f.flag('skip');
|
||||
f.flag('release');
|
||||
assert.equal(f.ended(d.id).code, 'revoked');
|
||||
@@ -441,6 +446,57 @@ test('G23: a revoke during install ends grant.failed revoked with no key file, n
|
||||
assert.equal(f.credentials.busy('demo/ops', 'host.web1'), false);
|
||||
});
|
||||
|
||||
// Row 249. An abort and a timeout kill only the local ssh, and sshd sends a non-pty command no SIGHUP,
|
||||
// so the held add goes on. Released after the undo's remove, it writes its line and exits 0.
|
||||
const lateAdd = async (f, id, code) => {
|
||||
await until(() => f.ended(id));
|
||||
const end = f.ended(id);
|
||||
assert.equal(end.code, code);
|
||||
assert.equal(end.hostLineLeft, f.tag);
|
||||
assert.match(f.messages(id)[0].body, new RegExp(`tagged ${f.tag} may be left on the host\\. Run: mosaic hosts sweep web1$`));
|
||||
assert.equal(f.requests().at(-1), `remove ${USER} ${f.tag}`);
|
||||
f.flag('release');
|
||||
await until(() => f.granted().some((l) => l.endsWith(' ' + f.tag)));
|
||||
assert.equal(f.record(), undefined);
|
||||
assert.deepEqual((await f.call('human', 'hosts.status'))[0].hostLineLeft, [f.tag]);
|
||||
await refusesWith(f.call('human', 'hosts.remove', { host: 'web1' }), 'host-line-left');
|
||||
assert.deepEqual(await f.call('human', 'hosts.sweep', { host: 'web1' }), { host: 'web1', removed: [f.tag], hostLineLeft: [] });
|
||||
assert.deepEqual(f.granted(), []);
|
||||
assert.deepEqual(await f.call('human', 'hosts.remove', { host: 'web1' }), { host: 'web1', removed: true });
|
||||
};
|
||||
|
||||
test('G23 (row 249): an add that writes after an abort\'s remove leaves hostLineLeft until the sweep', async (t) => {
|
||||
const f = await setup(t);
|
||||
f.flag('hold-add');
|
||||
const d = await f.raise();
|
||||
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
await until(() => existsSync(join(f.dir, 'holding')));
|
||||
assert.deepEqual(f.credentials.abortClaim('demo/ops', 'host.web1'), { pending: true });
|
||||
await lateAdd(f, d.id, 'revoked');
|
||||
});
|
||||
|
||||
// The timeout's kill without its 30 s wait: install gets its own signal, which SIGKILLs the local ssh
|
||||
// group as host-run.mjs's timer does, and the call then fails as a timed-out request does.
|
||||
test('G23 (row 249): an add that writes after a timed-out install\'s remove leaves hostLineLeft until the sweep', async (t) => {
|
||||
const f = await setup(t, {
|
||||
wrap: (hg) => ({
|
||||
...hg,
|
||||
install: async (business, host, record, tag, line) => {
|
||||
const timer = new AbortController();
|
||||
const out = hg.install(business, host, record, tag, line, timer.signal);
|
||||
await until(() => existsSync(join(f.dir, 'holding')));
|
||||
timer.abort();
|
||||
await out.catch(() => {});
|
||||
throw new Error('timed out');
|
||||
},
|
||||
}),
|
||||
});
|
||||
f.flag('hold-add');
|
||||
const d = await f.raise();
|
||||
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
await lateAdd(f, d.id, 'host-failed');
|
||||
});
|
||||
|
||||
test('G23: a revoke recorded after the raise and before the start ends step 1 revoked, and a held slot ends host-busy', async (t) => {
|
||||
const f = await setup(t);
|
||||
const revoked = f.fixtureGrant();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fork } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync, readFileSync, chmodSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { once } from 'node:events';
|
||||
@@ -74,6 +74,8 @@ test('startup token refusal returns safe code without value or partial listening
|
||||
const secret = 'fixture-token-never-in-db-948723';
|
||||
const file = join(root, 'secret');
|
||||
writeFileSync(file, secret, { mode: 0o644 });
|
||||
// writeFileSync applies the umask; chmod sets the too-open mode under any umask (row 243).
|
||||
chmodSync(file, 0o644);
|
||||
const config = structuredClone(businesses);
|
||||
config.demo.roles.cto.credentials = { gitea: { file, rotateBy: '2099-01-01' } };
|
||||
const { child, logs } = launch(t);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { tmp } from "./helpers.mjs";
|
||||
@@ -77,10 +77,13 @@ args=(); for a in "$@"; do if [[ "$a" == /* ]] && [ -e "$ROOT_DIR$a" ]; then arg
|
||||
HOME="$ROOT_DIR/srv/mosaic" PATH="$np$PATH" exec "$ROOT_DIR$cmd" "\${args[@]}"`,
|
||||
};
|
||||
|
||||
// Executables inside the tree, run by the fake sudo or by the script itself.
|
||||
// Executables inside the tree, run by the fake sudo or by the script itself. Node marks a run outside
|
||||
// the fake sudo, which sets HOME to the stack's home, so a test can tell whether root ran it.
|
||||
const NODE_STUB = `[ "$HOME" = "$ROOT_DIR/srv/mosaic" ] || touch "$HC/node-as-root"
|
||||
[ "\${1:-}" = --version ] && { cat "$HC/node-version"; exit 0; }
|
||||
exec "$REAL_NODE" "$@"`;
|
||||
const TREE = {
|
||||
[NODE]: `[ "\${1:-}" = --version ] && { cat "$HC/node-version"; exit 0; }
|
||||
exec "$REAL_NODE" "$@"`,
|
||||
[NODE]: NODE_STUB,
|
||||
[CLAUDE]: `[ "\${1:-}" = --version ] && cat "$HC/claude-version"; exit 0`,
|
||||
[CLI]: `${log("mosaic")}
|
||||
case "$1" in
|
||||
@@ -151,6 +154,9 @@ function host(t) {
|
||||
for (const [name, text] of Object.entries(state)) put(join(hc, name), text);
|
||||
for (const [name, body] of Object.entries(FAKES)) put(join(bin, name), `#!/usr/bin/env bash\n${name === "stat" ? "" : log(name)}\n${body}\n`, 0o755);
|
||||
for (const [path, body] of Object.entries(TREE)) put(join(root, path), `#!/usr/bin/env bash\n${body}\n`, 0o755);
|
||||
// F9 runs node only when it and each directory above it have no group or other write, whatever the umask.
|
||||
for (let d = dirname(NODE); d !== "/"; d = dirname(d)) chmodSync(join(root, d), 0o755);
|
||||
chmodSync(root, 0o755);
|
||||
return { dir, root, hc, bin, args };
|
||||
}
|
||||
|
||||
@@ -204,6 +210,7 @@ test("row 195 accept: every check passing exits 0, with each P mark and counts t
|
||||
assert.match(r.out, /^F5 bwrap-label pass bwrap 300 reads 'bwrap \(enforce\)' and its claude descendant 302 reads/m);
|
||||
assert.match(r.out, /^F7 pm-live pass one live launch: pm run r-0002$/m);
|
||||
assert.ok(!(r.out + r.err).includes(CRED_VALUE));
|
||||
assert.ok(existsSync(join(h.hc, "node-as-root")), "F9 runs the pinned, root-only node as root");
|
||||
});
|
||||
|
||||
test("row 195 accept: exits 1 with each F check failing alone, and its line names it", (t) => {
|
||||
@@ -274,7 +281,7 @@ test("row 195 accept: F5 fails with no bwrap of mosaic's, and passes on comm alo
|
||||
assert.deepEqual(failed(mustRun(h)), []);
|
||||
});
|
||||
|
||||
test("row 195 accept: F6 fails on a stub that prints nothing and on a path outside claude-<pin>", (t) => {
|
||||
test("row 195 accept: F6 fails on a stub that prints nothing, on a path outside claude-<pin> and on a .. segment", (t) => {
|
||||
let h = host(t);
|
||||
put(join(h.hc, "claude-version"), "");
|
||||
let r = mustRun(h);
|
||||
@@ -286,6 +293,14 @@ test("row 195 accept: F6 fails on a stub that prints nothing and on a path outsi
|
||||
r = mustRun(h);
|
||||
assert.deepEqual(failed(r), ["F6"]);
|
||||
assert.match(r.out, /^F6 claude-pin FAIL --claude \S+claude-2\.0\.9\S+ is not under \/opt\/mosaic-stack\/claude-2\.1\.0\/$/m);
|
||||
// Under claude-<pin>/ by prefix, but the binary it names is 2.0.9's, which answers with the pin.
|
||||
h = host(t);
|
||||
const dotdot = CLAUDE.replace(`claude-${CLAUDE_PIN}/`, `claude-${CLAUDE_PIN}/../claude-2.0.9/`);
|
||||
put(h.args, ARGS.map((a) => (a === CLAUDE ? dotdot : a)).join("\n") + "\n", 0o644);
|
||||
put(join(h.root, other), `#!/usr/bin/env bash\necho "${CLAUDE_PIN} (Claude Code)"\n`, 0o755);
|
||||
r = mustRun(h);
|
||||
assert.deepEqual(failed(r), ["F6"], r.out);
|
||||
assert.match(r.out, /^F6 claude-pin FAIL --claude \S+\/claude-2\.1\.0\/\.\.\/claude-2\.0\.9\/\S+ has a \.\. segment$/m);
|
||||
});
|
||||
|
||||
test("row 195 accept: F7 fails on two live PM launches, one for another business, and no launch.by", (t) => {
|
||||
@@ -328,6 +343,31 @@ test("row 195 accept: F9 fails on another owner, a group-writable mode and a num
|
||||
}
|
||||
});
|
||||
|
||||
test("row 195 accept: F9 won't run --node as root off the pin path or with a writable directory above it", (t) => {
|
||||
// Another node that works for the stack's user: F9 refuses to run it, and F10 fails on the path.
|
||||
let h = host(t);
|
||||
const other = NODE.replace(NODE_PIN, "v24.0.9");
|
||||
put(h.args, ARGS.map((a) => (a === NODE ? other : a)).join("\n") + "\n", 0o644);
|
||||
put(join(h.root, other), `#!/usr/bin/env bash\n${NODE_STUB}\n`, 0o755);
|
||||
let r = mustRun(h);
|
||||
assert.deepEqual(failed(r), ["F9", "F10"], r.out);
|
||||
assert.match(r.out, /^F9 autoupdater-off FAIL root won't run --node \/opt\/mosaic-stack\/node-v24\.0\.9\/bin\/node: it is not the --node-pin path$/m);
|
||||
assert.ok(!existsSync(join(h.hc, "node-as-root")));
|
||||
// The pinned path, under a group-writable directory and then a symlinked node: F9 alone fails, and
|
||||
// root doesn't run it.
|
||||
for (const [fault, bad] of [
|
||||
[(x) => chmodSync(join(x.root, dirname(NODE)), 0o775), "/opt/mosaic-stack/node-v24\\.1\\.0/bin \\(uid \\d+, mode 775\\)"],
|
||||
[(x) => { const f = join(x.root, NODE); renameSync(f, `${f}.real`); symlinkSync(`${f}.real`, f); }, "/opt/mosaic-stack/node-v24\\.1\\.0/bin/node \\(uid \\d+, mode 777\\)"],
|
||||
]) {
|
||||
h = host(t);
|
||||
fault(h);
|
||||
r = mustRun(h);
|
||||
assert.deepEqual(failed(r), ["F9"], r.out);
|
||||
assert.match(r.out, new RegExp(`^F9 autoupdater-off FAIL root won't run --node \\S+: ${bad} is not root's alone$`, "m"));
|
||||
assert.ok(!existsSync(join(h.hc, "node-as-root")));
|
||||
}
|
||||
});
|
||||
|
||||
test("row 195 accept: F11 fails on one *.json under discord/ and passes on an empty directory", (t) => {
|
||||
let h = host(t);
|
||||
put(join(h.root, HOME, ".mosaic-dev", "discord", "chief.json"), "{}");
|
||||
|
||||
@@ -18,6 +18,7 @@ const REPO = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
const SCRIPT = join(REPO, "scripts", "case2-persona-import.mjs");
|
||||
const FIXTURE = join(REPO, "packages", "cli", "tests", "fixtures", "case2-persona-import");
|
||||
const TRACE = join(FIXTURE, "trace.mjs");
|
||||
const MKDIR_RACE = join(FIXTURE, "mkdir-race.mjs");
|
||||
const PAIRS = ["--pair", "default=Bean", "--pair", "chet=Chet"];
|
||||
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
||||
const mode = (path) => statSync(path).mode & 0o777;
|
||||
@@ -36,15 +37,15 @@ function scratch(t) {
|
||||
return { root, hermes, configDir, staging: join(root, "staging"), trace: join(root, "trace.log") };
|
||||
}
|
||||
|
||||
function run(argv, { trace } = {}) {
|
||||
const pre = trace ? ["--import", TRACE] : [];
|
||||
const env = { ...process.env, ...(trace ? { PERSONA_TRACE: trace } : {}) };
|
||||
function run(argv, { trace, race } = {}) {
|
||||
const pre = [...(trace ? ["--import", TRACE] : []), ...(race ? ["--import", MKDIR_RACE] : [])];
|
||||
const env = { ...process.env, ...(trace ? { PERSONA_TRACE: trace } : {}), ...(race ? { PERSONA_MKDIR_RACE: race } : {}) };
|
||||
const r = spawnSync(process.execPath, [...pre, SCRIPT, ...argv], { encoding: "utf8", env, timeout: 30_000 });
|
||||
return { status: r.status, out: r.stdout, err: r.stderr, all: r.stdout + r.stderr };
|
||||
}
|
||||
|
||||
const stage = (s, extra = [], opts) => run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--staging", s.staging, ...extra], opts);
|
||||
const install = (s, business = "case2") => run(["install", "--staging", s.staging, "--config-dir", s.configDir, "--business", business]);
|
||||
const install = (s, business = "case2", opts) => run(["install", "--staging", s.staging, "--config-dir", s.configDir, "--business", business], opts);
|
||||
|
||||
// Every fixture marker and every line of fixture text the script reads.
|
||||
function assertNoText(s, output) {
|
||||
@@ -102,18 +103,38 @@ test("stage lists the per-channel prompts in all three places by name and byte c
|
||||
assert.equal(r.status, 0, r.all);
|
||||
const lines = r.out.split("\n").filter((l) => l.includes(": per-channel prompt "));
|
||||
assert.deepEqual(lines, [
|
||||
`profile default: per-channel prompt discord 111111111111111111, ${Buffer.byteLength("marker-prompt-top-5d02 Help with the novel.")} bytes`,
|
||||
`profile default: per-channel prompt discord 222222222222222222, ${Buffer.byteLength("Ünïcode prompt")} bytes`,
|
||||
`profile default: per-channel prompt "discord" "111111111111111111", ${Buffer.byteLength("marker-prompt-top-5d02 Help with the novel.")} bytes`,
|
||||
`profile default: per-channel prompt "discord" "222222222222222222", ${Buffer.byteLength("Ünïcode prompt")} bytes`,
|
||||
// Unquoted in the fixture, and past 2 ** 53: the id prints exactly.
|
||||
"profile default: per-channel prompt discord 1333333333333333333, 5 bytes",
|
||||
`profile default: per-channel prompt platforms.slack C0FIXTURE, ${Buffer.byteLength("marker-prompt-platforms-8e3a")} bytes`,
|
||||
"profile default: per-channel prompt gateway.platforms.telegram -1001, 29 bytes",
|
||||
"profile default: per-channel prompt gateway.platforms.telegram -1002, not text",
|
||||
'profile default: per-channel prompt "discord" "1333333333333333333", 5 bytes',
|
||||
`profile default: per-channel prompt "platforms.slack" "C0FIXTURE", ${Buffer.byteLength("marker-prompt-platforms-8e3a")} bytes`,
|
||||
'profile default: per-channel prompt "gateway.platforms.telegram" "-1001", 29 bytes',
|
||||
'profile default: per-channel prompt "gateway.platforms.telegram" "-1002", not text',
|
||||
]);
|
||||
assert.ok(r.out.includes("profile chet: no per-channel prompts"), r.out);
|
||||
assertNoText(s, r.all);
|
||||
});
|
||||
|
||||
// C2-20b N1 (webby-0073): a platform or channel key holding a newline prints as one quoted line, so
|
||||
// it can't forge a line of the script's own output.
|
||||
test("a config key with a newline prints as one quoted line", (t) => {
|
||||
const s = scratch(t);
|
||||
writeFileSync(join(s.hermes, "profiles", "chet", "config.yaml"), [
|
||||
'"discord\\nstaged: /forged/bean.md":',
|
||||
" channel_prompts:",
|
||||
' "1\\npairing: profile chet is Bean": marker-prompt-forge-4c1e',
|
||||
"",
|
||||
].join("\n"));
|
||||
const r = stage(s);
|
||||
assert.equal(r.status, 0, r.all);
|
||||
const lines = r.out.split("\n");
|
||||
assert.deepEqual(lines.filter((l) => l.startsWith("profile chet: per-channel prompt ")), [
|
||||
`profile chet: per-channel prompt "discord\\nstaged: /forged/bean.md" "1\\npairing: profile chet is Bean", ${Buffer.byteLength("marker-prompt-forge-4c1e")} bytes`,
|
||||
]);
|
||||
assert.ok(!lines.some((l) => l.startsWith("staged: /forged") || l.startsWith("pairing: profile chet is Bean")), r.out);
|
||||
assertNoText(s, r.all);
|
||||
});
|
||||
|
||||
test("a read trace shows stage and the dry run open SOUL.md and config.yaml and never memory, sessions or secrets", (t) => {
|
||||
const s = scratch(t);
|
||||
for (const argv of [["--staging", s.staging], ["--dry-run"]]) {
|
||||
@@ -288,23 +309,66 @@ test("install refuses an open or foreign staging file, a digest off the manifest
|
||||
assert.match(r.err, /the manifest doesn't list two personas/);
|
||||
});
|
||||
|
||||
test("--dry-run writes nothing and prints only counts, two heading lines in a fixture counted", (t) => {
|
||||
test("--dry-run writes nothing and prints only counts and the fence fault, two heading lines in a fixture counted", (t) => {
|
||||
const s = scratch(t);
|
||||
personaTree(s, { chet: "You are Chet. marker-chet-dry-7d21\n# Voice\nShort.\r\n## Habits\nNot # a heading, nor #nospace.\n" });
|
||||
const before = readdirSync(s.root).sort();
|
||||
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]);
|
||||
assert.equal(r.status, 0, r.all);
|
||||
assert.equal(r.status, 1, r.all);
|
||||
assert.deepEqual(readdirSync(s.root).sort(), before);
|
||||
assert.deepEqual(r.out.split("\n").filter(Boolean), [
|
||||
"files 2",
|
||||
'profile default: lines 3, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0',
|
||||
'profile chet: lines 5, lines starting "# " or "## " 2, lines naming a Hermes tool or channel 0',
|
||||
'profile chet: persona holds </persona-file> or a line starting "# " or "## ", so C2-08\'s fence would refuse it',
|
||||
]);
|
||||
assert.equal(r.err, "");
|
||||
assert.equal(r.err, "case2-persona-import: refused: C2-08's fence would refuse the persona of profile chet, so stage would write nothing\n");
|
||||
assertNoText(s, r.all);
|
||||
assert.ok(!r.all.includes("Voice") && !r.all.includes("Habits"));
|
||||
});
|
||||
|
||||
// C2-20b N2 (webby-0073): a persona over the fence and under the byte cap used to pass the dry run
|
||||
// and then refuse at stage. The dry run names the fault and refuses too, and passes at the limit.
|
||||
test("--dry-run applies C2-08's fence: over it the fault prints, nothing is written and it exits 1", (t) => {
|
||||
const atLimit = "é".repeat(PERSONA_CHARS);
|
||||
const s = scratch(t);
|
||||
personaTree(s, { bean: atLimit + "é" });
|
||||
const before = readdirSync(s.root).sort();
|
||||
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]);
|
||||
assert.equal(r.status, 1, r.all);
|
||||
assert.deepEqual(readdirSync(s.root).sort(), before);
|
||||
assert.deepEqual(r.out.split("\n").filter(Boolean), [
|
||||
"files 2",
|
||||
'profile default: lines 1, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0',
|
||||
"profile default: persona is over 8000 characters, so C2-08's fence would refuse it",
|
||||
'profile chet: lines 2, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0',
|
||||
]);
|
||||
assert.match(r.err, /refused: C2-08's fence would refuse the persona of profile default, so stage would write nothing/);
|
||||
assert.ok(!r.all.includes("éé"));
|
||||
personaTree(s, { bean: atLimit });
|
||||
const ok = run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]);
|
||||
assert.equal(ok.status, 0, ok.all);
|
||||
assert.equal(ok.err, "");
|
||||
});
|
||||
|
||||
// C2-20b N4 (webby-0073): a directory made between privateDir's lstat and its mkdir refuses with a
|
||||
// named reason, not a raw EEXIST. mkdir-race.mjs makes the directory just before the script's mkdir.
|
||||
test("a directory made between the check and mkdir refuses by name, at stage and at install", (t) => {
|
||||
const s = scratch(t);
|
||||
let r = stage(s, [], { race: s.staging });
|
||||
assert.equal(r.status, 1, r.all);
|
||||
assert.equal(r.err, `case2-persona-import: refused: ${s.staging} was made by something else between the check and mkdir\n`);
|
||||
assert.deepEqual(readdirSync(s.staging), []);
|
||||
|
||||
const s2 = scratch(t);
|
||||
assert.equal(stage(s2).status, 0);
|
||||
const dir = join(s2.configDir, "personas", "case2");
|
||||
r = install(s2, "case2", { race: dir });
|
||||
assert.equal(r.status, 1, r.all);
|
||||
assert.equal(r.err, `case2-persona-import: refused: ${dir} was made by something else between the check and mkdir\n`);
|
||||
assert.deepEqual(readdirSync(dir), []);
|
||||
});
|
||||
|
||||
test("--dry-run counts three lines naming a Hermes tool or channel and prints none of the words", (t) => {
|
||||
const s = scratch(t);
|
||||
personaTree(s, {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// mkdir race for case2-persona-import.test.mjs: loaded with --import, it makes the directory named by
|
||||
// $PERSONA_MKDIR_RACE just before the script's own mkdirSync of that path, as if another process won
|
||||
// the race after the script's lstat. The script's mkdir then meets a real EEXIST.
|
||||
import fs from "node:fs";
|
||||
import { syncBuiltinESMExports } from "node:module";
|
||||
|
||||
const { mkdirSync } = fs;
|
||||
const target = process.env.PERSONA_MKDIR_RACE;
|
||||
fs.mkdirSync = function raced(p, ...rest) {
|
||||
if (String(p) === target) mkdirSync(p, { mode: 0o700 });
|
||||
return mkdirSync.call(this, p, ...rest);
|
||||
};
|
||||
syncBuiltinESMExports();
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
// Controlled child for lane crash tests; no live service, network or model.
|
||||
import { writeFileSync } from 'node:fs';
|
||||
const [mode,marker]=process.argv.slice(2);
|
||||
if(marker&&mode!=='lock-probe')writeFileSync(marker,String(process.pid));
|
||||
if(mode!=='lock-probe')process.stdout.write('READY\n');
|
||||
if(mode==='ignore-term')process.on('SIGTERM',()=>{});
|
||||
else process.on('SIGTERM',()=>process.exit(0));
|
||||
setInterval(()=>{},1000);
|
||||
if(mode==='lock-probe'){
|
||||
const stateRoot=marker;
|
||||
const {verifyLocks,executeSteps}=await import('../../../../scripts/testing/gate-lane.mjs');
|
||||
verifyLocks({lane:'a',stateRoot,exclusive:false});
|
||||
const {appendRecord,identity}=await import('../../../../scripts/testing/lane-state.mjs');
|
||||
const {mkdirSync}=await import('node:fs');const {join}=await import('node:path');
|
||||
const out=join(stateRoot,'out');mkdirSync(out);
|
||||
appendRecord(join(stateRoot,'runs','abcdef123456'),{kind:'start',lane:'a',owner:identity()});
|
||||
const result=await executeSteps([{name:'fds',cwd:stateRoot,argv:[process.execPath,'-e',"const fs=require('fs'); for(const fd of fs.readdirSync('/proc/self/fd')){let p;try{p=fs.readlinkSync('/proc/self/fd/'+fd)}catch{continue}if(p.endsWith('/a.lock')||p.endsWith('/image.lock'))process.exit(7)}"]}],{out,env:process.env,record:()=>{}});
|
||||
if(result.rc)process.exit(result.rc);
|
||||
writeFileSync(join(stateRoot,'ready'),'verified');
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';
|
||||
import { mkdtempSync,rmSync,mkdirSync,writeFileSync } from 'node:fs';import { join } from 'node:path';import { createServer } from 'node:net';import { once } from 'node:events';
|
||||
const api=await import('../../../scripts/testing/lane-fixtures.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
|
||||
const evidence=await import('../../../scripts/testing/lane-evidence.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
|
||||
test('parallel lease holders use only the active lane and its shared state',async t=>{
|
||||
const ctx=leaseContext(t),lane=ctx.lane;
|
||||
const [a,b]=await Promise.all([api.leasePort(ctx.fixture,ctx),api.leasePort(ctx.fixture,ctx)]);
|
||||
const bands=BANDS,[lo,hi]=bands[lane];
|
||||
assert.ok(a>=lo&&a<=hi);assert.ok(b>=lo&&b<=hi);assert.notEqual(a,b);
|
||||
assert.ok(bands.a[1]<bands.b[0]);assert.equal(bands.a[1]-bands.a[0],127);assert.equal(bands.b[1]-bands.b[0],127);
|
||||
});
|
||||
test('unrelated home changes do not become lane writes, and own-root references do',()=>{
|
||||
assert.equal(typeof evidence.attributeEntries,'function','attribution is required');
|
||||
const result=evidence.attributeEntries([{path:'/home/person/.claude/projects/other-session',link:null},{path:'/home/person/.cache/lane-abcdef123456',link:null},{path:'/home/person/.pi/item',link:'/mnt/storage/scratch/gt-ABCDEF/work'}],{nonce:'abcdef123456',tempRoot:'/mnt/storage/scratch/gt-ABCDEF',fixturePaths:[]});
|
||||
assert.equal(result.owned.length,2);assert.equal(result.unrelated.length,1);
|
||||
});
|
||||
test('proof summaries refuse missing timings, non-overlap and a pair without a lane swap',()=>{
|
||||
const solos=[{candidate:'one',wallMs:100},{candidate:'two',wallMs:100}];
|
||||
const make=(lane,candidate,startMs=0,endMs=120)=>({lane,candidate,startMs,endMs,rc:0,clean:true});
|
||||
const pairs=[[make('a','one'),make('b','two',5,125)],[make('b','one'),make('a','two',5,125)],[make('a','one'),make('b','two',5,125)]];
|
||||
const result=evidence.summarizePairs(solos,pairs);assert.equal(result.length,3);assert.equal(result[0].makespanMs,125);assert.equal(result[0].runs[0].slowdown,1.2);assert.equal(result[0].throughput,1.6);
|
||||
const bad=structuredClone(pairs);delete bad[0][0].endMs;assert.throws(()=>evidence.summarizePairs(solos,bad),/timing/);
|
||||
const noSwap=structuredClone(pairs);noSwap[1]=structuredClone(pairs[0]);assert.throws(()=>evidence.summarizePairs(solos,noSwap),/swap/);
|
||||
const serial=structuredClone(pairs);serial[0][1].startMs=130;serial[0][1].endMs=250;assert.throws(()=>evidence.summarizePairs(solos,serial),/overlap/);
|
||||
});
|
||||
import { BANDS } from '../../../scripts/testing/lane-state.mjs';
|
||||
import { spawn,spawnSync } from 'node:child_process';
|
||||
import { readFileSync,existsSync } from 'node:fs';
|
||||
function leaseContext(t){
|
||||
const lane=process.env.MOSAIC_GATE_LANE??'a',stateRoot=process.env.MOSAIC_GATE_STATE??'/mnt/storage/scratch/mosaic-gate-lanes',callbacks=[];
|
||||
mkdirSync(stateRoot,{recursive:true,mode:0o700});
|
||||
t.after(async()=>{for(const f of callbacks.reverse())await f();});
|
||||
return {lane,stateRoot,fixture:{after:f=>callbacks.push(f)},cleanup:f=>callbacks.push(f)};
|
||||
}
|
||||
const delay=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
async function waitFor(check){for(let i=0;i<100;i++){if(check())return;await delay(20);}throw Error('fixture not ready');}
|
||||
test('an exhausted single-port band refuses, and an occupied foreign listener survives the probe',async t=>{
|
||||
const ctx=leaseContext(t),callbacks=[];
|
||||
const port=await api.leasePort({after:f=>callbacks.push(f)},ctx),original=BANDS[ctx.lane];
|
||||
const server=createServer();
|
||||
try{
|
||||
BANDS[ctx.lane]=[port,port];
|
||||
await assert.rejects(api.leasePort(t,ctx),/exhausted/);
|
||||
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(port,'127.0.0.1',resolve);});
|
||||
// Release only our lock. The sentinel listener remains foreign to the next lease.
|
||||
await callbacks.pop()();
|
||||
await assert.rejects(api.leasePort(t,ctx),/exhausted/);assert.equal(server.listening,true);
|
||||
}finally{BANDS[ctx.lane]=original;if(server.listening)await new Promise(r=>server.close(r));for(const f of callbacks)await f();}
|
||||
});
|
||||
test('parallel child allocators share real lane locks; a lease remains held through child shutdown',async t=>{
|
||||
const ctx=leaseContext(t),root=mkdtempSync('/mnt/storage/scratch/gf-');
|
||||
const modulePath=new URL('../../../scripts/testing/lane-fixtures.mjs',import.meta.url).href;
|
||||
const code=`import test from 'node:test';import fs from 'node:fs';const {leasePort}=await import(${JSON.stringify(modulePath)});test('lease held until fixture child finishes',async t=>{const port=await leasePort(t,JSON.parse(process.env.LEASE_CONTEXT));fs.writeFileSync(process.env.LEASE_PORT_FILE,String(port));await new Promise(r=>{const timer=setInterval(()=>{if(fs.existsSync(process.env.LEASE_STOP_FILE)){clearInterval(timer);r()}},5)});});`;
|
||||
const start=number=>{const file=join(root,`lease-${number}.test.mjs`),portFile=join(root,`port-${number}`),stopFile=join(root,`stop-${number}`);writeFileSync(file,code);const env={...process.env,LEASE_CONTEXT:JSON.stringify(ctx),LEASE_PORT_FILE:portFile,LEASE_STOP_FILE:stopFile};delete env.NODE_TEST_CONTEXT;const child=spawn(process.execPath,['--test','--test-concurrency=2','--test-reporter=tap',file],{env,stdio:['ignore','pipe','pipe']});child.output='';child.stderr.on('data',b=>child.output+=b);child.stdout.on('data',b=>child.output+=b);child.done=new Promise(r=>child.once('exit',code=>{child.exitedCode=code;r(code);}));child.portFile=portFile;child.stopFile=stopFile;return child;};
|
||||
const children=[start(1),start(2)];ctx.cleanup(async()=>{try{for(const c of children)writeFileSync(c.stopFile,'stop');await Promise.all(children.map(c=>c.done));}finally{rmSync(root,{recursive:true,force:true});}});
|
||||
await waitFor(()=>{for(const c of children)if(c.exitedCode!==undefined)throw Error(`allocator exited ${c.exitedCode}: ${c.output}`);return children.every(c=>existsSync(c.portFile));});
|
||||
const ports=children.map(c=>Number(readFileSync(c.portFile,'utf8')));assert.notEqual(...ports);
|
||||
const lock=join(ctx.stateRoot,'ports',ctx.lane,`${ports[0]}.lock`);
|
||||
assert.equal(spawnSync('flock',['-n',lock,'true']).status,1);
|
||||
writeFileSync(children[0].stopFile,'stop');assert.equal(await children[0].done,0,children[0].output);
|
||||
assert.equal(spawnSync('flock',['-n',lock,'true']).status,0);
|
||||
assert.equal(spawnSync('flock',['-n',join(ctx.stateRoot,'ports',ctx.lane,`${ports[1]}.lock`),'true']).status,1);
|
||||
});
|
||||
test('abrupt allocator death releases its holder without touching a foreign lease',async t=>{
|
||||
const ctx=leaseContext(t),foreign=await api.leasePort(ctx.fixture,ctx),modulePath=new URL('../../../scripts/testing/lane-fixtures.mjs',import.meta.url).href;
|
||||
const code=`const {leasePort}=await import(${JSON.stringify(modulePath)});const port=await leasePort({after:()=>{}},JSON.parse(process.argv[1]));console.log(port);setInterval(()=>{},1000);`;
|
||||
const child=spawn(process.execPath,['--input-type=module','-e',code,JSON.stringify(ctx)],{stdio:['ignore','pipe','pipe']});const done=new Promise(r=>child.once('exit',r));ctx.cleanup(async()=>{child.kill('SIGKILL');await done;});
|
||||
const port=await new Promise(r=>child.stdout.once('data',b=>r(Number(String(b).trim()))));assert.notEqual(port,foreign);
|
||||
child.kill('SIGKILL');await done;
|
||||
const lock=join(ctx.stateRoot,'ports',ctx.lane,`${port}.lock`);
|
||||
await waitFor(()=>spawnSync('flock',['-n',lock,'true']).status===0);
|
||||
assert.equal(spawnSync('flock',['-n',join(ctx.stateRoot,'ports',ctx.lane,`${foreign}.lock`),'true']).status,1);
|
||||
});
|
||||
test('fixture caches survive a replacement environment with a separate tmp subdirectory',async t=>{
|
||||
const root=mkdtempSync('/mnt/storage/scratch/gf-');t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
const env=api.fixtureCacheEnv(root);assert.equal(env.TMPDIR,join(root,'tmp'));assert.equal(env.JITI_FS_CACHE,join(root,'jiti'));assert.equal(env.XDG_CACHE_HOME,join(root,'cache'));
|
||||
const code="const fs=require('fs'),p=require('path');for(const k of ['TMPDIR','JITI_FS_CACHE','XDG_CACHE_HOME'])fs.writeFileSync(p.join(process.env[k],k),'fixture');if(process.env.HOME)process.exit(7)";
|
||||
const result=spawnSync(process.execPath,['-e',code],{env,encoding:'utf8'});assert.equal(result.status,0,result.stderr);
|
||||
for(const key of Object.keys(env))assert.equal(readFileSync(join(env[key],key),'utf8'),'fixture');
|
||||
});
|
||||
test('an unrelated session can append while a supervised step runs without becoming an attributed write',async t=>{
|
||||
const root=mkdtempSync('/mnt/storage/scratch/gf-');t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
const home=join(root,'home'),out=join(root,'out'),file=join(home,'.claude','other-session');mkdirSync(join(home,'.claude'),{recursive:true});mkdirSync(out);writeFileSync(file,'before');
|
||||
const before=evidence.homeMetadata(home),prior=new Map(before.map(e=>[e.path,JSON.stringify(e)]));
|
||||
const {executeSteps}=await import('../../../scripts/testing/gate-lane.mjs');
|
||||
const barrier=join(root,'step-running');
|
||||
const append=spawn(process.execPath,['-e',"const fs=require('fs');const deadline=setTimeout(()=>{console.error('append barrier timed out');process.exit(8)},5000);const timer=setInterval(()=>{if(fs.existsSync(process.argv[2])){clearInterval(timer);clearTimeout(deadline);fs.appendFileSync(process.argv[1],'unrelated append')}},5)",file,barrier],{stdio:'ignore'});const appended=new Promise(r=>append.once('exit',r));t.after(()=>append.kill('SIGKILL'));
|
||||
let appendedDuringStep=false;
|
||||
const stepCode="const fs=require('fs');fs.writeFileSync(process.argv[1],'running');const deadline=setTimeout(()=>{console.error('session append timed out');process.exit(8)},5000);const timer=setInterval(()=>{if(fs.readFileSync(process.argv[2],'utf8').includes('unrelated append')){clearInterval(timer);clearTimeout(deadline)}},5)";
|
||||
const result=await executeSteps([{name:'wait',cwd:root,argv:[process.execPath,'-e',stepCode,barrier,file]}],{out,env:process.env,record:r=>{if(r.kind==='step')appendedDuringStep=readFileSync(file,'utf8').includes('unrelated append');}});
|
||||
assert.equal(await appended,0);assert.equal(result.rc,0);assert.equal(appendedDuringStep,true);
|
||||
const changed=evidence.homeMetadata(home).filter(e=>prior.get(e.path)!==JSON.stringify(e));assert.ok(changed.some(e=>e.path===file));
|
||||
const attributed=evidence.attributeEntries(changed,{nonce:'abcdef123456',tempRoot:join(root,'temp'),fixturePaths:[]});assert.equal(attributed.owned.length,0);assert.ok(attributed.unrelated.length>0);
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';
|
||||
import { mkdtempSync,rmSync,writeFileSync,mkdirSync,cpSync,readFileSync,statSync,symlinkSync,unlinkSync } from 'node:fs';import { join, isAbsolute } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
const image=await import('../../../scripts/testing/lane-image.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
|
||||
const docker=await import('../../../scripts/testing/lane-docker.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
|
||||
test('a changed COPY input or actual image ID refuses compatible image sharing',t=>{
|
||||
assert.equal(typeof image.imageFingerprint,'function','image fingerprint required');
|
||||
const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
|
||||
writeFileSync(join(root,'RELEASE'),'0.0.1\n');
|
||||
for(const d of ['contracts','src','adapters']){mkdirSync(join(root,d));writeFileSync(join(root,d,'one'),'same');}
|
||||
const digest=image.imageFingerprint(root),id='sha256:'+'a'.repeat(64);const receipt={inputDigest:digest,imageId:id,tag:'mosaic-poc-agent:0.85.1-r0.0.1',baseDigest:'node@sha256:'+'b'.repeat(64),platform:'linux/amd64'};
|
||||
const inspect=()=>({Id:id,Os:'linux',Architecture:'amd64'});
|
||||
assert.equal(image.verifyImage(receipt,root,inspect).imageId,id);
|
||||
assert.throws(()=>image.verifyImage(receipt,root,()=>({...inspect(),Id:'sha256:'+'c'.repeat(64)})),/image ID/);
|
||||
writeFileSync(join(root,'adapters','one'),'different');assert.throws(()=>image.verifyImage(receipt,root,inspect),/inputs/);
|
||||
});
|
||||
test('warm Docker guard disables implicit build and pull and refuses image mutation',t=>{
|
||||
assert.equal(typeof docker.guardDocker,'function','Docker guard required');
|
||||
const run=composeFixture(t);
|
||||
assert.deepEqual(docker.guardDocker(['compose','run','--rm','-T','mosaic-agent','prompt'],run),['compose','-f',join(run.checkout,'compose.yaml'),'-f',join(run.tempRoot,'compose-no-build.yaml'),'run','--pull','never','--no-deps','--rm','-T','mosaic-agent','prompt']);
|
||||
for(const args of [['build','.'],['image','prune'],['compose','build'],['tag','a','b'],['network','rm','foreign']])assert.throws(()=>docker.guardDocker(args,run),/refused/);
|
||||
});
|
||||
test('exclusive image preparation records actual image ID and build wall time and requires a grant',t=>{
|
||||
assert.equal(typeof image.prepareImage,'function','prepareImage is required');
|
||||
const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
|
||||
writeFileSync(join(root,'RELEASE'),'0.0.1\n');for(const d of ['contracts','src','adapters'])mkdirSync(join(root,d));
|
||||
const calls=[];const invoke=(args)=>{calls.push(args);if(args[0]==='compose')return '';return JSON.stringify(args[2]==='node:24-bookworm-slim'?{RepoDigests:['node@sha256:'+'b'.repeat(64)]}:{Id:'sha256:'+'a'.repeat(64),Os:'linux',Architecture:'amd64'});};
|
||||
assert.throws(()=>image.prepareImage(root,{grant:'',tempRoot:root},invoke),/grant/);
|
||||
const result=image.prepareImage(root,{grant:'fixture',tempRoot:root},invoke);assert.equal(result.imageId,'sha256:'+'a'.repeat(64));assert.equal(result.builds,1);assert.ok(result.buildWallMs>=0);assert.ok(calls.some(x=>x.join(' ')==='compose build'));
|
||||
});
|
||||
test('cleanup refuses a foreign Docker label and removes only an exact owned project',()=>{
|
||||
const project='mg197-a-abcdef123456',id='123456789abc';let removed=false,label='foreign';
|
||||
const fake=(args)=>{if(args[0]==='ps')return removed?'':id;if(args[0]==='network')return '';if(args[0]==='container'&&args[1]==='inspect')return label;if(args[0]==='container'&&args[1]==='rm'){removed=true;return '';}throw Error('unexpected command');};
|
||||
// The injectable executor exercises ownership checks without touching Docker.
|
||||
assert.throws(()=>docker.cleanupProject({project},fake),/foreign/);assert.equal(removed,false);
|
||||
label=project;assert.deepEqual(docker.cleanupProject({project},fake).removed,[{kind:'container',id}]);assert.equal(removed,true);
|
||||
});
|
||||
function imageFixture(t){
|
||||
const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
|
||||
writeFileSync(join(root,'RELEASE'),'0.0.1\n');for(const d of ['contracts','src','adapters'])mkdirSync(join(root,d));
|
||||
const receipt={inputDigest:image.imageFingerprint(root),imageId:'sha256:'+'a'.repeat(64),tag:'mosaic-poc-agent:0.85.1-r0.0.1',baseDigest:'node@sha256:'+'b'.repeat(64),platform:'linux/amd64'};
|
||||
return {root,receipt};
|
||||
}
|
||||
test('missing receipt and missing image refuse without permitting a gate',t=>{
|
||||
const {root,receipt}=imageFixture(t);assert.throws(()=>image.verifyImage(null,root),/provenance receipt/);
|
||||
assert.throws(()=>image.verifyImage(receipt,root,()=>{throw Error('No such image');}),/No such image/);
|
||||
assert.throws(()=>image.verifyImage({...receipt,tag:'mosaic-poc-agent:wrong'},root,()=>({Id:receipt.imageId,Os:'linux',Architecture:'amd64'})),/release identity/);
|
||||
});
|
||||
test('a tag changed between supervised steps fails the next boundary and suppresses gate success',async t=>{
|
||||
const {root,receipt}=imageFixture(t),out=join(root,'out');mkdirSync(out);
|
||||
let actual=receipt.imageId;const inspect=()=>({Id:actual,Os:'linux',Architecture:'amd64'}),events=[];
|
||||
const {executePinnedSteps}=await import('../../../scripts/testing/gate-lane.mjs');
|
||||
await assert.rejects(executePinnedSteps([
|
||||
{name:'first',cwd:root,argv:[process.execPath,'-e','process.exit(0)']},
|
||||
{name:'second',cwd:root,argv:[process.execPath,'-e','process.exit(0)']}
|
||||
],{out,env:process.env,record:r=>events.push(r)}, {receipt,checkout:root,inspect,afterStep:()=>actual='sha256:'+'c'.repeat(64)}),/image ID/);
|
||||
assert.equal(events.filter(e=>e.kind==='step').length,1);
|
||||
assert.equal(events.some(e=>e.kind==='terminal'&&e.rc===0),false);
|
||||
});
|
||||
test('mutable-base rebuilds with identical files have distinct image IDs; cold and warm counters stay separate',t=>{
|
||||
const {root}=imageFixture(t);let builds=0,id='sha256:'+'a'.repeat(64);
|
||||
const fake=args=>{if(args[0]==='compose'){builds++;return 'fixture build';}return JSON.stringify(args[2]==='node:24-bookworm-slim'?{RepoDigests:['node@sha256:'+'b'.repeat(64)]}:{Id:id,Os:'linux',Architecture:'amd64'});};
|
||||
const cold=image.prepareImage(root,{grant:'fixture-cold',tempRoot:root},fake);
|
||||
assert.equal(cold.builds,1);assert.ok(cold.buildWallMs>=0);assert.equal(builds,1);
|
||||
for(let i=0;i<3;i++)image.verifyImage(cold,root,()=>({Id:id,Os:'linux',Architecture:'amd64'}));
|
||||
assert.equal(builds,1,'warm verification builds zero images');
|
||||
id='sha256:'+'c'.repeat(64);const rebuilt=image.prepareImage(root,{grant:'fixture-rebuild',tempRoot:root},fake);
|
||||
assert.equal(rebuilt.inputDigest,cold.inputDigest);assert.equal(rebuilt.builds,1);assert.equal(builds,2);
|
||||
assert.throws(()=>image.verifyImage(cold,root,()=>({Id:rebuilt.imageId,Os:'linux',Architecture:'amd64'})),/image ID/);
|
||||
});
|
||||
test('warm pinned execution records zero builds and checks the tag again after its final step',async t=>{
|
||||
const {root,receipt}=imageFixture(t),out=join(root,'out');mkdirSync(out);let id=receipt.imageId;
|
||||
const {executePinnedSteps}=await import('../../../scripts/testing/gate-lane.mjs');
|
||||
const options={out,env:process.env,record:()=>{}},inspect=()=>({Id:id,Os:'linux',Architecture:'amd64'});
|
||||
const result=await executePinnedSteps([{name:'warm',cwd:root,argv:[process.execPath,'-e','process.exit(0)']}],options,{receipt,checkout:root,inspect});
|
||||
assert.equal(result.rc,0);assert.equal(result.imageBuilds,0);
|
||||
await assert.rejects(executePinnedSteps([{name:'last',cwd:root,argv:[process.execPath,'-e','process.exit(0)']}],options,{receipt,checkout:root,inspect,afterStep:()=>id='sha256:'+'c'.repeat(64)}),/image ID/);
|
||||
});
|
||||
|
||||
|
||||
// These two read-only compatibility probes must reach the installed binary,
|
||||
// including when the lane's guard is first on PATH. Child fixture env stays
|
||||
// isolated; passing the real absolute path avoids re-entering that guard.
|
||||
function installedDocker(){
|
||||
const file=process.env.MOSAIC_GATE_DOCKER_RECORD;
|
||||
if(!file)return 'docker';
|
||||
const binary=JSON.parse(readFileSync(file,'utf8')).docker;
|
||||
assert.ok(typeof binary==='string'&&isAbsolute(binary),'lane record must name the real absolute Docker binary');
|
||||
return binary;
|
||||
}
|
||||
function composeFixture(t) {
|
||||
const tempRoot=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(tempRoot,{recursive:true,force:true}));
|
||||
const checkout=join(tempRoot,'candidate');mkdirSync(checkout);cpSync(new URL('../../../compose.yaml',import.meta.url),join(checkout,'compose.yaml'));
|
||||
const config=join(tempRoot,'docker-config');mkdirSync(config,{mode:0o700});
|
||||
const auth=join(tempRoot,'fixture-auth.json');writeFileSync(auth,'{}',{mode:0o600});
|
||||
return {tempRoot,checkout,project:'mg197-a-abcdef123456',tag:'mosaic-poc-agent:fixture',imageId:'sha256:'+'a'.repeat(64),
|
||||
env:{PATH:process.env.PATH,HOME:process.env.HOME,DOCKER_CONFIG:config,COMPOSE_PROJECT_NAME:'mg197-a-abcdef123456',
|
||||
MOSAIC_IMAGE_TAG:'mosaic-poc-agent:fixture',MOSAIC_PROVIDER:'fixture',MOSAIC_MODEL:'fixture',MOSAIC_DATA_ROOT:tempRoot,PI_AUTH_FILE:auth}};
|
||||
}
|
||||
test('installed Compose accepts rewritten run argv without starting a container',t=>{
|
||||
const run=composeFixture(t);
|
||||
if(docker.writeComposeOverride)docker.writeComposeOverride(run);
|
||||
const argv=docker.guardDocker(['compose','run','--rm','-T','mosaic-agent','fixture'],run);
|
||||
const command=argv.indexOf('run');assert.ok(command>0);
|
||||
// --help is before SERVICE, so parsing exits before any service operation.
|
||||
const parsed=spawnSync(installedDocker(),[...argv.slice(0,command+1),'--help',...argv.slice(command+1)],{cwd:run.checkout,env:run.env,encoding:'utf8',timeout:10000});
|
||||
assert.equal(parsed.status,0,parsed.stdout+parsed.stderr);
|
||||
assert.match(parsed.stdout,/Usage:/);
|
||||
});
|
||||
test('installed Compose resolves the private override with no build and the pinned image',t=>{
|
||||
const run=composeFixture(t);assert.equal(typeof docker.writeComposeOverride,'function');
|
||||
const override=docker.writeComposeOverride(run);
|
||||
assert.equal(override,join(run.tempRoot,'compose-no-build.yaml'));
|
||||
assert.equal(statSync(override).mode&0o777,0o600);
|
||||
assert.equal(docker.writeComposeOverride(run),override,'parallel callers reuse identical private content');
|
||||
const argv=docker.guardDocker(['compose','run','--rm','mosaic-agent'],run),command=argv.indexOf('run');
|
||||
const resolved=spawnSync(installedDocker(),[...argv.slice(0,command),'config','--format','json'],{cwd:run.checkout,env:run.env,encoding:'utf8',timeout:10000});
|
||||
assert.equal(resolved.status,0,resolved.stdout+resolved.stderr);
|
||||
const service=JSON.parse(resolved.stdout).services['mosaic-agent'];
|
||||
assert.equal(Object.hasOwn(service,'build'),false);assert.equal(service.image,run.imageId);
|
||||
t.diagnostic(JSON.stringify({installedComposeConfig:{buildPresent:Object.hasOwn(service,'build'),image:service.image}}));
|
||||
assert.ok(argv.includes('--no-deps'));assert.equal(argv[argv.indexOf('--pull')+1],'never');
|
||||
writeFileSync(override,'changed fixture');assert.throws(()=>docker.writeComposeOverride(run),/override/);
|
||||
unlinkSync(override);const foreign=join(run.tempRoot,'foreign');writeFileSync(foreign,'fixture');symlinkSync(foreign,override);
|
||||
assert.throws(()=>docker.writeComposeOverride(run),/override/);assert.equal(readFileSync(foreign,'utf8'),'fixture');
|
||||
});
|
||||
test('guard owns every Compose file argument and refuses caller configuration overrides',t=>{
|
||||
const run=composeFixture(t);
|
||||
for(const args of [['-f','foreign.yaml'],['--file','foreign.yaml'],['--file=foreign.yaml'],['--project-directory','/tmp'],['--build'],['--pull','always'],['--no-build']])
|
||||
assert.throws(()=>docker.guardDocker(['compose','run',...args,'mosaic-agent'],run),/options not allowed/);
|
||||
for(const args of [['compose','-f','foreign.yaml','run','mosaic-agent'],['compose','--project-directory','/tmp','run','mosaic-agent']])
|
||||
assert.throws(()=>docker.guardDocker(args,run),/unowned command/);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import * as fsExtra from 'node:fs';
|
||||
import * as childModule from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
const api=await import('../../../scripts/testing/lane-state.mjs');
|
||||
function fixture(t){const root=mkdtempSync('/mnt/storage/scratch/gs-');t.after(()=>rmSync(root,{recursive:true,force:true}));return root;}
|
||||
test('unfinished dead-owner runs quarantine their lane and a recovery record clears only that run',t=>{
|
||||
assert.equal(typeof api.appendRecord,'function','appendRecord is required');
|
||||
const root=fixture(t),dir=join(root,'runs','one');
|
||||
api.appendRecord(dir,{kind:'start',lane:'a',owner:{pid:123456789,start:'1',boot:'fixture'}});
|
||||
assert.equal(api.status(root,{processes:[]}).a.state,'QUARANTINED');
|
||||
api.appendRecord(dir,{kind:'recovered',clean:true});
|
||||
assert.equal(api.status(root,{processes:[]}).a.state,'CLEAR');
|
||||
});
|
||||
test('a legacy landing gate beside a lane stays legacy even with the same parent',t=>{
|
||||
assert.equal(typeof api.appendRecord,'function');
|
||||
const root=fixture(t);const owner={pid:123,start:'5',boot:'b',state:'S',ppid:1,argv:['node','gate-lane.mjs']};
|
||||
api.appendRecord(join(root,'runs','one'),{kind:'start',lane:'a',owner});
|
||||
const procs=[owner,{pid:124,start:'6',boot:'b',state:'S',ppid:1,argv:['node','/fixture/landing.mjs','gate','/fixture/wt','/fixture/logs']}];
|
||||
const result=api.status(root,{processes:procs});assert.equal(result.a.state,'BUSY');assert.equal(result.b.state,'CLEAR');assert.deepEqual(result.legacy,[{pid:124,start:'6'}]);
|
||||
});
|
||||
test('the shell status entry reads scratch receipts and reports legacy processes without mutating old slots',t=>{
|
||||
const root=fixture(t);const {spawnSync}=requireChild();
|
||||
const r=spawnSync('bash',[new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname,'status','--state-root',root],{encoding:'utf8'});
|
||||
assert.equal(r.status,0,r.stderr);const state=JSON.parse(r.stdout);assert.equal(state.a.state,'CLEAR');assert.equal(state.b.state,'CLEAR');assert.ok(Array.isArray(state.legacy));
|
||||
});
|
||||
function requireChild(){return childModule;}
|
||||
test('recovery supersedes an unclean terminal without rewriting either receipt',t=>{
|
||||
const root=fixture(t),dir=join(root,'runs','one');api.appendRecord(dir,{kind:'start',lane:'a',owner:{pid:99999999,start:'1',boot:'fixture'}});api.appendRecord(dir,{kind:'terminal',clean:false});api.appendRecord(dir,{kind:'recovered',clean:true});assert.equal(api.status(root,{processes:[]}).a.state,'CLEAR');assert.equal(api.records(dir).length,3);
|
||||
});
|
||||
test('the wrapper refuses a held lane lock and preserves its inode',async t=>{
|
||||
const root=fixture(t),file=join(root,'a.lock');const {spawn,spawnSync}=childModule;
|
||||
const holder=spawn('flock',['-x',file,'bash','-c','echo ready; read -r done'],{stdio:['pipe','pipe','pipe']});const exited=new Promise(r=>holder.once('exit',r));await new Promise(r=>holder.stdout.once('data',r));
|
||||
const {statSync}=await import('node:fs');const ino=statSync(file).ino;
|
||||
t.after(async()=>{holder.stdin.end('\n');await exited;});
|
||||
const r=spawnSync('bash',[new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname,'run','--state-root',root,'--lane','a','--grant','fixture'],{encoding:'utf8'});
|
||||
assert.equal(r.status,2);assert.match(r.stderr,/lock busy/);assert.equal(statSync(file).ino,ino);
|
||||
});
|
||||
test('a held kernel lock without a start receipt is BUSY rather than CLEAR',async t=>{
|
||||
const root=fixture(t),file=join(root,'a.lock');const holder=childModule.spawn('flock',['-x',file,'bash','-c','echo ready; read -r done'],{stdio:['pipe','pipe','pipe']});const exited=new Promise(r=>holder.once('exit',r));await new Promise(r=>holder.stdout.once('data',r));t.after(async()=>{holder.stdin.end('\n');await exited;});
|
||||
assert.equal(api.status(root,{processes:[]}).a.state,'BUSY');
|
||||
});
|
||||
test('SIGKILL leaves a quarantined run and locked recovery publishes a new clean receipt',async t=>{
|
||||
const root=fixture(t),temp=mkdtempSync('/mnt/storage/scratch/gt-');t.after(()=>rmSync(temp,{recursive:true,force:true}));
|
||||
const child=childModule.spawn(process.execPath,[new URL('./fixtures/gate-lane-child.mjs',import.meta.url).pathname,'wait',join(temp,'started')],{stdio:['ignore','pipe','pipe']});
|
||||
const exited=new Promise(r=>child.once('exit',r));await new Promise(r=>child.stdout.once('data',r));
|
||||
const id='abcdef123456',dir=join(root,'runs',id),docker=join(root,'docker-fixture');writeFileSync(docker,'#!/bin/sh\nexit 0\n',{mode:0o700});
|
||||
api.appendRecord(dir,{kind:'start',lane:'a',owner:api.identity(child.pid),tempRoot:temp,project:`mg197-a-${id}`,docker});
|
||||
child.kill('SIGKILL');await exited;assert.equal(api.status(root,{processes:[]}).a.state,'QUARANTINED');
|
||||
const r=childModule.spawnSync('bash',[new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname,'recover','--state-root',root,'--run-id',id,'--grant','fixture-recover'],{encoding:'utf8'});
|
||||
assert.equal(r.status,0,r.stderr);assert.equal(api.status(root,{processes:[]}).a.state,'CLEAR');assert.equal(api.records(dir).filter(e=>e.kind==='start').length,1);assert.equal(api.records(dir).filter(e=>e.kind==='recovered').length,1);
|
||||
});
|
||||
test('a malformed receipt refuses status and an identity mismatch cannot look live',t=>{
|
||||
const root=fixture(t),dir=join(root,'runs','one');api.appendRecord(dir,{kind:'start',lane:'a',owner:{pid:123,start:'old',boot:'b'}});
|
||||
assert.equal(api.status(root,{processes:[{pid:123,start:'new',boot:'b',state:'S',ppid:1,argv:['node','owned'] }]}).a.state,'QUARANTINED');
|
||||
writeFileSync(join(dir,'bad.json'),'{');assert.throws(()=>api.status(root,{processes:[]}),/invalid run receipt/);
|
||||
});
|
||||
test('a symlink lock is refused before acquiring or replacing its target',t=>{
|
||||
const root=fixture(t),target=join(root,'foreign');writeFileSync(target,'untouched');const {symlinkSync}=fsExtra;symlinkSync(target,join(root,'a.lock'));
|
||||
const r=childModule.spawnSync('bash',[new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname,'run','--state-root',root,'--lane','a','--grant','fixture'],{encoding:'utf8'});
|
||||
assert.equal(r.status,2);assert.match(r.stderr,/ordinary file/);assert.equal(readFileSync(target,'utf8'),'untouched');
|
||||
});
|
||||
test('failed recovery keeps quarantine and never publishes a clean recovery receipt',async t=>{
|
||||
const root=fixture(t),temp=mkdtempSync('/mnt/storage/scratch/gt-');t.after(()=>rmSync(temp,{recursive:true,force:true}));
|
||||
const id='abcdef123456',dir=join(root,'runs',id),docker=join(root,'docker-fixture');writeFileSync(docker,'#!/bin/sh\nexit 1\n',{mode:0o700});
|
||||
api.appendRecord(dir,{kind:'start',lane:'a',owner:{pid:99999999,start:'1',boot:'fixture'},tempRoot:temp,project:`mg197-a-${id}`,docker});
|
||||
const r=childModule.spawnSync('bash',[new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname,'recover','--state-root',root,'--run-id',id,'--grant','fixture-recover'],{encoding:'utf8'});
|
||||
assert.equal(r.status,2);assert.equal(api.status(root,{processes:[]}).a.state,'QUARANTINED');assert.equal(api.records(dir).some(e=>e.kind==='recovered'),false);
|
||||
});
|
||||
test('two shared image holders coexist, but exclusive preparation refuses their lock',async t=>{
|
||||
const root=fixture(t),file=join(root,'image.lock');const holder=childModule.spawn('flock',['-s',file,'bash','-c','echo ready; read -r done'],{stdio:['pipe','pipe','pipe']});const exited=new Promise(r=>holder.once('exit',r));await new Promise(r=>holder.stdout.once('data',r));t.after(async()=>{holder.stdin.end('\n');await exited;});
|
||||
const entry=new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname;
|
||||
const shared=childModule.spawnSync('bash',[entry,'run','--state-root',root,'--lane','a','--grant','fixture'],{encoding:'utf8'});
|
||||
assert.equal(shared.status,2);assert.doesNotMatch(shared.stderr,/lock busy|descriptor|not locked/);
|
||||
const exclusive=childModule.spawnSync('bash',[entry,'prepare-image','--state-root',root,'--grant','fixture'],{encoding:'utf8'});
|
||||
assert.equal(exclusive.status,2);assert.match(exclusive.stderr,/lock busy/);
|
||||
});
|
||||
const entry=new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname;
|
||||
const childFixture=new URL('./fixtures/gate-lane-child.mjs',import.meta.url).pathname;
|
||||
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
async function until(check){for(let n=0;n<100;n++){if(check())return;await sleep(20);}throw Error('fixture barrier did not become ready');}
|
||||
test('two simultaneous shell claims have one winner; supervisor retains lock FDs and ordinary children do not',async t=>{
|
||||
const root=fixture(t),bin=join(root,'bin');fsExtra.mkdirSync(bin);const shim=join(bin,'node');
|
||||
writeFileSync(shim,`#!/bin/sh\nif [ "$2" = --locked ]; then exec '${process.execPath}' '${childFixture}' lock-probe '${root}'; fi\nexec '${process.execPath}' "$@"\n`,{mode:0o700});
|
||||
const env={...process.env,PATH:bin+':'+process.env.PATH};
|
||||
const start=()=>{const p=childModule.spawn('bash',[entry,'run','--state-root',root,'--lane','a','--grant','fixture'],{env,stdio:['ignore','pipe','pipe']});p.done=new Promise(r=>p.once('exit',code=>r(code)));return p;};
|
||||
const first=start(),second=start();t.after(()=>{first.kill('SIGKILL');second.kill('SIGKILL');});
|
||||
await until(()=>fsExtra.existsSync(join(root,'ready')));
|
||||
const all=api.runRecords(root);assert.equal(all.length,1);assert.equal(readFileSync(join(root,'out/fds.log'),'utf8'),'');
|
||||
const winner=all[0].records.find(r=>r.kind==='start').owner.pid;assert.ok([first.pid,second.pid].includes(winner));
|
||||
const loser=first.pid===winner?second:first;assert.equal(await loser.done,2);
|
||||
const ino=fsExtra.statSync(join(root,'a.lock')).ino;
|
||||
assert.equal(childModule.spawnSync('flock',['-n',join(root,'a.lock'),'true']).status,1);
|
||||
const won=first.pid===winner?first:second;won.kill('SIGTERM');await won.done;
|
||||
assert.equal(fsExtra.statSync(join(root,'a.lock')).ino,ino);
|
||||
});
|
||||
test('same checkout and a legacy land gate each refuse a claim on an otherwise free lane',async t=>{
|
||||
const {activeConflicts}=await import('../../../scripts/testing/gate-lane.mjs');
|
||||
const root=fixture(t),owner={pid:321,start:'1',boot:'test',state:'S',ppid:1,argv:['node','owned']};
|
||||
api.appendRecord(join(root,'runs','one'),{kind:'start',lane:'a',checkout:'/fixture/candidate',owner});
|
||||
const o={stateRoot:root,lane:'b',checkout:'/fixture/candidate'};
|
||||
assert.throws(()=>activeConflicts(o,{processes:[owner]}),/checkout already owned/);
|
||||
assert.throws(()=>activeConflicts({...o,checkout:'/fixture/other'},{processes:[owner,{pid:322,start:'2',boot:'test',state:'S',ppid:1,argv:['node','/fixture/landing.mjs','gate']}]}),/LEGACY BUSY/);
|
||||
});
|
||||
test('recovery kills a proven surviving child, preserves a foreign child and keeps quarantine if interrupted',async t=>{
|
||||
const root=fixture(t),temp=mkdtempSync('/mnt/storage/scratch/gt-');t.after(()=>rmSync(temp,{recursive:true,force:true}));
|
||||
const owned=childModule.spawn(process.execPath,[childFixture,'wait',join(temp,'child')],{cwd:temp,stdio:'ignore'});
|
||||
const foreign=childModule.spawn(process.execPath,[childFixture,'wait',join(root,'foreign')],{stdio:'ignore'});
|
||||
const ownedDone=new Promise(r=>owned.once('exit',r)),foreignDone=new Promise(r=>foreign.once('exit',r));t.after(async()=>{owned.kill('SIGKILL');foreign.kill('SIGKILL');await Promise.all([ownedDone,foreignDone]);});
|
||||
await until(()=>fsExtra.existsSync(join(temp,'child'))&&fsExtra.existsSync(join(root,'foreign')));
|
||||
const docker=join(root,'docker'),pidFile=join(root,'docker-pid'),id='abcdef123456',dir=join(root,'runs',id);
|
||||
writeFileSync(docker,`#!/bin/sh\necho $$ > '${pidFile}'\nexec sleep 60\n`,{mode:0o700});
|
||||
api.appendRecord(dir,{kind:'start',id,stateRoot:root,lane:'a',owner:{pid:99999999,start:'1',boot:'test'},tempRoot:temp,project:`mg197-a-${id}`,docker});
|
||||
const recovery=childModule.spawn('bash',[entry,'recover','--state-root',root,'--run-id',id,'--grant','fixture'],{stdio:'ignore'});const done=new Promise(r=>recovery.once('exit',r));
|
||||
await until(()=>fsExtra.existsSync(pidFile));const dockerOwner=api.identity(Number(readFileSync(pidFile,'utf8')));recovery.kill('SIGKILL');await done;
|
||||
if(api.sameProcess(dockerOwner))process.kill(dockerOwner.pid,'SIGKILL');
|
||||
await ownedDone;assert.equal(api.sameProcess(api.identity(foreign.pid)),true);
|
||||
assert.equal(api.records(dir).some(r=>r.kind==='recovered'),false);assert.equal(api.status(root,{processes:[]}).a.state,'QUARANTINED');
|
||||
writeFileSync(docker,'#!/bin/sh\nexit 0\n',{mode:0o700});
|
||||
const retried=childModule.spawnSync('bash',[entry,'recover','--state-root',root,'--run-id',id,'--grant','fixture-resume'],{encoding:'utf8'});
|
||||
assert.equal(retried.status,0,retried.stderr);assert.equal(api.status(root,{processes:[]}).a.state,'CLEAR');assert.equal(api.sameProcess(api.identity(foreign.pid)),true);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const api = await import('../../../scripts/testing/gate-lane.mjs').catch(e => {
|
||||
if (e.code !== 'ERR_MODULE_NOT_FOUND') throw e;
|
||||
return {};
|
||||
});
|
||||
function fixture(t) {
|
||||
const root = mkdtempSync('/mnt/storage/scratch/gt-');
|
||||
// Node appends six characters, keeping this root at 30 bytes.
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const checkout = join(root, 'checkout'); mkdirSync(checkout);
|
||||
for (const [f,text] of [['package.json','{}'],['RELEASE','0.0.1\n']]) writeFileSync(join(checkout,f),text);
|
||||
execFileSync('git',['init','-q',checkout]);
|
||||
execFileSync('git',['-C',checkout,'add','.']);
|
||||
execFileSync('git',['-C',checkout,'-c','user.name=Fixture','-c','user.email=f@invalid','commit','-qm','fixture']);
|
||||
symlinkSync('/mnt/storage/src/mosaic-stack/node_modules',join(checkout,'node_modules'));
|
||||
return {lane:'a',candidate:execFileSync('git',['-C',checkout,'rev-parse','HEAD'],{encoding:'utf8'}).trim(),checkout,out:join(root,'out'),tempRoot:root,stateRoot:join(root,'state'),grant:'fixture-grant',hostFacts:{unprivilegedPortStart:1024,ephemeral:[32768,60999],listeners:[]}};
|
||||
}
|
||||
test('preflight refuses unsafe lane roots, missing grants and live port 9 before side effects', t => {
|
||||
assert.equal(typeof api.validateRun,'function','validateRun is required');
|
||||
const f=fixture(t);
|
||||
assert.equal(api.validateRun(f).lane,'a');
|
||||
for(const [patch,reason] of [[{lane:'c'},/lane/],[{grant:''},/grant/],[{tempRoot:'/tmp'},/tmp/],[{tempRoot:join(f.tempRoot,'deeper')},/30|missing/],[{candidate:'a'.repeat(40)},/candidate/],[{hostFacts:{...f.hostFacts,listeners:[{address:'0.0.0.0',port:9}]}},/port.9/],[{hostFacts:{...f.hostFacts,unprivilegedPortStart:0}},/privileged/]]) assert.throws(()=>api.validateRun({...f,...patch}),reason);
|
||||
const link=join(f.tempRoot,'link');symlinkSync('/tmp',link);assert.throws(()=>api.validateRun({...f,tempRoot:link}),/tmp/);
|
||||
writeFileSync(join(f.checkout,'RELEASE'),'changed');assert.throws(()=>api.validateRun(f),/dirty/);
|
||||
});
|
||||
test('discovery preserves all package and script steps while ignoring non-file shell names', t => {
|
||||
const f=fixture(t);mkdirSync(join(f.checkout,'packages'));
|
||||
for(const p of ['z','queue','a']){mkdirSync(join(f.checkout,'packages',p,'tests'),{recursive:true});writeFileSync(join(f.checkout,'packages',p,'tests','one.test.mjs'),'');}
|
||||
mkdirSync(join(f.checkout,'scripts'));writeFileSync(join(f.checkout,'scripts','test-z.sh'),'');writeFileSync(join(f.checkout,'scripts','test-a.sh'),'');mkdirSync(join(f.checkout,'scripts','test-directory.sh'));
|
||||
const steps=api.discoverSteps(f.checkout);
|
||||
assert.deepEqual(steps.map(s=>s.name),['pkg-a','pkg-z','script-a','script-z']);
|
||||
assert.deepEqual(steps[0].argv,['node','--test','--test-concurrency=2','tests/one.test.mjs']);
|
||||
});
|
||||
test('a failed step stops the gate and records its nonzero exit without running the next step',async t=>{
|
||||
assert.equal(typeof api.executeSteps,'function','executeSteps is required');
|
||||
const f=fixture(t),events=[],out=f.out;mkdirSync(out);const marker=join(f.tempRoot,'should-not-exist');
|
||||
const result=await api.executeSteps([{name:'first',cwd:f.checkout,argv:['bash','-c','exit 7']},{name:'second',cwd:f.checkout,argv:['touch',marker]}],{out,env:process.env,record:r=>events.push(r)});
|
||||
assert.equal(result.rc,7);assert.equal(events.filter(e=>e.kind==='step').length,1);assert.ok(events.find(e=>e.kind==='step').wallMs>=0);
|
||||
const {existsSync}=await import('node:fs');assert.equal(existsSync(marker),false);
|
||||
});
|
||||
test('a cancelled step stops its process group and records failure',async t=>{
|
||||
const f=fixture(t);mkdirSync(f.out);const controller=new AbortController(),events=[];
|
||||
const child=new URL('./fixtures/gate-lane-child.mjs',import.meta.url).pathname;
|
||||
const result=await api.executeSteps([{name:'child',cwd:f.checkout,argv:[process.execPath,child,'wait',join(f.tempRoot,'started')]}],{out:f.out,env:process.env,record:e=>{events.push(e);if(e.kind==='child')setTimeout(()=>controller.abort(),100);},signal:controller.signal});
|
||||
assert.notEqual(result.rc,0);const owned=events.find(e=>e.kind==='child').owner;const {sameProcess}=await import('../../../scripts/testing/lane-state.mjs');assert.equal(sameProcess(owned),false);
|
||||
});
|
||||
test('step receipts retain pass fail skipped counts and hashes of their actual logs',async t=>{
|
||||
const f=fixture(t);mkdirSync(f.out);const events=[];
|
||||
await api.executeSteps([{name:'counts',cwd:f.checkout,argv:[process.execPath,'-e',"console.log('ℹ pass 2\\nℹ fail 0\\nℹ skipped 1')"]}],{out:f.out,env:process.env,record:r=>events.push(r)});
|
||||
const step=events.find(e=>e.kind==='step');assert.deepEqual(step.counts,{pass:2,fail:0,skipped:1});assert.match(step.logSha256,/^[a-f0-9]{64}$/);
|
||||
});
|
||||
test('preflight rejects a real 31-byte root, missing dependency tree, occupied band and missing inventory',t=>{
|
||||
const f=fixture(t),long=f.tempRoot+'x';mkdirSync(long,{mode:0o700});t.after(()=>rmSync(long,{recursive:true,force:true}));assert.equal(Buffer.byteLength(long),31);
|
||||
assert.throws(()=>api.validateRun({...f,tempRoot:long}),/30 bytes/);
|
||||
assert.throws(()=>api.validateRun({...f,hostFacts:{...f.hostFacts,ephemeral:[60000,62000]}}),/overlaps/);
|
||||
assert.throws(()=>api.validateRun({...f,hostFacts:{...f.hostFacts,listeners:[{address:'127.0.0.1',port:61005}]}}),/live listener/);
|
||||
assert.throws(()=>api.validateRun({...f,hostFacts:{}}),/inventory/);
|
||||
rmSync(join(f.checkout,'node_modules'));assert.throws(()=>api.validateRun(f),/node_modules/);
|
||||
});
|
||||
|
||||
test('preflight refuses canonical and builder checkouts and an existing output',t=>{
|
||||
const f=fixture(t);
|
||||
for(const checkout of ['/mnt/storage/src/mosaic-stack',new URL('../../../',import.meta.url).pathname])assert.throws(()=>api.validateRun({...f,checkout}),/canonical or builder/);
|
||||
mkdirSync(f.out);assert.throws(()=>api.validateRun(f),/output already exists/);
|
||||
});
|
||||
test('both candidates must carry every approved launcher file',t=>{
|
||||
const f=fixture(t),other=join(f.tempRoot,'second');mkdirSync(other);
|
||||
for(const checkout of [f.checkout,other]){
|
||||
const target=join(checkout,'scripts','testing');fsExtra.cpSync(new URL('../../../scripts/testing',import.meta.url),target,{recursive:true});
|
||||
api.validateTooling(checkout);
|
||||
}
|
||||
fsExtra.appendFileSync(join(other,'scripts/testing/lane-state.mjs'),'\n// changed\n');
|
||||
assert.throws(()=>api.validateTooling(other),/tooling differs/);
|
||||
fsExtra.rmSync(join(f.checkout,'scripts/testing/lane-fixtures.mjs'));
|
||||
assert.throws(()=>api.validateTooling(f.checkout),/tooling differs/);
|
||||
});
|
||||
test('the 92-byte socket example passes and an over-107-byte pathname refuses',()=>{
|
||||
const root='/mnt/storage/scratch/gt-ABCDEF';assert.equal(Buffer.byteLength(root),30);
|
||||
const socket=root+'/mosaic-cli-ABCDEF/data/launches/acme/r0123456789ab/tools.sock';
|
||||
assert.equal(Buffer.byteLength(socket),92);assert.equal(stateApi.socketPath(socket),socket);
|
||||
assert.throws(()=>stateApi.socketPath(socket+'x'.repeat(16)),/107 bytes/);
|
||||
});
|
||||
test('package/script duplicates stay and every supervised step clears NODE_TEST_CONTEXT',async t=>{
|
||||
const f=fixture(t);mkdirSync(join(f.checkout,'packages/cli/tests'),{recursive:true});mkdirSync(join(f.checkout,'scripts'));
|
||||
writeFileSync(join(f.checkout,'packages/cli/tests/repeated.test.mjs'),'');writeFileSync(join(f.checkout,'scripts/test-cli.sh'),'node --test packages/cli/tests/repeated.test.mjs\n');
|
||||
const steps=api.discoverSteps(f.checkout);assert.deepEqual(steps.map(s=>s.name),['pkg-cli','script-cli']);
|
||||
const original=process.env.NODE_TEST_CONTEXT;process.env.NODE_TEST_CONTEXT='parent-context';
|
||||
let env;try{env=api.cleanEnv(f,{project:'fixture'},'/fake/docker');}finally{if(original===undefined)delete process.env.NODE_TEST_CONTEXT;else process.env.NODE_TEST_CONTEXT=original;}
|
||||
assert.equal(env.NODE_TEST_CONTEXT,undefined);assert.equal(env.HOME,process.env.HOME);
|
||||
mkdirSync(f.out);const events=[];
|
||||
for(const s of steps)s.argv=[process.execPath,'-e',"if(process.env.NODE_TEST_CONTEXT)process.exit(9);console.log('cleared')"];
|
||||
const result=await api.executeSteps(steps,{out:f.out,env,record:e=>events.push(e)});
|
||||
assert.equal(result.rc,0);assert.equal(events.filter(e=>e.kind==='step').length,2);
|
||||
});
|
||||
test('scoped ss loopback and wildcard port 9 listeners refuse',t=>{
|
||||
const f=fixture(t);
|
||||
for(const address of ['127.0.0.1%lo','0.0.0.0%lo','::%lo','::ffff:127.0.0.1%lo'])assert.throws(()=>api.validateRun({...f,hostFacts:{...f.hostFacts,listeners:[{address,port:9}]}}),/port 9/);
|
||||
});
|
||||
import * as fsExtra from 'node:fs';
|
||||
import * as stateApi from '../../../scripts/testing/lane-state.mjs';
|
||||
|
||||
|
||||
test('step children use legacy umask 022 while the supervisor retains 077', async t => {
|
||||
const f=fixture(t);mkdirSync(f.out,{mode:0o700});const events=[],made=join(f.tempRoot,'step-file');
|
||||
const original=process.umask(0o077);
|
||||
try {
|
||||
const result=await api.executeSteps([{name:'umask',cwd:f.checkout,argv:[process.execPath,'-e',`const fs=require('node:fs');console.log(process.umask().toString(8));fs.writeFileSync(${JSON.stringify(made)},'fixture',{mode:0o666});`]}],{out:f.out,env:process.env,record:r=>events.push(r)});
|
||||
assert.equal(result.rc,0);assert.equal(process.umask(),0o077);
|
||||
assert.equal(fsExtra.readFileSync(join(f.out,'umask.log'),'utf8').trim(),'22');
|
||||
assert.equal(fsExtra.statSync(made).mode&0o777,0o644);
|
||||
assert.equal(fsExtra.statSync(join(f.out,'umask.log')).mode&0o777,0o600);
|
||||
} finally {process.umask(original);}
|
||||
});
|
||||
test('lane wrapper keeps its state directory and lock files private', t => {
|
||||
const f=fixture(t),script=new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname;
|
||||
const result=spawnSync('bash',[script,'recover','--state-root',f.stateRoot,'--run-id','000000000000','--grant','fixture-private-modes'],{encoding:'utf8'});
|
||||
assert.equal(result.status,2,result.stdout+result.stderr);assert.match(result.stderr,/run not found/);
|
||||
assert.equal(fsExtra.statSync(f.stateRoot).mode&0o777,0o700);
|
||||
for(const name of ['a.lock','b.lock','image.lock'])assert.equal(fsExtra.statSync(join(f.stateRoot,name)).mode&0o777,0o600);
|
||||
});
|
||||
@@ -37,7 +37,10 @@ function run(cmd, args, env) {
|
||||
return { code: r.status, out: r.stdout, err: r.stderr };
|
||||
}
|
||||
|
||||
const bootstrap = (home, ...args) => run(BOOTSTRAP, args, homeEnv(home));
|
||||
// The child runs under an explicit umask, so the mode assertions hold under any gate umask (row 243,
|
||||
// sage-1918). mosaic-config.mjs creates with 0644 and lets the umask narrow it.
|
||||
const underUmask = (mask, home, args) => run("/bin/bash", ["-c", `umask ${mask}; exec "$@"`, "bash", BOOTSTRAP, ...args], homeEnv(home));
|
||||
const bootstrap = (home, ...args) => underUmask("022", home, args);
|
||||
const config = (home, extra, ...args) => run(process.execPath, [CONFIG, ...args], homeEnv(home, extra));
|
||||
const cfgOf = (home, name) => join(home, ".config", `mosaic-${name}`, "config.json");
|
||||
|
||||
@@ -94,6 +97,16 @@ test("T-I1 bootstrap --instance creates each instance's config, root and marker
|
||||
assert.equal(existsSync(join(home, ".mosaic-dev")), false);
|
||||
});
|
||||
|
||||
await t.test("under umask 077 the config and marker are 0600: the source honors the operator's umask", (t) => {
|
||||
const home = tmp(t, "si-");
|
||||
const r = underUmask("077", home, ["--instance", "alpha"]);
|
||||
assert.equal(r.code, 0, r.err);
|
||||
assert.equal(mode(join(home, ".config", "mosaic-alpha")), 0o700);
|
||||
assert.equal(mode(cfgOf(home, "alpha")), 0o600);
|
||||
assert.equal(mode(join(home, ".mosaic-alpha")), 0o700);
|
||||
assert.equal(mode(join(home, ".mosaic-alpha", "instance.json")), 0o600);
|
||||
});
|
||||
|
||||
await t.test("a second bootstrap of each changes no byte, mode or mtime", (t) => {
|
||||
const home = tmp(t, "si-");
|
||||
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
import { after as laneAfter } from 'node:test';
|
||||
// scripts/stack-release.sh (D1, #1659). Design, the T table and the mutants:
|
||||
// agents/darkwing/work/queue-162/BUILD.md. Every run uses a temp data root, temp units and stand-in
|
||||
// systemctl, journalctl and systemd-analyze on PATH; nothing here reads ~/.mosaic-dev or a live unit.
|
||||
@@ -1784,7 +1786,7 @@ for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']],
|
||||
}
|
||||
|
||||
const TESTER='7499';
|
||||
const freePort=async()=>{const s=createServer();s.listen(0,'127.0.0.1');await once(s,'listening');const p=String(s.address().port);await new Promise((r)=>s.close(r));return p};
|
||||
const freePort=async()=>String(await leasePort({after:laneAfter},{closed:true}));
|
||||
const rh=(e,...p)=>join(e.env.HOME,...p);
|
||||
const rfault=(e,name)=>{mkdirSync(rh(e,'faults'),{recursive:true});writeFileSync(rh(e,'faults',name),'')};
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
import { after as laneAfter } from 'node:test';
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
@@ -73,16 +75,7 @@ function closeServer(server) {
|
||||
|
||||
// Grab an ephemeral free port, then hand it back immediately so a caller can
|
||||
// try to bind it themselves (used to prove startServer never opened a socket).
|
||||
function getFreePort() {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const probe = createNetServer();
|
||||
probe.once("error", reject);
|
||||
probe.listen(0, "127.0.0.1", () => {
|
||||
const port = probe.address().port;
|
||||
probe.close(() => resolvePromise(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
function getFreePort() { return leasePort({after:laneAfter}, {closed:true}); }
|
||||
|
||||
function runCli(args) {
|
||||
return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8", timeout: 15000 });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fixtureCacheEnv } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
// The host's claude CLI through adapters/claude with the bundle's hook and
|
||||
// MCP server, against the scripted Messages API. Scratch CLAUDE_CONFIG_DIR
|
||||
// and HOME, a dummy key, nonessential traffic off: nothing reaches a real
|
||||
@@ -61,7 +62,7 @@ async function session(t, script, tools = ["read", "bash"]) {
|
||||
|
||||
function turn(env, request, cwd, adapter = ADAPTER) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("/bin/sh", [adapter], { cwd, env: { ...env, MOSAIC_REQUEST: request }, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
||||
const child = spawn("/bin/sh", [adapter], { cwd, env: { ...env, ...fixtureCacheEnv(env.CLAUDE_CONFIG_DIR), MOSAIC_REQUEST: request }, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (b) => (stdout += b));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fixtureCacheEnv } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
// Real pi (the pinned CLI) through adapters/pi with the extension, against
|
||||
// the scripted Messages API. No real model, no network beyond 127.0.0.1.
|
||||
|
||||
@@ -57,7 +58,7 @@ async function session(t, script, place = (dir) => join(dir, "ws"), instance = "
|
||||
|
||||
function turn(env, request, cwd) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("/bin/sh", [ADAPTER], { cwd, env: { ...env, MOSAIC_REQUEST: request }, stdio: ["ignore", "pipe", "pipe"] });
|
||||
const child = spawn("/bin/sh", [ADAPTER], { cwd, env: { ...env, ...fixtureCacheEnv(env.PI_CODING_AGENT_DIR), MOSAIC_REQUEST: request }, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (b) => (stdout += b));
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
import { after as laneAfter } from 'node:test';
|
||||
// Row 74 (#1568, lead decision 93): the preview mode. A request for the public name needs the
|
||||
// proxy's header equal to the one user id, else 401 with nothing read; in preview mode nothing
|
||||
// but GET and HEAD gets through, whatever the Host; loopback requests work as they did. Round 2:
|
||||
@@ -205,8 +207,7 @@ test('in preview mode every write answers 403 read-only, from the public name an
|
||||
});
|
||||
|
||||
test('loopback requests are unchanged in preview mode, and the bus views work with the board down', async t => {
|
||||
const probe = createServer(); await new Promise(r => probe.listen(0, '127.0.0.1', r));
|
||||
const dead = `http://127.0.0.1:${probe.address().port}`; await close(probe);
|
||||
const dead = `http://127.0.0.1:${await leasePort({after:laneAfter}, {closed:true})}`;
|
||||
const { port, busCalls } = await setup(t, { board: dead, timeout: 200 });
|
||||
const loopback = { host: `127.0.0.1:${port}` };
|
||||
const inbox = await send(port, { headers: loopback });
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
import { after as laneAfter } from 'node:test';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
@@ -25,7 +27,7 @@ test('proof startup refuses partial flags non-preview and invalid fixture file m
|
||||
const cases = [
|
||||
...sets.map(flags => ({ flags })),
|
||||
...[['mode', VALUE, 0o644], ['short', 'short_fixture', 0o600], ['newline', VALUE.slice(0, 20) + '\n' + VALUE.slice(20), 0o600], ['bad', VALUE + '!', 0o600], ['two-final-lines', VALUE + '\n\n', 0o600]].map(([name, value, mode]) => {
|
||||
const file = join(f.root, name); writeFileSync(file, value, { mode });
|
||||
const file = join(f.root, name); writeFileSync(file, value, { mode }); chmodSync(file, mode); // umask-proof (row 243)
|
||||
return { flags: [...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], value };
|
||||
}),
|
||||
];
|
||||
@@ -62,8 +64,7 @@ test('serve argv carries only fixture proof path and header; startup explicitly
|
||||
test('CLI applies proof on the main listener and rejects that header on its tester listener', async t => {
|
||||
const f = await testerFixture(t), file = join(f.root, 'proof.fixture');
|
||||
writeFileSync(file, VALUE, { mode: 0o600 });
|
||||
const free = createServer(); free.listen(0, '127.0.0.1'); await once(free, 'listening');
|
||||
const testerPort = free.address().port; await new Promise(r => free.close(r));
|
||||
const testerPort = await leasePort({after:laneAfter}, {closed:true});
|
||||
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--tester-port', String(testerPort), '--business', 'acme', ...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], { env: f.f.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let output = ''; child.stdout.on('data', c => output += c); child.stderr.resume();
|
||||
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
import { after as laneAfter } from 'node:test';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer, get } from 'node:http';
|
||||
@@ -97,8 +99,7 @@ test('proxy preserves exact request bytes, status and receipt, rejects forms and
|
||||
});
|
||||
|
||||
test('unreachable board reports URL; CLI rejects unsupported options', async () => {
|
||||
const probe = createServer(); await new Promise(r => probe.listen(0, '127.0.0.1', r));
|
||||
const url = `http://127.0.0.1:${probe.address().port}`; await close(probe);
|
||||
const url = `http://127.0.0.1:${await leasePort({after:laneAfter}, {closed:true})}`;
|
||||
const web = await startServer({ port: 0, board: url, timeout: 100 });
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${web.address().port}/api/board`);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
import { after as laneAfter } from 'node:test';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
@@ -45,15 +47,7 @@ function filled(flags, root = REPO) {
|
||||
|
||||
// A loopback port with nothing listening, so status's /healthz request
|
||||
// reaches no real Console.
|
||||
async function closedPort() {
|
||||
const server = createServer();
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const { port } = server.address();
|
||||
server.close();
|
||||
await once(server, 'close');
|
||||
return port;
|
||||
}
|
||||
async function closedPort() { return leasePort({after:laneAfter}, {closed:true}); }
|
||||
|
||||
function tmp(t) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'webui-service-'));
|
||||
|
||||
@@ -358,6 +358,7 @@ test('11: the CLI refuses --sso with the old flags and without console.sso, and
|
||||
assert.match(noKey.stderr, /^refused: --sso needs console\.sso in the system config$/m);
|
||||
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify({ ...base, console: { sso: { publicOrigin: ORIGIN, providers: [providerFor(a)] } } }));
|
||||
writeFileSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), `${a.clientSecret}\n`, { mode: 0o644 });
|
||||
chmodSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), 0o644); // writeFileSync applies the umask (row 243)
|
||||
const wide = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
|
||||
assert.equal(wide.status, 2);
|
||||
assert.match(wide.stderr, /^refused: console\.sso provider a client secret slot must have no group or other permission bits$/m);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
import { after as laneAfter } from 'node:test';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:net';
|
||||
@@ -15,10 +17,7 @@ const get = (port, path) => new Promise((resolve, reject) => {
|
||||
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text }));
|
||||
}); q.on('error', reject); q.end();
|
||||
});
|
||||
async function freePort() {
|
||||
const s = createServer(); s.listen(0, '127.0.0.1'); await once(s, 'listening');
|
||||
const port = s.address().port; await new Promise(r => s.close(r)); return port;
|
||||
}
|
||||
async function freePort() { return leasePort({after:laneAfter}, {closed:true}); }
|
||||
|
||||
test('tester-port validates range tunnel main port and requires configured bus before listening', t => {
|
||||
const root = tmp(t, 'tester-cli-invalid-'), f = fixture(root);
|
||||
|
||||
+26
-4
@@ -7,9 +7,13 @@
|
||||
# Root runs it on the Case 1 host after the install window, from a root-owned checkout. It reads the
|
||||
# user, home, copy, node, claude and both ports from the args file, which must pass stack-upgrade.sh's
|
||||
# rules and be a factory file for --business. Every command that runs the stack's code runs as
|
||||
# `sudo -u USER -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin` from /. Root itself only reads
|
||||
# systemd, ss, /proc, the account database and file metadata. It changes nothing on the host but the
|
||||
# message F3 sends and its reply, and it never opens claude-session.env.
|
||||
# `sudo -u USER -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin` from /. Root itself reads ss,
|
||||
# /proc, the account database and file metadata, and runs these: systemctl show and is-enabled (F1,
|
||||
# P11), curl on the Console's loopback healthz (F2), sudo -l (P1, P3), sudo -u jarvis bwrap --version
|
||||
# and hostcheck.sh --check (P9), and node to parse managed-settings.json (F9). F9 runs node only when
|
||||
# --node is the pinned path and it and every directory above it are root's with no group or other
|
||||
# write. The script changes nothing on the host but the message F3 sends and its reply, and it never
|
||||
# opens claude-session.env.
|
||||
#
|
||||
# One line per check, `<id> <path> <mark> <evidence>`, then `count <mark> N` for each mark and a
|
||||
# result line. F checks are `pass` or `FAIL`. P checks are `closed`, `accepted <ruling>` or
|
||||
@@ -259,6 +263,7 @@ check_f4() {
|
||||
check_f6() {
|
||||
local want="/opt/mosaic-stack/claude-$CLAUDE_PIN/" v
|
||||
[[ "$CLAUDE" == "$want"* ]] || { ffail F6 claude-pin "--claude $CLAUDE is not under $want"; return; }
|
||||
[[ "$CLAUDE/" != */../* ]] || { ffail F6 claude-pin "--claude $CLAUDE has a .. segment"; return; }
|
||||
v="$("${AS_USER[@]}" "$CLAUDE" --version 2>/dev/null | sed -n 1p)"
|
||||
if [ "$v" = "$CLAUDE_PIN (Claude Code)" ]; then fpass F6 claude-pin "$CLAUDE prints '$v'"
|
||||
else ffail F6 claude-pin "$CLAUDE --version prints '$(clean "$v")', not '$CLAUDE_PIN (Claude Code)'"; fi
|
||||
@@ -291,10 +296,27 @@ check_f8() {
|
||||
else ffail F8 credential-file "$CRED is '$s', not '$S_USER 600 regular file'"; fi
|
||||
}
|
||||
|
||||
# Empty when $1 and every directory above it, up to /, are the running user's (root's on the host) with
|
||||
# no group or other write: case2-unit.sh's root_only rule for --node, checked again before root runs it.
|
||||
# Otherwise the first path that breaks the rule. A symlink breaks it, because its mode is 777.
|
||||
not_root_only() {
|
||||
local p="$1" st
|
||||
while :; do
|
||||
st="$(stat -c '%u %a' -- "$ROOT$p" 2>/dev/null)" || { echo "$p (cannot stat)"; return; }
|
||||
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) || { echo "$p (uid ${st% *}, mode ${st#* })"; return; }
|
||||
[ "$p" = / ] && return
|
||||
p="$(dirname -- "$p")"
|
||||
done
|
||||
}
|
||||
|
||||
check_f9() {
|
||||
local s v
|
||||
local s v bad
|
||||
s="$(stat -c '%U:%G %a %F' -- "$ROOT$SETTINGS" 2>/dev/null)" || s="missing"
|
||||
[ ! -L "$ROOT$SETTINGS" ] && [ "$s" = "root:root 644 regular file" ] || { ffail F9 autoupdater-off "$SETTINGS is '$s', not 'root:root 644 regular file'"; return; }
|
||||
# Root runs node here, so it runs only the pinned path, and only when nothing but root can replace it.
|
||||
[ "$NODE" = "/opt/mosaic-stack/node-$NODE_PIN/bin/node" ] || { ffail F9 autoupdater-off "root won't run --node $NODE: it is not the --node-pin path"; return; }
|
||||
bad="$(not_root_only "$NODE")"
|
||||
[ -z "$bad" ] || { ffail F9 autoupdater-off "root won't run --node $NODE: $bad is not root's alone"; return; }
|
||||
v="$("$ROOT$NODE" -e 'const s=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(JSON.stringify(s?.env?.DISABLE_AUTOUPDATER??null))' -- "$ROOT$SETTINGS" 2>/dev/null)" || v="unreadable"
|
||||
if [ "$v" = '"1"' ]; then fpass F9 autoupdater-off "$SETTINGS is root:root 0644 with env.DISABLE_AUTOUPDATER \"1\""
|
||||
else ffail F9 autoupdater-off "$SETTINGS env.DISABLE_AUTOUPDATER is $v, not \"1\""; fi
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
// staged file against the manifest and C2-08's fence, and writes
|
||||
// <configDir>/personas/<business>/<instance>.md at 0600. It never overwrites.
|
||||
//
|
||||
// Output is names, byte counts and sha256, never persona or prompt text. --dry-run writes nothing
|
||||
// and prints counts only: files, lines, lines starting "# " or "## ", and lines naming a Hermes tool
|
||||
// or channel from HERMES_WORDS. A fence fault there is counted, not refused.
|
||||
// Output is names, byte counts and sha256, never persona or prompt text. A key from config.yaml
|
||||
// prints as a JSON string, so it can't start a line of its own. --dry-run writes nothing and prints
|
||||
// counts only: files, lines, lines starting "# " or "## ", and lines naming a Hermes tool or channel
|
||||
// from HERMES_WORDS. It applies C2-08's fence as stage does, prints each persona's fault by its
|
||||
// reason and then refuses. A SOUL.md over four times the fence in bytes refuses before any count.
|
||||
//
|
||||
// Exit codes: 0 ok, 1 refused, 2 usage.
|
||||
|
||||
@@ -184,7 +186,12 @@ function privateDir(path, { create }) {
|
||||
const s = lstatSync(path, { throwIfNoEntry: false });
|
||||
if (!s) {
|
||||
if (!create) throw new Refusal(`${path} doesn't exist`);
|
||||
mkdirSync(path, { mode: 0o700 });
|
||||
try {
|
||||
mkdirSync(path, { mode: 0o700 });
|
||||
} catch (e) {
|
||||
if (e.code === "EEXIST") throw new Refusal(`${path} was made by something else between the check and mkdir`);
|
||||
throw e;
|
||||
}
|
||||
chmodSync(path, 0o700);
|
||||
return;
|
||||
}
|
||||
@@ -221,10 +228,14 @@ function stage(o, say) {
|
||||
|
||||
if (o.dryRun) {
|
||||
say(`files ${personas.length}`);
|
||||
const faulted = [];
|
||||
for (const p of personas) {
|
||||
const c = counts(p.text);
|
||||
say(`profile ${p.profile}: lines ${c.lines}, lines starting "# " or "## " ${c.headings}, lines naming a Hermes tool or channel ${c.words}`);
|
||||
const fault = personaTextFault(p.text);
|
||||
if (fault) { say(`profile ${p.profile}: persona ${fault}, so C2-08's fence would refuse it`); faulted.push(p.profile); }
|
||||
}
|
||||
if (faulted.length) throw new Refusal(`C2-08's fence would refuse the persona of profile ${faulted.join(" and ")}, so stage would write nothing`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -234,7 +245,7 @@ function stage(o, say) {
|
||||
for (const p of pairs) {
|
||||
const prompts = config(p);
|
||||
if (!prompts.length) say(`profile ${p.profile}: no per-channel prompts`);
|
||||
for (const c of prompts) say(`profile ${p.profile}: per-channel prompt ${c.where} ${c.channel}, ${c.bytes === null ? "not text" : `${c.bytes} bytes`}`);
|
||||
for (const c of prompts) say(`profile ${p.profile}: per-channel prompt ${JSON.stringify(c.where)} ${JSON.stringify(c.channel)}, ${c.bytes === null ? "not text" : `${c.bytes} bytes`}`);
|
||||
}
|
||||
for (const p of personas) {
|
||||
const fault = personaTextFault(p.text);
|
||||
|
||||
+10
-1
@@ -71,6 +71,8 @@ EOF_PROBE
|
||||
mkdir -p "$SANDBOX/toolroot/plans"
|
||||
echo "row one" >"$SANDBOX/toolroot/plans/QUEUE.md"
|
||||
TOOLS_JSON="{\"roots\":[{\"name\":\"docs\",\"path\":\"$SANDBOX/toolroot\"}],\"maxFileBytes\":4096,\"maxCallsPerTurn\":8}"
|
||||
export PI_CODING_AGENT_DIR="$SANDBOX/pi-agent"
|
||||
mkdir -p "$PI_CODING_AGENT_DIR"
|
||||
PI_COMMON="--mode rpc --no-extensions --no-context-files --no-skills --no-prompt-templates --no-themes --offline --no-session --provider zai --model glm-5.3"
|
||||
if [ -x "$PI_BIN" ]; then
|
||||
printf '{"type":"get_state","id":"a"}\n' | MOSAIC_DISCORD_TOOLS="$TOOLS_JSON" timeout 60 "$PI_BIN" $PI_COMMON --no-builtin-tools \
|
||||
@@ -142,7 +144,14 @@ fi
|
||||
# A nested `node --test` inherits a parent runner's NODE_TEST_CONTEXT, reports
|
||||
# to that runner and exits 0 whatever its tests do, so the suite clears it
|
||||
# (#1508 N13). The planted failing test proves a failure still fails here.
|
||||
node_tests() { env -u NODE_TEST_CONTEXT node --test "$@"; }
|
||||
node_tests() {
|
||||
local lane_concurrency=()
|
||||
if [ -n "${MOSAIC_TEST_CONCURRENCY:-}" ]; then
|
||||
[ "$MOSAIC_TEST_CONCURRENCY" = 2 ] || return 2
|
||||
lane_concurrency=(--test-concurrency=2)
|
||||
fi
|
||||
env -u NODE_TEST_CONTEXT node --test "${lane_concurrency[@]}" "$@"
|
||||
}
|
||||
mkdir -p "$SANDBOX/planted"
|
||||
printf '%s\n' 'import { test } from "node:test";' 'import assert from "node:assert/strict";' 'test("planted failure", () => assert.equal(1, 2));' >"$SANDBOX/planted/planted.test.mjs"
|
||||
NODE_TEST_CONTEXT=child-v8 node_tests "$SANDBOX/planted/" >"$SANDBOX/planted.log" 2>&1
|
||||
|
||||
@@ -76,7 +76,14 @@ check "checked-in demo bundles equal a fresh generation" $DEMO_OK
|
||||
# A nested `node --test` inherits a parent runner's NODE_TEST_CONTEXT, reports
|
||||
# to that runner and exits 0 whatever its tests do, so the suite clears it
|
||||
# (#1508 N13). The planted failing test proves a failure still fails here.
|
||||
node_tests() { env -u NODE_TEST_CONTEXT node --test "$@"; }
|
||||
node_tests() {
|
||||
local lane_concurrency=()
|
||||
if [ -n "${MOSAIC_TEST_CONCURRENCY:-}" ]; then
|
||||
[ "$MOSAIC_TEST_CONCURRENCY" = 2 ] || return 2
|
||||
lane_concurrency=(--test-concurrency=2)
|
||||
fi
|
||||
env -u NODE_TEST_CONTEXT node --test "${lane_concurrency[@]}" "$@"
|
||||
}
|
||||
mkdir -p "$SANDBOX/planted"
|
||||
printf '%s\n' 'import { test } from "node:test";' 'import assert from "node:assert/strict";' 'test("planted failure", () => assert.equal(1, 2));' >"$SANDBOX/planted/planted.test.mjs"
|
||||
NODE_TEST_CONTEXT=child-v8 node_tests "$SANDBOX/planted/" >"$SANDBOX/planted.log" 2>&1
|
||||
|
||||
@@ -47,7 +47,12 @@ node -e 'const p=require("./packages/queue/package.json"); process.exit(p.depend
|
||||
check "packages/queue declares no dependencies" $?
|
||||
|
||||
# --- the package tests ---
|
||||
env -u NODE_TEST_CONTEXT node --test --test-reporter=spec packages/queue/tests/ > "$SANDBOX/tests.log" 2>&1
|
||||
lane_concurrency=()
|
||||
if [ -n "${MOSAIC_TEST_CONCURRENCY:-}" ]; then
|
||||
[ "$MOSAIC_TEST_CONCURRENCY" = 2 ] || exit 2
|
||||
lane_concurrency=(--test-concurrency=2)
|
||||
fi
|
||||
env -u NODE_TEST_CONTEXT node --test "${lane_concurrency[@]}" --test-reporter=spec packages/queue/tests/ > "$SANDBOX/tests.log" 2>&1
|
||||
rc=$?
|
||||
grep -E '^ℹ (tests|pass|fail) ' "$SANDBOX/tests.log"
|
||||
if [ "$rc" != 0 ]; then grep -E '^✖|Error' "$SANDBOX/tests.log" | head -40; fi
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Gate lanes
|
||||
|
||||
This tooling is for Sage's granted test gates on dragon-lin. It does not grant
|
||||
itself a slot. A grant reference is an audit field, not an authentication token.
|
||||
Until Sage adopts these lanes, the existing shared-slot procedure still applies.
|
||||
|
||||
Ask Sage with the lane, candidate SHA and exact command. Get a separate grant
|
||||
for image preparation, suites, and the whole-slot paired proof. New state is
|
||||
`/mnt/storage/scratch/mosaic-gate-lanes`. Old slot files are untouched.
|
||||
|
||||
Invoke the entry from the builder or canonical repository. The --checkout target
|
||||
must be a separate clean scratch clone containing identical tooling source:
|
||||
|
||||
```bash
|
||||
scripts/testing/gate-lane.sh run --lane a --candidate FULL_SHA \
|
||||
--checkout /mnt/storage/scratch/ROW-RUN/wt \
|
||||
--out /mnt/storage/scratch/ROW-RUN/out --grant SAGE_MESSAGE_ID \
|
||||
--image-receipt /mnt/storage/scratch/PREP/image-receipt.json
|
||||
```
|
||||
|
||||
Each run creates a private root like `/mnt/storage/scratch/ga-ABCDEF` (30 bytes).
|
||||
An explicit `--temp-root` must already be private, outside `/tmp`, and no longer
|
||||
than 30 UTF-8 bytes after resolving symlinks. The sandbox's own `/tmp` and HOME
|
||||
are unchanged. Host HOME stays real. A listener covering loopback port 9, an
|
||||
unsafe privileged-port threshold or unreadable host inventory refuses startup.
|
||||
|
||||
Lane a leases ports 61000..61127, lane b 61128..61255. Refuse any band overlapping
|
||||
the host's current ephemeral range or a live listener. Port-0 fixtures with a
|
||||
held server keep using port 0. Never replace the literal dead Board :9 URLs.
|
||||
Two Node test files run per lane, including nested shell-suite Node runners.
|
||||
|
||||
```bash
|
||||
scripts/testing/gate-lane.sh status
|
||||
scripts/testing/gate-lane.sh recover --run-id RUN_NONCE --grant SAGE_MESSAGE_ID
|
||||
```
|
||||
|
||||
Status reports each lane, the image lock, unfinished receipts and unmanaged
|
||||
test/landing/build processes as LEGACY BUSY. It prints no command lines or
|
||||
environment values. A dead owner with an unfinished receipt is quarantined even
|
||||
when flock is free. Recovery takes both lanes and the exclusive image lock.
|
||||
Unknown process-group, scope or Docker ownership refuses recovery. Do not remove
|
||||
lock files, old receipts, or another run's resources to clear a refusal.
|
||||
|
||||
An image receipt records exact COPY input hashes/modes, image ID, release tag,
|
||||
base digest and platform. Both concurrent candidates must match those files and
|
||||
that same image ID. Rebuilding identical files is not equivalent. Warm gates
|
||||
refuse implicit Docker builds/pulls and recheck the ID at each step and the end.
|
||||
External same-uid commands can ignore these locks. Sage must exclude other
|
||||
builders; this is test coordination, not a security boundary against that uid.
|
||||
|
||||
Only under an explicit image-build grant:
|
||||
|
||||
```bash
|
||||
scripts/testing/gate-lane.sh prepare-image --lane a --candidate FULL_SHA \
|
||||
--checkout /mnt/storage/scratch/ROW-PREP/wt \
|
||||
--out /mnt/storage/scratch/ROW-PREP/out --grant SAGE_MESSAGE_ID
|
||||
```
|
||||
|
||||
Preparation holds both lanes and the exclusive image lock. Its build log,
|
||||
receipt, count and wall time are separate from warm gate timings. The current
|
||||
Containerfile uses a mutable FROM tag and apt indexes. A missing base digest
|
||||
refuses provenance publication. No credential file is copied or printed.
|
||||
|
||||
Run records are immutable JSON files published by link after fsync. Every step
|
||||
has a log, UTC/monotonic times, exit code, pass/fail/skip counts and log hash.
|
||||
The first failed step stops the gate. Cleanup touches only exact project labels,
|
||||
recorded process identities and fixture paths. A process leak fails the gate
|
||||
even when cleanup succeeds. Failed logs and temp roots remain for diagnosis.
|
||||
A cleanup failure leaves the lane quarantined. GATE_PASSED requires all steps
|
||||
and cleanup. Required prerequisite skips fail the run; optional skips remain
|
||||
visible and require acceptance review.
|
||||
|
||||
Home metadata is evidence only. Assert only on entries attributable to this
|
||||
run's nonce or fixture paths. Other sessions normally change ~/.claude, ~/.pi
|
||||
and ~/.cache, so there is no global unchanged-home assertion.
|
||||
|
||||
The row 197 acceptance packet is `agents/herb/work/queue-197/`: two candidate
|
||||
solo baselines followed by three overlapping pairs, lane assignment swapped in
|
||||
pair two. Keep later paired results after adoption. Six green gates establish
|
||||
the initial acceptance bar, not a reliable estimate of rare flakes.
|
||||
|
||||
The preflight unit fixture uses the canonical checkout's existing node_modules
|
||||
at `/mnt/storage/src/mosaic-stack/node_modules` through the required plain
|
||||
symlink. It does not install dependencies. Unit run roots use the `gt-` prefix;
|
||||
real lane roots use `ga-` or `gb-`. Resource helper tests inside a lane lease only
|
||||
that lane's band through MOSAIC_GATE_STATE, including their child test files.
|
||||
The exhaustion probe temporarily narrows its process-local band to a port it
|
||||
already leased, so it never reserves the whole shared band.
|
||||
|
||||
`closed` call-site options are accepted for compatibility; every dynamic lease
|
||||
reserves an unbound endpoint. Listener fixtures decide when to bind and close.
|
||||
Fixture child caches put TMPDIR under `tmp/` next to `jiti/` and `cache/`.
|
||||
|
||||
Outside a lane, the resource helper file uses lane a with the shared default
|
||||
state `/mnt/storage/scratch/mosaic-gate-lanes`. It leaves stable lock files in
|
||||
place. A single-file run therefore respects a live lane-a gate's closed leases.
|
||||
Its unrelated-session probe bounds both polling children at five seconds.
|
||||
@@ -0,0 +1,227 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { realpathSync, existsSync, statSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { shortRoot, checkHost, hostFacts, refuse, within, SCRATCH, identity } from './lane-state.mjs';
|
||||
const BUILDER=resolve(dirname(fileURLToPath(import.meta.url)),'../..');
|
||||
const git=(root,...args)=>execFileSync('git',['-C',root,...args],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
|
||||
export function validateRun(o){
|
||||
if(!['a','b'].includes(o.lane))refuse('invalid lane');
|
||||
if(typeof o.grant!=='string'||!o.grant.trim())refuse('missing grant reference');
|
||||
for(const key of ['checkout','out','stateRoot'])if(typeof o[key]!=='string'||!o[key])refuse(`missing ${key}`);
|
||||
const tempRoot=shortRoot(o.tempRoot);
|
||||
const checkout=realpathSync(o.checkout);
|
||||
if(checkout===BUILDER||checkout==='/mnt/storage/src/mosaic-stack'||!within(SCRATCH,checkout))refuse('canonical or builder checkout');
|
||||
if(!/^[a-f0-9]{40}$/.test(o.candidate)||git(checkout,'rev-parse','HEAD')!==o.candidate)refuse('wrong candidate SHA');
|
||||
const changed=git(checkout,'status','--porcelain','--untracked-files=normal').split('\n').filter(x=>x&&x!=='?? node_modules');
|
||||
if(changed.length)refuse('dirty checkout');
|
||||
if(!existsSync(join(checkout,'node_modules'))||!statSync(join(checkout,'node_modules')).isDirectory())refuse('missing node_modules');
|
||||
const out=resolve(o.out);if(existsSync(out))refuse('output already exists');
|
||||
if(!within(dirname(checkout),out)||!within(SCRATCH,realpathSync(dirname(out))))refuse('output escapes run scratch');
|
||||
const stateRoot=resolve(o.stateRoot);if(!within(SCRATCH,stateRoot))refuse('state outside scratch');
|
||||
checkHost(o.lane,o.hostFacts??hostFacts());
|
||||
return {...o,tempRoot,checkout,out,stateRoot};
|
||||
}
|
||||
export function validateTooling(checkout){
|
||||
for(const name of readdirSync(join(BUILDER,'scripts/testing')).filter(n=>/\.(mjs|sh)$/.test(n))){
|
||||
const candidate=join(checkout,'scripts/testing',name),launcher=join(BUILDER,'scripts/testing',name);
|
||||
if(!existsSync(candidate)||!readFileSync(candidate).equals(readFileSync(launcher)))refuse('candidate tooling differs from launcher');
|
||||
}
|
||||
}
|
||||
export function discoverSteps(checkout,concurrency=2){
|
||||
if(concurrency!==2)refuse('test concurrency must be 2');
|
||||
const steps=[];
|
||||
for(const p of readdirSync(join(checkout,'packages')).sort())if(p!=='queue'&&existsSync(join(checkout,'packages',p,'tests'))&&statSync(join(checkout,'packages',p,'tests')).isDirectory())steps.push({name:`pkg-${p}`,cwd:join(checkout,'packages',p),argv:['node','--test','--test-concurrency=2',...readdirSync(join(checkout,'packages',p,'tests')).filter(n=>n.endsWith('.test.mjs')).sort().map(n=>`tests/${n}`)]});
|
||||
for(const f of readdirSync(join(checkout,'scripts')).sort())if(/^test-.+\.sh$/.test(f)&&statSync(join(checkout,'scripts',f)).isFile())steps.push({name:`script-${f.slice(5,-3)}`,cwd:checkout,argv:['bash',`scripts/${f}`]});
|
||||
return steps;
|
||||
}
|
||||
|
||||
export async function executeSteps(steps,{out,env,record,onChild=()=>{},signal}){
|
||||
const {openSync,closeSync}=await import('node:fs');const {spawn}=await import('node:child_process');
|
||||
for(const step of steps){
|
||||
if(signal?.aborted)return {rc:130};
|
||||
const begin=performance.now(),startMs=Date.now();const fd=openSync(join(out,`${step.name}.log`),'wx',0o600);
|
||||
// Keep the supervisor private while matching the legacy test environment.
|
||||
const child=spawn('/bin/bash',['-c','umask 022; exec "$@"','gate-step',...step.argv],{cwd:step.cwd,env,detached:true,stdio:['ignore',fd,fd]});closeSync(fd);
|
||||
onChild(child);record({kind:'child',group:child.pid,owner:identity(child.pid)});
|
||||
let escalation;
|
||||
const abort=()=>{try{process.kill(-child.pid,'SIGTERM');}catch{};escalation=setTimeout(()=>{try{process.kill(-child.pid,'SIGKILL');}catch{}},3000);escalation.unref();};
|
||||
signal?.addEventListener('abort',abort,{once:true});
|
||||
const rc=await new Promise(resolve=>{child.once('error',()=>resolve(127));child.once('close',(code,sig)=>resolve(signal?.aborted?130:(code??(sig?128:1))));});
|
||||
clearTimeout(escalation);signal?.removeEventListener('abort',abort);onChild(null);
|
||||
const bytes=readFileSync(join(out,`${step.name}.log`)),text=bytes.toString('utf8');
|
||||
const counts={};for(const name of ['pass','fail','skipped']){const m=text.match(new RegExp('^(?:ℹ |# )'+name+' (\\d+)','m'));if(m)counts[name]=Number(m[1]);}
|
||||
if(counts.pass===undefined){const summaries=[...text.matchAll(/(\d+) passed, (\d+) failed/g)];const last=summaries.at(-1);if(last){counts.pass=Number(last[1]);counts.fail=Number(last[2]);}}
|
||||
const requiredSkips=text.split('\n').filter(line=>/^skip\b/i.test(line)&&/(docker|daemon|sandbox|systemd|credential)/i.test(line));
|
||||
record({kind:'step',name:step.name,startMs,endMs:Date.now(),wallMs:performance.now()-begin,rc,counts,requiredSkips,logSha256:createHash('sha256').update(bytes).digest('hex')});
|
||||
if(rc!==0||requiredSkips.length)return {rc:rc||2};
|
||||
}
|
||||
return {rc:0};
|
||||
}
|
||||
|
||||
export async function executePinnedSteps(steps,options,{receipt,checkout,inspect,afterStep=()=>{}}){
|
||||
for(const step of steps){
|
||||
verifyImage(receipt,checkout,inspect);
|
||||
const result=await executeSteps([step],options);afterStep(step);
|
||||
if(result.rc){verifyImage(receipt,checkout,inspect);return {...result,imageBuilds:0};}
|
||||
}
|
||||
verifyImage(receipt,checkout,inspect);return {rc:0,imageBuilds:0};
|
||||
}
|
||||
|
||||
import { mkdirSync, lstatSync, fstatSync, mkdtempSync, writeFileSync, readFileSync, openSync, readlinkSync, rmdirSync } from 'node:fs';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { status, appendRecord, runRecords, processInventory, sameProcess, nonce } from './lane-state.mjs';
|
||||
import { verifyImage, imageFingerprint, inspectImage, prepareImage } from './lane-image.mjs';
|
||||
import { cleanupProject } from './lane-docker.mjs';
|
||||
import { homeMetadata, attributeEntries, socketInventory } from './lane-evidence.mjs';
|
||||
function options(argv){
|
||||
const result={verb:'run',stateRoot:'/mnt/storage/scratch/mosaic-gate-lanes',lane:'a',exclusive:false};
|
||||
const names={'--state-root':'stateRoot','--lane':'lane','--candidate':'candidate','--checkout':'checkout','--out':'out','--temp-root':'tempRoot','--grant':'grant','--image-receipt':'imageReceipt','--run-id':'runId'};
|
||||
const args=[...argv];if(args[0]&&!args[0].startsWith('--'))result.verb=args.shift();
|
||||
if(!['run','status','recover','prepare-image','lock-dir'].includes(result.verb))refuse('unknown verb');
|
||||
while(args.length){const key=args.shift();if(key==='--exclusive'){result.exclusive=true;continue;}if(!names[key]||!args.length)refuse('invalid or missing option');result[names[key]]=args.shift();}
|
||||
return result;
|
||||
}
|
||||
function stateDirectory(path,create=false){
|
||||
const root=resolve(path);if(!within(SCRATCH,root)||root===SCRATCH)refuse('state must be private scratch');
|
||||
if(create){const parent=realpathSync(dirname(root));if(!within(SCRATCH,parent))refuse('state parent escapes scratch');mkdirSync(root,{recursive:true,mode:0o700});}
|
||||
if(existsSync(root)){const st=lstatSync(root);if(st.isSymbolicLink()||!st.isDirectory()||st.uid!==process.getuid()||(st.mode&0o077))refuse('state is not a private owned directory');
|
||||
for(const n of ['a.lock','b.lock','image.lock'])if(existsSync(join(root,n))&&!lstatSync(join(root,n)).isFile())refuse('lock is not an ordinary file');
|
||||
}
|
||||
return root;
|
||||
}
|
||||
export function verifyLocks(o){
|
||||
for(const [fd,name] of o.exclusive?[[3,'a.lock'],[4,'b.lock'],[5,'image.lock']]:[[3,`${o.lane}.lock`],[5,'image.lock']]){
|
||||
let opened,path;try{opened=fstatSync(fd);path=lstatSync(join(o.stateRoot,name));}catch{refuse('lock descriptors missing');}
|
||||
if(!opened.isFile()||!path.isFile()||opened.ino!==path.ino||opened.dev!==path.dev||path.uid!==process.getuid())refuse('lock file replaced');
|
||||
// A separate open must fail while our shared/exclusive lock is held.
|
||||
const r=spawnSync('flock',['-n','-x',join(o.stateRoot,name),'true']);if(r.status!==1)refuse('lock descriptor is not locked');
|
||||
}
|
||||
}
|
||||
export function activeConflicts(o,observations={}){
|
||||
const current=status(o.stateRoot,{...observations,ignoreLocks:o.exclusive?['a','b','image']:[o.lane,'image']});
|
||||
if(current.legacy.length)refuse('LEGACY BUSY: unmanaged gate, test or Docker process');
|
||||
for(const lane of o.exclusive?['a','b']:[o.lane])if(current[lane].state!=='CLEAR')refuse(`${lane} ${current[lane].state}`);
|
||||
for(const r of runRecords(o.stateRoot)){const start=r.records.find(e=>e.kind==='start');if(!r.records.some(e=>['terminal','recovered'].includes(e.kind)&&e.clean)&&start?.checkout===o.checkout)refuse('checkout already owned');}
|
||||
}
|
||||
export function cleanEnv(o,run,docker){
|
||||
const kept=['PATH','HOME','USER','LOGNAME','SHELL','LANG','LC_ALL','LC_CTYPE','TZ','TERM','XDG_RUNTIME_DIR','DBUS_SESSION_BUS_ADDRESS','DOCKER_HOST','CHROMIUM'];
|
||||
const env=Object.fromEntries(kept.filter(k=>typeof process.env[k]==='string').map(k=>[k,process.env[k]]));
|
||||
Object.assign(env,{TMPDIR:o.tempRoot,TMP:o.tempRoot,TEMP:o.tempRoot,MOSAIC_GATE_LANE:o.lane,MOSAIC_GATE_STATE:o.stateRoot,MOSAIC_TEST_CONCURRENCY:'2',COMPOSE_PROJECT_NAME:run.project,JITI_FS_CACHE:join(o.tempRoot,'jiti'),XDG_CACHE_HOME:join(o.tempRoot,'cache'),MOSAIC_CONFIG:join(o.tempRoot,'missing-config.json'),MOSAIC_GATE_DOCKER_RECORD:join(o.out,'docker.json')});
|
||||
env.PATH=`${join(o.out,'bin')}:${env.PATH??'/usr/bin:/bin'}`;
|
||||
return env;
|
||||
}
|
||||
function fixtureProcesses(run){
|
||||
const all=processInventory(),ancestors=new Set([process.pid]);let parent=process.pid;
|
||||
while(parent){const p=all.find(p=>p.pid===parent);if(!p||ancestors.has(p.ppid))break;ancestors.add(p.ppid);parent=p.ppid;}
|
||||
return all.filter(p=>{
|
||||
if(ancestors.has(p.pid)||p.state==='Z')return false;
|
||||
let cwd;try{cwd=readlinkSync(`/proc/${p.pid}/cwd`);}catch{}
|
||||
return p.argv.some(a=>typeof a==='string'&&(a===run.tempRoot||a.startsWith(run.tempRoot+'/')))||cwd&&(within(run.tempRoot,cwd)||run.checkout&&within(run.checkout,cwd));
|
||||
});
|
||||
}
|
||||
async function cleanProcesses(run,record){
|
||||
const before=fixtureProcesses(run);
|
||||
if(run.stateRoot&&run.id){
|
||||
const procs=processInventory(),known=new Set(before.map(p=>p.pid));
|
||||
const {records}=await import('./lane-state.mjs');
|
||||
for(const child of records(join(run.stateRoot,'runs',run.id)).filter(e=>e.kind==='child')){
|
||||
const members=procs.filter(p=>p.group===child.group&&p.state!=='Z');
|
||||
if(members.length&&sameProcess(child.owner,procs.find(p=>p.pid===child.owner?.pid))){
|
||||
try{process.kill(-child.group,'SIGKILL');}catch(e){if(e.code!=='ESRCH')throw e;}
|
||||
}else if(members.some(p=>!known.has(p.pid)))refuse('unproven orphan process-group ownership');
|
||||
}
|
||||
}
|
||||
record({kind:'leftover-processes',objects:before.map(({pid,start,boot})=>({pid,start,boot}))});
|
||||
for(const p of before){if(!sameProcess(p))continue;let raw;try{raw=readFileSync(`/proc/${p.pid}/cgroup`,'utf8');}catch(e){if(e.code==='ENOENT')continue;throw e;}const scope=raw.trim().split('/').find(x=>/^mosaic-chat-[a-z0-9]+\.scope$/.test(x));
|
||||
if(scope){const shown=execFileSync('systemctl',['--user','show',scope,'-p','InvocationID','--value'],{encoding:'utf8'}).trim();if(!/^[a-f0-9]{32}$/.test(shown))refuse('scope identity unknown');record({kind:'scope-cleanup',unit:scope,invocation:shown});execFileSync('systemctl',['--user','kill','--signal=SIGKILL',scope],{stdio:'pipe'});}
|
||||
if(sameProcess(p))try{process.kill(p.pid,'SIGKILL');}catch(e){if(e.code!=='ESRCH')throw e;}
|
||||
}
|
||||
for(let n=0;n<30;n++){if(!fixtureProcesses(run).some(p=>p.state!=='Z'))return before.length===0;await new Promise(r=>setTimeout(r,100));}
|
||||
refuse('owned fixture process remains');
|
||||
}
|
||||
async function recoverRun(o){
|
||||
if(!o.grant)refuse('missing recovery grant');
|
||||
if(!/^[a-f0-9]{12}$/.test(o.runId??''))refuse('invalid run ID');
|
||||
const r=runRecords(o.stateRoot).find(r=>r.id===o.runId);if(!r)refuse('run not found');
|
||||
const start=r.records.find(x=>x.kind==='start');if(start?.mode==='prepare-image'&&status(o.stateRoot).legacy.length)refuse('image builder still active or unknown');if(!start||sameProcess(start.owner))refuse('run owner live or unknown');
|
||||
if(r.records.some(x=>['terminal','recovered'].includes(x.kind)&&x.clean))refuse('run already clean');
|
||||
shortRoot(start.tempRoot);const record=e=>appendRecord(r.dir,e);
|
||||
const procs=processInventory(),known=new Set(fixtureProcesses(start).map(p=>p.pid));
|
||||
for(const event of r.records.filter(e=>e.kind==='child')){
|
||||
const members=procs.filter(p=>p.group===event.group&&p.state!=='Z');
|
||||
if(members.length&&!sameProcess(event.owner,procs.find(p=>p.pid===event.owner?.pid))&&members.some(p=>!known.has(p.pid)))refuse('orphan process-group ownership unknown');
|
||||
if(members.length&&sameProcess(event.owner)){process.kill(-event.group,'SIGKILL');}
|
||||
}
|
||||
await cleanProcesses(start,record);cleanupProject(start,start.docker);
|
||||
appendRecord(r.dir,{kind:'recovered',clean:true,grant:o.grant,owner:identity()});return {recovered:r.id};
|
||||
}
|
||||
async function runGate(input){
|
||||
for(const key of ['checkout','out','candidate','grant','imageReceipt'])if(!input[key])refuse(`missing ${key}`);
|
||||
const automatic=!input.tempRoot,tempRoot=input.tempRoot??mkdtempSync(`/mnt/storage/scratch/g${input.lane}-`);
|
||||
let o,receipt,docker;
|
||||
try{
|
||||
o=validateRun({...input,tempRoot});
|
||||
validateTooling(o.checkout);
|
||||
activeConflicts(o);
|
||||
receipt=JSON.parse(readFileSync(o.imageReceipt,'utf8'));verifyImage(receipt,o.checkout);
|
||||
docker=execFileSync('which',['docker'],{encoding:'utf8'}).trim();
|
||||
}catch(e){if(automatic)rmdirSync(tempRoot);throw e;}
|
||||
const id=nonce(),run={kind:'start',id,stateRoot:o.stateRoot,lane:o.lane,exclusive:o.exclusive,owner:identity(),grant:o.grant,candidate:o.candidate,checkout:o.checkout,tempRoot:o.tempRoot,out:o.out,project:`mg197-${o.lane}-${id}`,tag:receipt.tag,imageId:receipt.imageId,docker};
|
||||
const dir=join(o.stateRoot,'runs',id);appendRecord(dir,run);const record=e=>{appendRecord(dir,e);if(e.kind==='step')process.stdout.write(`${e.name} rc=${e.rc} wall_ms=${Math.round(e.wallMs)} pass=${e.counts.pass??'-'} fail=${e.counts.fail??'-'} skipped=${e.counts.skipped??'-'}\n`);};
|
||||
const begin=performance.now(),startMs=Date.now();let rc=1,clean=false,current=null;
|
||||
const aborter=new AbortController();const stop=()=>aborter.abort();for(const signal of ['SIGINT','SIGTERM','SIGHUP'])process.on(signal,stop);
|
||||
try{
|
||||
mkdirSync(o.out,{mode:0o700});mkdirSync(join(o.out,'bin'),{mode:0o700});
|
||||
writeFileSync(join(o.out,'docker.json'),JSON.stringify({...run,docker}),{mode:0o600,flag:'wx'});
|
||||
const guard=join(BUILDER,'scripts/testing/lane-docker.mjs');
|
||||
// Launch the guard directly so it validates every real Docker invocation.
|
||||
writeFileSync(join(o.out,'bin','docker'),`#!/bin/sh\nexec '${process.execPath.replaceAll("'","'\\''")}' '${guard.replaceAll("'","'\\''")}' "$@"\n`,{mode:0o700,flag:'wx'});
|
||||
const steps=discoverSteps(o.checkout);record({kind:'plan',steps});
|
||||
const before=homeMetadata(process.env.HOME);record({kind:'home-before',entries:before});
|
||||
const env=cleanEnv(o,run,docker);
|
||||
const result=await executePinnedSteps(steps,{out:o.out,env,record,onChild:c=>current=c,signal:aborter.signal},{receipt,checkout:o.checkout,afterStep:()=>record({kind:'sockets',entries:socketInventory(o.tempRoot)})});
|
||||
rc=result.rc;
|
||||
const after=homeMetadata(process.env.HOME);record({kind:'home-after',entries:after});
|
||||
const prior=new Map(before.map(e=>[e.path,JSON.stringify(e)]));const changed=after.filter(e=>prior.get(e.path)!==JSON.stringify(e));
|
||||
if(attributeEntries(changed,{...run,nonce:id,fixturePaths:[]}).owned.length)refuse('lane-attributed real HOME write');
|
||||
if(git(o.checkout,'diff','--name-only'))refuse('tracked checkout changed during gate');
|
||||
const noLeaks=await cleanProcesses(run,record);cleanupProject(run,docker);clean=true;if(!noLeaks&&rc===0)rc=1;
|
||||
}catch(e){record({kind:'failure',message:e.message});process.stderr.write(`${e.message}\n`);rc=rc||1;
|
||||
try{await cleanProcesses(run,record);cleanupProject(run,docker);clean=true;}catch(error){record({kind:'cleanup-failed',message:error.message});}
|
||||
}finally{
|
||||
if(current){try{process.kill(-current.pid,'SIGKILL');}catch{}}
|
||||
for(const signal of ['SIGINT','SIGTERM','SIGHUP'])process.off(signal,stop);
|
||||
record({kind:'terminal',clean,rc,startMs,endMs:Date.now(),wallMs:performance.now()-begin,imageBuilds:0});
|
||||
}
|
||||
if(rc===0&&clean)process.stdout.write('GATE_PASSED\n');return rc;
|
||||
}
|
||||
async function main(argv){
|
||||
const locked=argv[0]==='--locked';if(locked)argv=argv.slice(1);
|
||||
if(argv[0]==='lock-dir'){
|
||||
const o=options(argv.slice(1));if(!o.grant)refuse('missing grant reference');if(!['a','b'].includes(o.lane))refuse('invalid lane');process.stdout.write(stateDirectory(o.stateRoot,true)+'\n');return 0;
|
||||
}
|
||||
const o=options(argv);o.stateRoot=stateDirectory(o.stateRoot);
|
||||
if(o.verb==='status'){process.stdout.write(JSON.stringify(status(o.stateRoot))+'\n');return 0;}
|
||||
if(!locked)refuse('use gate-lane.sh for locked operations');
|
||||
if(['recover','prepare-image'].includes(o.verb))o.exclusive=true;
|
||||
verifyLocks(o);
|
||||
if(o.verb==='recover'){process.stdout.write(JSON.stringify(await recoverRun(o))+'\n');return 0;}
|
||||
if(o.verb==='prepare-image'){
|
||||
for(const key of ['checkout','out','candidate','grant'])if(!o[key])refuse(`missing ${key}`);
|
||||
const checked=validateRun({...o,tempRoot:o.tempRoot??mkdtempSync('/mnt/storage/scratch/ga-')});activeConflicts(checked);
|
||||
const id=nonce(),dir=join(checked.stateRoot,'runs',id),docker=execFileSync('which',['docker'],{encoding:'utf8'}).trim();
|
||||
appendRecord(dir,{kind:'start',mode:'prepare-image',lane:checked.lane,exclusive:true,owner:identity(),grant:checked.grant,candidate:checked.candidate,checkout:checked.checkout,tempRoot:checked.tempRoot,out:checked.out,project:`mg197-${checked.lane}-${id}`,docker});
|
||||
mkdirSync(checked.out,{mode:0o700});
|
||||
try{
|
||||
const receipt=prepareImage(checked.checkout,checked);
|
||||
writeFileSync(join(checked.out,'image-receipt.json'),JSON.stringify(receipt,null,2)+'\n',{mode:0o600,flag:'wx'});
|
||||
appendRecord(dir,{kind:'terminal',clean:true,rc:0,imageId:receipt.imageId,builds:1,wallMs:receipt.buildWallMs});
|
||||
process.stdout.write(JSON.stringify({imageReceipt:join(checked.out,'image-receipt.json'),imageId:receipt.imageId,buildWallMs:receipt.buildWallMs})+'\n');return 0;
|
||||
}catch(e){appendRecord(dir,{kind:'terminal',clean:false,rc:1,message:e.message});throw e;}
|
||||
}
|
||||
return runGate(o);
|
||||
}
|
||||
if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href)main(process.argv.slice(2)).then(rc=>process.exitCode=rc).catch(e=>{process.stderr.write(`${e.message}\n`);process.exitCode=2;});
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
entry="$(cd "$(dirname "$0")" && pwd -P)/gate-lane.mjs"
|
||||
verb="${1:-run}"
|
||||
if [ "$verb" = status ]; then exec node "$entry" "$@"; fi
|
||||
state="$(node "$entry" lock-dir "$@")"
|
||||
lane=a
|
||||
exclusive=0
|
||||
args=("$@")
|
||||
for ((i=0;i<${#args[@]};i++)); do
|
||||
case "${args[i]}" in
|
||||
--lane) lane="${args[i+1]:-}" ;;
|
||||
--exclusive) exclusive=1 ;;
|
||||
esac
|
||||
done
|
||||
case "$verb" in recover|prepare-image) exclusive=1;; esac
|
||||
case "$lane" in a|b) ;; *) echo 'gate-lane: refused: invalid lane' >&2; exit 2;; esac
|
||||
if [ "$exclusive" = 1 ]; then
|
||||
exec 3>>"$state/a.lock" 4>>"$state/b.lock" 5>>"$state/image.lock"
|
||||
flock -n 3 && flock -n 4 && flock -n -x 5 || { echo 'gate-lane: refused: lane or image lock busy' >&2; exit 2; }
|
||||
else
|
||||
exec 3>>"$state/$lane.lock" 5>>"$state/image.lock"
|
||||
flock -n 3 && flock -n -s 5 || { echo 'gate-lane: refused: lane or image lock busy' >&2; exit 2; }
|
||||
fi
|
||||
exec node "$entry" --locked "$@"
|
||||
@@ -0,0 +1,60 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, linkSync, unlinkSync, openSync, closeSync, fstatSync, constants } from 'node:fs';
|
||||
import { join, isAbsolute } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { refuse, shortRoot, nonce } from './lane-state.mjs';
|
||||
function composeFiles(run){
|
||||
if(!/^sha256:[a-f0-9]{64}$/.test(run.imageId??''))refuse('invalid pinned image ID');
|
||||
if(typeof run.checkout!=='string'||!isAbsolute(run.checkout))refuse('invalid candidate checkout');
|
||||
return [join(run.checkout,'compose.yaml'),join(shortRoot(run.tempRoot),'compose-no-build.yaml')];
|
||||
}
|
||||
export function writeComposeOverride(run){
|
||||
const [,file]=composeFiles(run);
|
||||
const text=`services:\n mosaic-agent:\n build: !reset null\n image: ${JSON.stringify(run.imageId)}\n`;
|
||||
// Publish a complete file without replacing another guard's override.
|
||||
const pending=file+'.'+nonce();
|
||||
try{
|
||||
writeFileSync(pending,text,{mode:0o600,flag:'wx'});
|
||||
try{linkSync(pending,file);}catch(e){if(e.code!=='EEXIST')throw e;}
|
||||
}finally{try{unlinkSync(pending);}catch(e){if(e.code!=='ENOENT')throw e;}}
|
||||
let fd;
|
||||
try{
|
||||
fd=openSync(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
|
||||
const st=fstatSync(fd);
|
||||
if(!st.isFile()||st.uid!==process.getuid()||(st.mode&0o777)!==0o600||st.size!==Buffer.byteLength(text)||readFileSync(fd,'utf8')!==text)
|
||||
refuse('unsafe or changed Compose override');
|
||||
}catch{refuse('unsafe or changed Compose override');}
|
||||
finally{if(fd!==undefined)closeSync(fd);}
|
||||
return file;
|
||||
}
|
||||
export function guardDocker(argv,run){
|
||||
if(argv[0]==='info'&&argv.length===1)return argv;
|
||||
if(argv[0]==='image'&&argv[1]==='inspect'&&argv[2]===run.tag&&argv.length===3)return argv;
|
||||
if(argv[0]==='compose'&&argv[1]==='run'){
|
||||
const service=argv.indexOf('mosaic-agent',2);
|
||||
if(service<0||argv.slice(2,service).some(a=>!['--rm','-T','--no-deps','--interactive','-i'].includes(a)))refuse('Docker Compose run options not allowed');
|
||||
const [base,override]=composeFiles(run);
|
||||
return ['compose','-f',base,'-f',override,'run','--pull','never','--no-deps',...argv.slice(2)];
|
||||
}
|
||||
refuse('Docker mutation or unowned command');
|
||||
}
|
||||
export function cleanupProject(run,docker='docker'){
|
||||
if(!/^mg197-[ab]-[a-f0-9]{12}$/.test(run.project))refuse('invalid Docker ownership project');
|
||||
const invoke=typeof docker==='function'?docker:(args)=>execFileSync(docker,args,{encoding:'utf8',stdio:['ignore','pipe','pipe']});
|
||||
const removed=[];
|
||||
for(const [kind,list,filter] of [['container',['ps','-aq'],'com.docker.compose.project'],['network',['network','ls','-q'],'com.docker.compose.project']]){
|
||||
const ids=invoke([...list,'--filter',`label=${filter}=${run.project}`]).trim().split(/\s+/).filter(Boolean);
|
||||
for(const id of ids){if(!/^[a-f0-9]{12,64}$/.test(id))refuse('invalid Docker resource ID');
|
||||
const label=invoke([kind,'inspect',id,'--format',`{{index .${kind==='container'?'Config.':''}Labels "${filter}"}}`]).trim();
|
||||
if(label!==run.project)refuse('foreign Docker resource');
|
||||
invoke(kind==='container'?['container','rm','-f',id]:['network','rm',id]);removed.push({kind,id});
|
||||
}
|
||||
const left=invoke([...list,'--filter',`label=${filter}=${run.project}`]).trim();if(left)refuse('Docker cleanup incomplete');
|
||||
}
|
||||
return {clean:true,removed};
|
||||
}
|
||||
if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href){
|
||||
try{const run=JSON.parse(readFileSync(process.env.MOSAIC_GATE_DOCKER_RECORD,'utf8'));if(process.env.COMPOSE_PROJECT_NAME!==run.project)refuse('Compose project mismatch');
|
||||
const argv=guardDocker(process.argv.slice(2),run);if(argv[0]==='compose')writeComposeOverride(run);const result=spawnSync(run.docker,argv,{stdio:'inherit'});process.exit(result.status??1);
|
||||
}catch(e){process.stderr.write(`${e.message}\n`);process.exit(2);}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { readdirSync, lstatSync, readlinkSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { within, refuse } from './lane-state.mjs';
|
||||
export function attributeEntries(metadata,run){
|
||||
const result={owned:[],unrelated:[],unknown:[]};
|
||||
for(const e of metadata){const owns=e.path.includes(run.nonce)||e.link&&within(run.tempRoot,e.link)||(run.fixturePaths??[]).some(path=>within(path,e.path)||e.link&&within(path,e.link));result[owns?'owned':'unrelated'].push(e);}
|
||||
return result;
|
||||
}
|
||||
export function homeMetadata(home){
|
||||
const out=[];
|
||||
for(const name of ['.claude','.pi','.cache']){
|
||||
const root=join(home,name);if(!existsSync(root))continue;
|
||||
// Two levels are evidence only. Never open or hash file contents.
|
||||
const walk=(dir,depth)=>{for(const d of readdirSync(dir,{withFileTypes:true})){const path=join(dir,d.name);try{const st=lstatSync(path);out.push({path,size:st.size,mtime:st.mtimeMs,mode:st.mode,link:st.isSymbolicLink()?readlinkSync(path):null});if(d.isDirectory()&&depth)walk(path,depth-1);}catch{}}};
|
||||
walk(root,1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
export function socketInventory(root){const out=[];const walk=dir=>{for(const d of readdirSync(dir,{withFileTypes:true})){const p=join(dir,d.name);if(d.isDirectory())walk(p);else if(d.isSocket()){const bytes=Buffer.byteLength(p);out.push({path:p,bytes});if(bytes>107)refuse('socket path exceeds 107 bytes');}}};walk(root);return out;}
|
||||
export function summarizePairs(solos,pairs){
|
||||
const byCandidate=new Map(solos.map(r=>[r.candidate,r]));
|
||||
if(solos.length!==2||byCandidate.size!==2||solos.some(r=>!Number.isFinite(r.wallMs)||r.wallMs<=0))refuse('solo timing invalid');
|
||||
if(pairs.length!==3)refuse('proof needs three pairs');
|
||||
let first;
|
||||
return pairs.map((pair,index)=>{
|
||||
if(pair.length!==2||pair[0].candidate===pair[1].candidate||new Set(pair.map(r=>r.lane)).size!==2||pair.some(r=>!['a','b'].includes(r.lane)))refuse('pair needs different candidates and lanes');
|
||||
for(const run of pair){
|
||||
if(!run.clean||run.rc!==0||!byCandidate.has(run.candidate))refuse('proof gate failed');
|
||||
if(!Number.isFinite(run.startMs)||!Number.isFinite(run.endMs)||run.endMs<=run.startMs)refuse('paired timing invalid');
|
||||
}
|
||||
if(index===0)first=new Map(pair.map(r=>[r.candidate,r.lane]));
|
||||
if(index===1&&pair.some(r=>first.get(r.candidate)===r.lane))refuse('second pair must swap lanes');
|
||||
if(Math.max(...pair.map(r=>r.startMs))>=Math.min(...pair.map(r=>r.endMs)))refuse('paired gates did not overlap');
|
||||
const makespan=Math.max(...pair.map(r=>r.endMs))-Math.min(...pair.map(r=>r.startMs));
|
||||
return {makespanMs:makespan,runs:pair.map(r=>({candidate:r.candidate,wallMs:r.endMs-r.startMs,slowdown:(r.endMs-r.startMs)/byCandidate.get(r.candidate).wallMs})),throughput:pair.reduce((n,r)=>n+byCandidate.get(r.candidate).wallMs,0)/makespan};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Imported only by tests. The port holder lives until the fixture closes.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync, realpathSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { createServer } from 'node:net';
|
||||
import { once } from 'node:events';
|
||||
import { BANDS, within, SCRATCH, refuse } from './lane-state.mjs';
|
||||
async function hold(file){
|
||||
const child=spawn('flock',['-n','-E','73',file,'bash','-c','printf "held\\n"; IFS= read -r release'],{stdio:['pipe','pipe','pipe']});
|
||||
let err='';child.stderr.on('data',c=>err+=c);const exited=once(child,'exit');
|
||||
const ok=await new Promise((resolve,reject)=>{child.stdout.once('data',()=>resolve(true));child.once('exit',code=>code===73?resolve(false):reject(Error(`port lock holder exited ${code}: ${err}`)));child.once('error',reject);});
|
||||
if(!ok)return null;
|
||||
return async()=>{child.stdin.end('\n');await exited;};
|
||||
}
|
||||
async function probe(port){const s=createServer();try{await new Promise((resolve,reject)=>{s.once('error',reject);s.listen(port,'127.0.0.1',resolve);});const found=s.address().port;await new Promise(r=>s.close(r));return found;}catch(e){if(s.listening)await new Promise(r=>s.close(r));if(e.code==='EADDRINUSE')return null;throw e;}}
|
||||
export async function leasePort(t,{lane=process.env.MOSAIC_GATE_LANE,stateRoot=process.env.MOSAIC_GATE_STATE}={}){
|
||||
if(!lane){const port=await probe(0);return port;}
|
||||
if(!BANDS[lane]||!stateRoot)refuse('invalid port lane state');
|
||||
const state=realpathSync(stateRoot);if(!within(SCRATCH,state))refuse('port state outside scratch');
|
||||
const dir=join(state,'ports',lane);mkdirSync(dir,{recursive:true,mode:0o700});
|
||||
const [lo,hi]=BANDS[lane];
|
||||
for(let port=lo;port<=hi;port++){
|
||||
const release=await hold(join(dir,`${port}.lock`));if(!release)continue;
|
||||
let actual;try{actual=await probe(port);}catch(e){await release();throw e;}
|
||||
if(!actual){await release();continue;}
|
||||
t.after(release);return port;
|
||||
}
|
||||
refuse('lane port band exhausted');
|
||||
}
|
||||
export function fixtureCacheEnv(root){
|
||||
mkdirSync(root,{recursive:true,mode:0o700});const dir=realpathSync(root);for(const n of ['tmp','jiti','cache'])mkdirSync(join(dir,n),{recursive:true,mode:0o700});
|
||||
return {TMPDIR:join(dir,'tmp'),JITI_FS_CACHE:join(dir,'jiti'),XDG_CACHE_HOME:join(dir,'cache')};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, readdirSync, lstatSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { refuse } from './lane-state.mjs';
|
||||
export function imageFingerprint(checkout){
|
||||
const dockerfile=readFileSync(join(checkout,'Containerfile'),'utf8');
|
||||
const copies=dockerfile.split('\n').filter(l=>/^\s*(COPY|ADD)\s/i.test(l)).map(l=>l.trim());
|
||||
const expected=['COPY package.json package-lock.json ./','COPY contracts /opt/mosaic/contracts','COPY src /opt/mosaic/src','COPY adapters /opt/mosaic/adapters'];
|
||||
if(JSON.stringify(copies)!==JSON.stringify(expected))refuse('image COPY inputs changed; manifest review required');
|
||||
const hash=createHash('sha256');
|
||||
const visit=rel=>{const path=join(checkout,rel),st=lstatSync(path);if(st.isSymbolicLink())refuse('image input is a symlink');hash.update(JSON.stringify([rel,st.mode&0o777,st.isDirectory()?'d':'f'])+'\n');if(st.isDirectory())for(const n of readdirSync(path).sort())visit(`${rel}/${n}`);else if(st.isFile())hash.update(readFileSync(path));else refuse('unsupported image input');};
|
||||
for(const rel of ['Containerfile','package.json','package-lock.json','contracts','src','adapters'])visit(rel);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
export function inspectImage(tag,docker=process.env.MOSAIC_GATE_REAL_DOCKER??'docker'){
|
||||
return JSON.parse(execFileSync(docker,['image','inspect',tag,'--format','{{json .}}'],{encoding:'utf8',stdio:['ignore','pipe','pipe']}));
|
||||
}
|
||||
export function verifyImage(receipt,checkout,inspect=inspectImage){
|
||||
if(!receipt||!/^sha256:[a-f0-9]{64}$/.test(receipt.imageId??'')||!/^.+@sha256:[a-f0-9]{64}$/.test(receipt.baseDigest??'')||!/^linux\/[a-z0-9_]+$/.test(receipt.platform??'')||!/^mosaic-poc-agent:[A-Za-z0-9_.-]+$/.test(receipt.tag??''))refuse('image provenance receipt invalid');
|
||||
if(receipt.inputDigest!==imageFingerprint(checkout))refuse('image inputs differ');
|
||||
const actual=inspect(receipt.tag);
|
||||
if(actual.Id!==receipt.imageId)refuse('pinned image ID differs');
|
||||
if(`${actual.Os}/${actual.Architecture}`!==receipt.platform)refuse('image platform differs');
|
||||
const release=readFileSync(join(checkout,'RELEASE'),'utf8').trim();const pkg=JSON.parse(readFileSync(join(checkout,'package.json'),'utf8'));
|
||||
if(pkg.dependencies?.['@earendil-works/pi-coding-agent']&&receipt.tag!==`mosaic-poc-agent:${pkg.dependencies['@earendil-works/pi-coding-agent']}-r${release}`)refuse('image tag differs from release identity');
|
||||
return receipt;
|
||||
}
|
||||
// Called by the CLI only while both lanes and the image lock are exclusive.
|
||||
export function prepareImage(checkout,{grant,tempRoot,out},invoke){
|
||||
if(!grant)refuse('missing image preparation grant');
|
||||
const inputDigest=imageFingerprint(checkout);
|
||||
const pkg=JSON.parse(readFileSync(join(checkout,'package.json'),'utf8'));
|
||||
const release=readFileSync(join(checkout,'RELEASE'),'utf8').trim();
|
||||
if(!/^\d+\.\d+\.\d+$/.test(release))refuse('invalid image release');
|
||||
const tag=`mosaic-poc-agent:${pkg.dependencies['@earendil-works/pi-coding-agent']}-r${release}`;
|
||||
const run=invoke??((args)=>execFileSync('docker',args,{cwd:checkout,encoding:'utf8',stdio:['ignore','pipe','pipe'],env:{PATH:process.env.PATH,HOME:process.env.HOME,...(process.env.DOCKER_HOST?{DOCKER_HOST:process.env.DOCKER_HOST}:{}),COMPOSE_PROJECT_NAME:'mg197-image-prepare',MOSAIC_IMAGE_TAG:tag,MOSAIC_DATA_ROOT:join(tempRoot,'data'),MOSAIC_PROVIDER:'fixture',MOSAIC_MODEL:'fixture',PI_AUTH_FILE:'/dev/null'}}));
|
||||
const start=performance.now();let buildOutput;try{buildOutput=run(['compose','build']);}catch(e){if(out)writeFileSync(join(out,'image-build.log'),String(e.stdout??'')+String(e.stderr??''),{mode:0o600,flag:'wx'});throw e;}if(out)writeFileSync(join(out,'image-build.log'),buildOutput,{mode:0o600,flag:'wx'});const buildWallMs=performance.now()-start;
|
||||
if(inputDigest!==imageFingerprint(checkout))refuse('image inputs changed while building');
|
||||
const actual=JSON.parse(run(['image','inspect',tag,'--format','{{json .}}']));
|
||||
const base=JSON.parse(run(['image','inspect','node:24-bookworm-slim','--format','{{json .}}']));
|
||||
const receipt={inputDigest,imageId:actual.Id,tag,baseDigest:base.RepoDigests?.find(x=>/@sha256:[a-f0-9]{64}$/.test(x)),platform:`${actual.Os}/${actual.Architecture}`,grant,builds:1,buildWallMs,at:new Date().toISOString()};
|
||||
verifyImage(receipt,checkout,()=>actual);return receipt;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Scratch-only lane ownership. No live Mosaic state is read or written.
|
||||
import { realpathSync, statSync, lstatSync, existsSync, readdirSync, readFileSync, mkdirSync, openSync, writeSync, fsyncSync, closeSync, linkSync, unlinkSync, renameSync } from 'node:fs';
|
||||
import { resolve, join, dirname, relative, isAbsolute } from 'node:path';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
export const SCRATCH='/mnt/storage/scratch';
|
||||
export const BANDS={a:[61000,61127],b:[61128,61255]};
|
||||
export function refuse(message){throw new Error(`gate-lane: refused: ${message}`);}
|
||||
export function within(root,path){const r=relative(root,path);return r===''||(!r.startsWith('..')&&!isAbsolute(r));}
|
||||
export function shortRoot(path){
|
||||
let real;try{real=realpathSync(path);}catch{refuse('temporary root missing');}
|
||||
if(real==='/tmp'||real.startsWith('/tmp/'))refuse('temporary root under /tmp');
|
||||
if(Buffer.byteLength(real)>30)refuse('temporary root exceeds 30 bytes');
|
||||
if(!within(SCRATCH,real)||!statSync(real).isDirectory()||statSync(real).uid!==process.getuid()||(statSync(real).mode&0o077))refuse('temporary root must be private owned scratch');
|
||||
return real;
|
||||
}
|
||||
export function socketPath(path){if(Buffer.byteLength(path)>107)refuse('socket path exceeds 107 bytes');return path;}
|
||||
export function hostFacts(){
|
||||
const range=readFileSync('/proc/sys/net/ipv4/ip_local_port_range','utf8').trim().split(/\s+/).map(Number);
|
||||
const unprivilegedPortStart=Number(readFileSync('/proc/sys/net/ipv4/ip_unprivileged_port_start','utf8').trim());
|
||||
const text=execFileSync('ss',['-Hltn'],{encoding:'utf8'});
|
||||
const listeners=text.trim().split('\n').filter(Boolean).map(line=>{const field=line.trim().split(/\s+/)[3];const m=field?.match(/^(.*):(\d+)$/);if(!m)refuse('unreadable listener inventory');return {address:m[1].replace(/^\[|\]$/g,''),port:Number(m[2])};});
|
||||
return {ephemeral:range,unprivilegedPortStart,listeners};
|
||||
}
|
||||
export function checkHost(lane,facts){
|
||||
if(!facts||!Array.isArray(facts.listeners)||facts.ephemeral?.length!==2||!facts.ephemeral.every(Number.isInteger))refuse('host inventory unavailable');
|
||||
if(!Number.isInteger(facts.unprivilegedPortStart)||facts.unprivilegedPortStart<=9)refuse('unprivileged port range includes port 9');
|
||||
if(facts.listeners.some(x=>x.port===9&&['127.0.0.1','0.0.0.0','*','::','::ffff:127.0.0.1'].includes(x.address.replace(/%[^%]+$/, ''))))refuse('live port 9 listener');
|
||||
const [lo,hi]=BANDS[lane];const [first,last]=facts.ephemeral;
|
||||
if(lo<=last&&hi>=first)refuse('lane port band overlaps ephemeral range');
|
||||
if(facts.listeners.some(x=>x.port>=lo&&x.port<=hi))refuse('lane port band has a live listener');
|
||||
}
|
||||
export function identity(pid=process.pid){try{const s=readFileSync(`/proc/${pid}/stat`,'utf8');const f=s.slice(s.lastIndexOf(')')+2).split(' ');return {pid:Number(pid),start:f[19],boot:readFileSync('/proc/sys/kernel/random/boot_id','utf8').trim(),state:f[0]};}catch{return null;}}
|
||||
export function sameProcess(a,b=identity(a?.pid)){return !!a&&!!b&&a.pid===b.pid&&a.start===b.start&&a.boot===b.boot&&b.state!=='Z';}
|
||||
export function nonce(){return randomBytes(6).toString('hex');}
|
||||
export function appendRecord(dir,record){
|
||||
const publishedDir=dir;if(record.kind==='start'){if(existsSync(dir))refuse('run already exists');dir=dir+'.starting';}
|
||||
mkdirSync(dir,{recursive:true,mode:0o700});
|
||||
const file=join(dir,`${Date.now()}-${nonce()}-${record.kind}.json`);
|
||||
const pending=file+'.pending',fd=openSync(pending,'wx',0o600);try{writeSync(fd,JSON.stringify({...record,at:new Date().toISOString()})+'\n');fsyncSync(fd);}finally{closeSync(fd);}
|
||||
linkSync(pending,file);unlinkSync(pending);if(record.kind==='start')renameSync(dir,publishedDir);return join(publishedDir,file.split('/').at(-1));
|
||||
}
|
||||
export function records(dir){if(!existsSync(dir))return [];return readdirSync(dir).filter(n=>n.endsWith('.json')).sort().map(n=>{try{return JSON.parse(readFileSync(join(dir,n),'utf8'));}catch{refuse(`invalid run receipt ${n}`);}});}
|
||||
export function runRecords(stateRoot){
|
||||
const runs=join(stateRoot,'runs');if(!existsSync(runs))return [];
|
||||
return readdirSync(runs).filter(id=>!id.endsWith('.starting')).map(id=>({id,dir:join(runs,id),records:records(join(runs,id))}));
|
||||
}
|
||||
export function processInventory(){
|
||||
const out=[];
|
||||
for(const pid of readdirSync('/proc').filter(n=>/^\d+$/.test(n))){try{const argv=readFileSync(`/proc/${pid}/cmdline`,'utf8').split('\0').filter(Boolean);const raw=readFileSync(`/proc/${pid}/stat`,'utf8');const f=raw.slice(raw.lastIndexOf(')')+2).split(' ');out.push({...identity(Number(pid)),ppid:Number(f[1]),group:Number(f[2]),argv});}catch{}}
|
||||
return out;
|
||||
}
|
||||
export function status(stateRoot,observations={}){
|
||||
const procs=observations.processes??processInventory();const runs=runRecords(stateRoot);
|
||||
const answer={a:{state:'CLEAR'},b:{state:'CLEAR'},image:'free',legacy:[],at:new Date().toISOString()};
|
||||
const owned=new Set();
|
||||
for(const run of runs){const start=run.records.find(x=>x.kind==='start');if(!start)refuse('run receipt has no start');const clean=run.records.some(x=>['terminal','recovered'].includes(x.kind)&&x.clean===true);
|
||||
if(clean)continue;
|
||||
const live=procs.find(p=>sameProcess(start.owner,p));const state=live?'BUSY':'QUARANTINED';
|
||||
for(const lane of start.exclusive?['a','b']:[start.lane]){if(!BANDS[lane])refuse('invalid lane receipt');answer[lane]={state,run:run.id};}
|
||||
answer.image=start.exclusive?'exclusive':'shared';
|
||||
if(live){owned.add(live.pid);let changed=true;while(changed){changed=false;for(const p of procs)if(owned.has(p.ppid)&&!owned.has(p.pid)){owned.add(p.pid);changed=true;}}}
|
||||
}
|
||||
for(const p of procs){if(p.state==='Z'||p.pid===process.pid||owned.has(p.pid))continue;
|
||||
const args=p.argv??[];const executable=args[0]?.split('/').at(-1);
|
||||
const test=(executable==='node'&&args.includes('--test'))||args.some(a=>/^(?:.*\/)?(?:gate\.sh|test-[a-z-]+\.sh)$/.test(a))||(executable==='docker'&&(args.includes('compose')||args.includes('build')))||args.some(a=>a.endsWith('/landing.mjs')&&args.includes('gate'))||args.some(a=>/(?:^|\/)[^/]*mutant[^/]*\.(?:mjs|py|sh)$/.test(a));
|
||||
if(test)answer.legacy.push({pid:p.pid,start:p.start});
|
||||
}
|
||||
for(const lane of ['a','b','image']){
|
||||
if(observations.ignoreLocks?.includes(lane)||!existsSync(join(stateRoot,`${lane}.lock`)))continue;
|
||||
const lock=lstatSync(join(stateRoot,`${lane}.lock`));if(!lock.isFile())refuse('lock is not an ordinary file');
|
||||
const probe=spawnSync('flock',['-n','-x',join(stateRoot,`${lane}.lock`),'true']);
|
||||
if(probe.status!==0&&probe.status!==1)refuse('cannot inspect lane lock');
|
||||
if(probe.status===1){if(lane==='image')answer.image=answer.image==='free'?'held':answer.image;else if(answer[lane].state==='CLEAR')answer[lane]={state:'BUSY',run:null};}
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
Reference in New Issue
Block a user