darkwing: row 92 round 2 packet, candidate 8166bade (#1586)

Filbert's six changes and Dewey's N1-N3: five table rows, two launcher
tests, README sentences. 34/34 mutants killed, 14 suites green.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 17:03:58 -05:00
co-authored by Claude Opus 5.5
parent f04185fcbd
commit 4643911ac1
7 changed files with 1654 additions and 0 deletions
+146
View File
@@ -0,0 +1,146 @@
# Row 92, role sessions confined to their own files, round 2 build (Darkwing)
Issue #1586. Round 1 packet: `agents/darkwing/work/queue-92/` (candidate
manifest `bc132570…`). Round 1 verdicts: Dewey approved with notes N1 to
N5 (comment 27942, rev 771). Filbert asked for six changes (comment 27951,
rev 772).
Base `af01465c`, as in round 1. No commit from the base to the packet's
commit touches the 13 paths. `build.patch` is
`git diff --cached --binary af01465c` of the same 13 paths. Apply it with
`git apply --index` to a detached worktree of the base, and
`sha256sum -c candidate-manifest.sha256` then gives 13/13 OK.
## What changed since round 1
No source file moved. `sandbox.mjs`, `session.mjs`, `launcher.mjs`,
`gate.mjs`, the unit and the other test files are byte for byte round 1's.
Five files moved:
| File | What changed | For |
|---|---|---|
| `packages/seat/tests/sandbox.test.mjs` | five rows in the confined-shell table; `standIns` makes a host `/dev/shm` file and a SysV segment | Filbert 1 and 2 |
| `packages/cli/tests/launcher.test.mjs` | two tests: a pi launch with no credential, a runner killed from the host | Filbert 3 and 4 |
| `packages/cli/README.md` | the refusal table row names the proxy set; a paragraph on how a Pi session logs in; the shell clause covers Claude Code's `Bash` | Filbert 5 and 6, Dewey N1 and N2 |
| `packages/seat/README.md` | one sentence: Pi logs in only through `ENV_ALLOW`'s variables | Filbert 6, Dewey N1 |
| `packages/harness/README.md` | Limits: the credential variable is readable from `bash`; `/proc/net/unix` lists the host's socket names | Dewey N2 and N3 |
## Filbert's changes
1. **The private `/proc`, `/dev` and IPC namespace.** Four rows in
`checks()`:
| Row | Command | May |
|---|---|---|
| read a stand-in in the host's /dev/shm | `cat /dev/shm/<the test's scratch name>` | no |
| see the test's own pid in /proc | `test -e /proc/<the test's pid>` | no |
| read its own IPC namespace's segment list | `test -r /proc/sysvipc/shm` | yes |
| see a SysV segment the host made | `awk '$2 == <id> …' /proc/sysvipc/shm` | no |
`standIns` now takes `t`. It writes the `/dev/shm` file (0600) and makes
a 4096-byte segment with `ipcmk -M`. `t.after` removes both. `session()`
checks on the host that the file, the test's own `/proc/<pid>` and the
segment all exist before any row runs, so a refusal inside means the
sandbox hid them. The readable row stops the segment row from passing
just because `/proc/sysvipc` is missing.
I tried `ipcs -m -i <id>` first. It exits 0 even when it prints "not
found", so it can't tell the namespaces apart. The `awk` over
`/proc/sysvipc/shm` exits 1 in a private IPC namespace and 0 in a shared
one (probe below).
2. **pi runs in the sandbox.** A row runs
`<REPO>/node_modules/.bin/pi --version` and needs rc 0. The table's
session is pi, and so is the direct `binds()` sandbox in the second
test.
3. **The launcher's `sessionExit` call.** "a runner killed from the host
ends its launch as killed, not failed" launches the PM, waits for its
claim, sends SIGKILL to the runner's host pid (bwrap's child) and reads
the launch log's end entry: reason `killed`, exit code null, signal
`SIGKILL`.
4. **A pi launch with no credential.** "a pi launch needs no session
credential" drops the fixture's stand-in, checks that none of the
`SESSION_CREDENTIALS` variables is left in the env, and launches the PM
(pi).
5. **cli README refusal row.** It names all three sets now.
6. **pi and `~/.pi/agent`.** The cli Sessions paragraph says a Pi session
logs in only through the variables `ENV_ALLOW` passes, never reads the
operator's `~/.pi/agent`, has no launch credential check, and so fails
at its first turn on a provider whose login lives only in those files.
The seat README's sandbox section says the same in one sentence. No
code, as Filbert asked.
## Dewey's notes
- N1 is Filbert's change 6, above.
- N2. The cli Sessions paragraph now says a shell the harness opens,
Claude Code's `Bash` or Pi's `bash`, can read the credential variables.
The harness Limits bullet "The gate is not the sandbox" says the same.
- N3. The Limits bullet on the network now names `/proc/net/unix`: it
lists the host's Unix sockets by name, path ones included, and a name
gives no access to a path socket the mounts hide.
- N4 (seccomp, `--disable-userns`) stays outside this row, as Dewey said.
- N5. Row 89 still hasn't landed. Whichever of 89 and 92 lands second
reruns its gate on the tree it lands on, and its
`packages/harness/README.md` digest will move.
## Probes
Run 2026-10-12 on this host, outside the tests, with a 4096-byte segment
from `ipcmk -M`, removed afterwards with `ipcrm -m`:
- `ipcs -m -i <id>` inside `--unshare-ipc` printed "id … not found" and
exited 0, so it can't be the check.
- `awk '$2 == <id> { f = 1 } END { exit !f }' /proc/sysvipc/shm` exited 1
inside `--unshare-ipc`, 0 in the same sandbox without it, and 0 on the
host.
- Under bwrap's `--dev`, `/dev/shm` exists and is empty.
After the seat suite and after the mutant run, `/dev/shm` held no
`mosaic-sandbox-*` file and `ipcs -m` showed no 4096-byte segment of
ours.
## Mutants
`mutants.sh` here: round 1's 26 unchanged, then 8 more. It ran with a
copy of `agents/darkwing/work/queue-59-review/r1/mut.mjs` (sha256
`a1ed6bca…`, round 1's helper) from 21:53:34Z to 21:58:15Z, with no other
seat's gate or mutant run going (`mutants.txt`). **34 of 34 killed.**
Every source file was restored and still matches the manifest.
| Mutant | Filbert's | Killed by |
|---|---|---|
| `no-ipc-namespace` (`--unshare-ipc` dropped) | survived | the row "see a SysV segment the host made" |
| `host-proc` (`--ro-bind /proc`) | survived | the row "see the test's own pid in /proc" |
| `host-dev` (`--dev-bind /dev`) | survived | the row "read a stand-in in the host's /dev/shm" |
| `pi-no-node-modules` | survived | the row "run pi, the session's harness" |
| `launcher-raw-exit` (`resolve({ code, signal })`) | survived | "a runner killed from the host ends its launch as killed, not failed" |
| `credential-every-harness` | survived | "a pi launch needs no session credential" |
| `pdeathsig-term` | killed | the hard-death recovery test, as in Filbert's run |
| `workspace-read-only` | killed | the row "write and read its workspace" |
In each of the four table mutants, both sandbox tests failed on that one
row and no other: the session test and the expose test's session run.
`exit-code-not-mapped` (round 1) is now also caught by the host-kill test.
Filbert's other five I didn't repeat. `no /etc`, `0755 home/tmp` and
`recover on start time alone` stay as they noted: the first isn't
demonstrable offline, the second is equivalent, the third is a second
guard. `claude bin at its real path only` and `sandbox check for Claude
Code only` were already killed in their run.
## Gate
`gate.sh`, run sequentially in `~/darkwing-scratch/r92/wt` (the base plus
this patch, `node_modules` symlinked), 21:58:30Z to 22:01:56Z, after a
check that no other seat's gate or mutant run was going
(`gate-summary.txt`). All 14 suites exited 0, 0 skipped:
- node: seat 33, cli 120 (round 1's 118 plus the two launcher tests),
harness 59, bus 102, webui 84;
- scripts: auth 15, conductor 17, config 24, discord 66,
extension-package 18, foundation 44, queue 27, release 14, task 26
(`DOCKER_HOST` at a dead socket).
The seat count doesn't move because the five new rows run inside the two
existing sandbox tests. test-release left an empty `wt_default` network,
created at 22:01:44Z, the second test-release started; I removed it.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
6ae423d3e1cad712f3353c79281d91cb98d0a3037703df06bfb65f490b63f3ac packages/cli/README.md
d19dca70349e45ad3607c6fccd00afc9b1904b4df79e1fe107f2fd926981fd16 packages/cli/src/launcher.mjs
8e4e255fdf560769fd79827a98591a3d56a753f2740b22a5538d5a9bd37ae741 packages/cli/systemd/mosaic-bus.service.in
43a7f4b783a6d16edf5c2762e9fd657e0d14a0e2af2581814b8afe0df2daeac9 packages/cli/tests/fixtures/launch-host.mjs
7e92ecf7bbcfd09f7a4cf6f3afac3646e198115d95ddd24dfdd5dd8bb53ab411 packages/cli/tests/host.test.mjs
057bba967b7034e52790dde5ab5c11d4c994625a07a55e0ff2b55a01ceb44e2f packages/cli/tests/launcher.test.mjs
ec09680cf3e7d6514f28f745302a08f0473f27dd06c699365573e38593039430 packages/harness/README.md
bfb7ca89754e36f9f467b7ea1bad03b38ea34d5f9bf1e49000ec2643bad8c811 packages/harness/src/gate.mjs
f5e0126f8e68b347785e353c33f07760537be9b5de8feca1ccc868aba2db20e8 packages/seat/README.md
39702ce147a1d1e7fecaf7a57e34041728ad0412f2a841b925698ed00527ca9b packages/seat/src/sandbox.mjs
c2e32e95884e734a92298d7d1e5e82445a91a7f5ca5c3d3741297d38c6c4edd6 packages/seat/src/session.mjs
594e0d1cf54b50a710771add89e37ffc21afbd9ee9275a1755e12a78e05fc090 packages/seat/tests/sandbox.test.mjs
0898f1db9e889d4a7ea1e266a450511c88127268db065be053462a4c2fe9d1c2 packages/seat/tests/session.test.mjs
@@ -0,0 +1,16 @@
start 2026-10-12T21:58:30Z base af01465c
seat rc=0 21:58:30-21:58:34 ℹ pass 33 ℹ fail 0 ℹ skipped 0 0 marks
cli rc=0 21:58:34-21:58:51 ℹ pass 120 ℹ fail 0 ℹ skipped 0 0 marks
harness rc=0 21:58:51-21:59:02 ℹ pass 59 ℹ fail 0 ℹ skipped 0 0 marks
bus rc=0 21:59:02-21:59:05 ℹ pass 102 ℹ fail 0 ℹ skipped 0 0 marks
webui rc=0 21:59:05-22:00:01 ℹ pass 84 ℹ fail 0 ℹ skipped 0 0 marks
test-auth rc=0 22:00:01-22:00:02 selftest: 15 passed, 0 failed
test-conductor rc=0 22:00:02-22:00:07 selftest: 17 passed, 0 failed
test-config rc=0 22:00:07-22:00:09 selftest: 24 passed, 0 failed
test-discord rc=0 22:00:09-22:00:17 discord suite: 66 passed, 0 failed
test-extension-package rc=0 22:00:17-22:00:18 extension package selftest: 18 passed, 0 failed
test-foundation rc=0 22:00:18-22:01:02 selftest: 44 passed, 0 failed
test-queue rc=0 22:01:02-22:01:44 queue suite: 27 passed, 0 failed
test-release rc=0 22:01:44-22:01:54 selftest: 14 passed, 0 failed
test-task rc=0 22:01:54-22:01:56 selftest: 26 passed, 0 failed
end 2026-10-12T22:01:56Z
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Row 92 round 2 gate: seat, cli, harness, bus and webui (each package's own test command, from its
# directory), then every scripts/test-*.sh, sequential, full output kept.
cd ~/darkwing-scratch/r92/wt || exit 2
W=$PWD
export TMPDIR=~/darkwing-scratch/r92/tmp
O=~/darkwing-scratch/r92/gate2
for p in seat cli harness bus webui; do
s=$(date -u +%T)
case $p in seat|webui) a=(tests/);; *) a=(tests/*.test.mjs);; esac
(cd "$W/packages/$p" && node --test "${a[@]}") > $O/node-$p.txt 2>&1; rc=$?
echo "$p rc=$rc $s-$(date -u +%T) $(grep -E '^ℹ (pass|fail|skipped)' $O/node-$p.txt | tr '\n' ' ') $(grep -cE '^✖|^not ok' $O/node-$p.txt) marks"
done
for f in scripts/test-*.sh; do
n=$(basename $f .sh); s=$(date -u +%T)
if [ $n = test-task ]; then DOCKER_HOST=unix:///nonexistent bash $f > $O/$n.txt 2>&1; else bash $f > $O/$n.txt 2>&1; fi; rc=$?
echo "$n rc=$rc $s-$(date -u +%T) $(tail -1 $O/$n.txt | cut -c1-120)"
done
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Row 92 (#1586) round 2 mutants: round 1's 26, then the sites Filbert's
# round 1 run left alive and their neighbours. Run from the candidate
# worktree's root:
# LOG=<dir> bash mutants.sh /path/to/mut.mjs > mutants.txt
# Each mutant edits one site with mut.mjs (exactly one match, else exit 3),
# runs the named test files and restores the file. "killed" means the run
# failed; "survived" means it passed.
set -u
MUT=$1
run() {
local name=$1 file=$2 from=$3 to=$4; shift 4
cp -- "$file" "$file.orig"
if ! node "$MUT" "$file" "$from" "$to"; then
echo "$name: NO MATCH"; mv -- "$file.orig" "$file"; return
fi
if node --test "$@" > "${LOG:-.}/mut-$name.log" 2>&1; then
echo "$name: survived"
else
echo "$name: killed ($(grep -m1 -o 'ℹ fail [0-9]*' "${LOG:-.}/mut-$name.log"), $(grep -m1 '^✖' "${LOG:-.}/mut-$name.log" | cut -c1-90))"
fi
mv -- "$file.orig" "$file"
}
S=packages/seat/src/sandbox.mjs
E=packages/seat/src/session.mjs
L=packages/cli/src/launcher.mjs
U=packages/cli/systemd/mosaic-bus.service.in
ST=(packages/seat/tests/sandbox.test.mjs)
SE=(packages/seat/tests/session.test.mjs)
LT=(packages/cli/tests/launcher.test.mjs)
ALL=(packages/seat/tests/sandbox.test.mjs packages/seat/tests/session.test.mjs packages/cli/tests/launcher.test.mjs)
# Binds
run no-workspace-bind $S $' { kind: "rw", src: workspace, why: "the instance\'s workspace, where the harness works" },\n' '' "${ST[@]}"
run runDir-read-only $S '{ kind: "rw", src: runDir,' '{ kind: "ro", src: runDir,' "${ST[@]}"
run bind-all-runs $S '{ kind: "rw", src: runDir,' '{ kind: "rw", src: dirname(runDir),' "${ST[@]}"
run bundle-writable $S $' { kind: "ro", src: join(runDir, "bundle"), why: "the run\'s bundle stays as the host wrote it" },\n' '' "${ST[@]}"
run session-json-writable $S $' { kind: "ro", src: join(runDir, "session.json"), why: "the session file stays as the host wrote it" },\n' '' "${ST[@]}"
run no-private-tmp $S $' { kind: "rw", src: join(runDir, "tmp"), dest: "/tmp", why: "a private /tmp, on disk in the run directory" },\n' '' "${ST[@]}"
run bind-checkout $S ' for (const f of RUNNER_FILES) out.push(' $' out.push({ kind: "ro", src: REPO, why: "x" });\n for (const f of RUNNER_FILES) out.push(' "${ST[@]}"
run node-modules-always $S 'if (harness === "pi") out.push' 'if (true) out.push' "${ST[@]}"
run runner-file-missing $S $' "packages/business/src/vocabulary.mjs",\n' '' "${ST[@]}"
run no-remount-ro $S '...argsOf(list), "--remount-ro", "/", "--setenv"' '...argsOf(list), "--setenv"' "${ST[@]}"
run host-home $S '"--setenv", "HOME", join(runDir, "home")' '"--setenv", "HOME", process.env.HOME' "${ST[@]}"
# Namespaces and death
run no-pid-namespace $S '"--unshare-pid", "--unshare-ipc", "--as-pid-1"' '"--unshare-ipc"' "${ALL[@]}"
run die-with-parent $S '"--unshare-ipc", "--as-pid-1"]' '"--unshare-ipc", "--as-pid-1", "--die-with-parent"]' "${LT[@]}"
run no-pdeathsig $S 'const KILL_WITH_PARENT = ["/usr/bin/setpriv", "--pdeathsig", "KILL", "--"];' 'const KILL_WITH_PARENT = [];' "${LT[@]}"
run exit-code-not-mapped $E $' if (!signal && code > 128 && SIGNALS[code - 128]) return { code: null, signal: SIGNALS[code - 128] };\n' '' "${SE[@]}" "${LT[@]}"
run recover-matches-unshare $L '(cmdlineOf(s.pid) ?? [])[0] === bwrap)' '(cmdlineOf(s.pid) ?? [])[0] === "unshare")' "${LT[@]}"
# Refusals
run no-sandbox-check $L $' if (unavailable) throw new Refusal("sandbox-unavailable", unavailable);\n' '' "${LT[@]}"
run probe-always-ok $S $' if (!existsSync(bwrap)) return `${bwrap} is missing`;\n' $' return null;\n' "${ST[@]}" "${LT[@]}"
run no-credential-check $L 'if (model.harness === "claude-code" && !hasSessionCredential(env))' 'if (false)' "${LT[@]}"
run proxy-key-alone $E '["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"]]' '["ANTHROPIC_AUTH_TOKEN"]]' "${SE[@]}" "${LT[@]}"
run proxy-key-ignored $E ', ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"]]' ']' "${SE[@]}" "${LT[@]}"
run any-variable-will-do $E 'SESSION_CREDENTIALS.some((set) => set.every(' 'SESSION_CREDENTIALS.some((set) => set.some(' "${SE[@]}" "${LT[@]}"
run claude-bin-not-bound $L 'claudeBin: env.MOSAIC_CLAUDE_BIN || null,' 'claudeBin: null,' "${LT[@]}"
run launch-socket-always $L 'launchSocket: resolved.launch ? launchSocketPath(dataRoot) : null,' 'launchSocket: launchSocketPath(dataRoot),' "${LT[@]}"
# The unit
run unit-no-envfile $U 'EnvironmentFile=-' '# EnvironmentFile=-' packages/cli/tests/host.test.mjs
run unit-envfile-required $U 'EnvironmentFile=-' 'EnvironmentFile=' packages/cli/tests/host.test.mjs
# Round 2: Filbert's survivors (changes 1 to 4) and their neighbours
run no-ipc-namespace $S '"--unshare-pid", "--unshare-ipc", "--as-pid-1"]' '"--unshare-pid", "--as-pid-1"]' "${ST[@]}"
run host-proc $S 'args.push("--proc", b.dest)' 'args.push("--ro-bind", "/proc", b.dest)' "${ST[@]}"
run host-dev $S 'args.push("--dev", b.dest)' 'args.push("--dev-bind", "/dev", b.dest)' "${ST[@]}"
run pi-no-node-modules $S 'if (harness === "pi") out.push' 'if (false) out.push' "${ST[@]}"
run launcher-raw-exit $L 'resolve(sessionExit(code, signal))' 'resolve({ code, signal })' "${LT[@]}"
run credential-every-harness $L 'if (model.harness === "claude-code" && !hasSessionCredential(env))' 'if (!hasSessionCredential(env))' "${LT[@]}"
run pdeathsig-term $S '"--pdeathsig", "KILL"' '"--pdeathsig", "TERM"' "${LT[@]}"
run workspace-read-only $S '{ kind: "rw", src: workspace,' '{ kind: "ro", src: workspace,' "${ST[@]}"
@@ -0,0 +1,34 @@
no-workspace-bind: killed (ℹ fail 2, ✖ a confined session's shell reaches its own workspace and run directory and nothing of th)
runDir-read-only: killed (ℹ fail 3, ✖ every bind says why; the bundle and session file stay read-only over the run directory ()
bind-all-runs: killed (ℹ fail 3, ✖ every bind says why; the bundle and session file stay read-only over the run directory ()
bundle-writable: killed (ℹ fail 3, ✖ every bind says why; the bundle and session file stay read-only over the run directory ()
session-json-writable: killed (ℹ fail 3, ✖ every bind says why; the bundle and session file stay read-only over the run directory ()
no-private-tmp: killed (ℹ fail 3, ✖ every bind says why; the bundle and session file stay read-only over the run directory ()
bind-checkout: killed (ℹ fail 2, ✖ a confined session's shell reaches its own workspace and run directory and nothing of th)
node-modules-always: killed (ℹ fail 1, ✖ every bind says why; the bundle and session file stay read-only over the run directory ()
runner-file-missing: killed (ℹ fail 3, ✖ RUNNER_FILES is exactly what the runner and the harness sources it starts import (2.4001)
no-remount-ro: killed (ℹ fail 2, ✖ a confined session's shell reaches its own workspace and run directory and nothing of th)
host-home: killed (ℹ fail 2, ✖ a confined session's shell reaches its own workspace and run directory and nothing of th)
no-pid-namespace: killed (ℹ fail 4, ✖ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (22)
die-with-parent: killed (ℹ fail 2, ✖ a host that died hard leaves its sessions to the next host, which kills them and ends th)
no-pdeathsig: killed (ℹ fail 2, ✖ a host that died hard leaves its sessions to the next host, which kills them and ends th)
exit-code-not-mapped: killed (ℹ fail 2, ✖ a runner killed from the host ends its launch as killed, not failed (255.196062ms))
recover-matches-unshare: killed (ℹ fail 1, ✖ a host that died hard leaves its sessions to the next host, which kills them and ends th)
no-sandbox-check: killed (ℹ fail 1, ✖ a missing or failing bwrap refuses sandbox-unavailable before anything starts; there is )
probe-always-ok: killed (ℹ fail 2, ✖ a missing or failing bwrap refuses sandbox-unavailable before anything starts; there is )
no-credential-check: killed (ℹ fail 1, ✖ a Claude Code launch with no session credential refuses before it starts (191.987684ms))
proxy-key-alone: killed (ℹ fail 2, ✖ a Claude Code launch with no session credential refuses before it starts (201.143048ms))
proxy-key-ignored: killed (ℹ fail 3, ✖ a Claude Code launch with no session credential refuses before it starts (132.669208ms))
any-variable-will-do: killed (ℹ fail 2, ✖ a Claude Code launch with no session credential refuses before it starts (196.260373ms))
claude-bin-not-bound: killed (ℹ fail 1, ✖ a real Claude Code coder's shell can't read a stand-in secret or anything else its sandb)
launch-socket-always: killed (ℹ fail 1, ✖ the PM launches a coder through its launch tool; the coder answers; refusals name their )
unit-no-envfile: killed (ℹ fail 1, ✖ bus-service.sh renders the unit and installs it into a given directory (6.614589ms))
unit-envfile-required: killed (ℹ fail 1, ✖ bus-service.sh renders the unit and installs it into a given directory (7.086427ms))
no-ipc-namespace: killed (ℹ fail 2, ✖ a confined session's shell reaches its own workspace and run directory and nothing of th)
host-proc: killed (ℹ fail 2, ✖ a confined session's shell reaches its own workspace and run directory and nothing of th)
host-dev: killed (ℹ fail 2, ✖ a confined session's shell reaches its own workspace and run directory and nothing of th)
pi-no-node-modules: killed (ℹ fail 2, ✖ a confined session's shell reaches its own workspace and run directory and nothing of th)
launcher-raw-exit: killed (ℹ fail 1, ✖ a runner killed from the host ends its launch as killed, not failed (288.485973ms))
credential-every-harness: killed (ℹ fail 1, ✖ a pi launch needs no session credential: pi logs in through its own ENV_ALLOW variables )
pdeathsig-term: killed (ℹ fail 2, ✖ a host that died hard leaves its sessions to the next host, which kills them and ends th)
workspace-read-only: killed (ℹ fail 2, ✖ a confined session's shell reaches its own workspace and run directory and nothing of th)