darkwing: row 92 (#1586) session confinement packet, round 1, manifest bc132570
Build, patch and manifest for the bwrap sandbox, the credential check and the unit's EnvironmentFile= line. Gate 14 suites rc=0; 26 mutants killed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
# Row 92, role sessions confined to their own files, build (Darkwing)
|
||||
|
||||
Issue #1586. Brief: `docs/plans/2026-10-12_session-confinement.md`
|
||||
(blob `21ee4458`, queue rev 741, decision 118), with Sage's direction on
|
||||
decision 119 (Q126, `b66bfe71`, queue rev 754): any provider credential
|
||||
that `ENV_ALLOW` carries passes the launch check, the credential file's
|
||||
path appears once in the unit template and once in the README paragraph,
|
||||
and tests use stand-ins. Base `af01465c`. No commit from the base to the
|
||||
packet's commit touches the 13 paths.
|
||||
|
||||
`build.patch` is `git diff --cached --binary af01465c` of the 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.
|
||||
|
||||
| File | What changed |
|
||||
|---|---|
|
||||
| `packages/seat/src/sandbox.mjs` | new: the bind table, the bwrap command line, the probe |
|
||||
| `packages/seat/src/session.mjs` | the runner spawns in the sandbox; `SESSION_CREDENTIALS`; `sessionExit` |
|
||||
| `packages/seat/tests/sandbox.test.mjs` | new: real confined sessions against stand-ins, binds broken on purpose |
|
||||
| `packages/seat/tests/session.test.mjs` | the session's HOME; the exit mapping; the credential sets |
|
||||
| `packages/seat/README.md` | "The sandbox (#1586)" in place of the PID namespace limits |
|
||||
| `packages/cli/src/launcher.mjs` | two new refusal steps; the paths a session needs; recovery matches bwrap |
|
||||
| `packages/cli/tests/launcher.test.mjs` | both refusals; the real Claude Code coder; credential stand-ins |
|
||||
| `packages/cli/tests/fixtures/launch-host.mjs` | passes the fake adapter as a read-only file |
|
||||
| `packages/cli/tests/host.test.mjs` | the unit has one `EnvironmentFile=`, the README names it |
|
||||
| `packages/cli/systemd/mosaic-bus.service.in` | the `EnvironmentFile=` line and its comment |
|
||||
| `packages/cli/README.md` | the two refusals, the credential, the unit paragraph |
|
||||
| `packages/harness/src/gate.mjs` | header comment only |
|
||||
| `packages/harness/README.md` | "Limits" rewritten for the sandbox |
|
||||
|
||||
## The sandbox
|
||||
|
||||
`spawnSession` now runs
|
||||
`bwrap <namespaces> <binds> --remount-ro / --setenv HOME <run>/home --setenv TMPDIR /tmp --chdir <run> -- setpriv --pdeathsig KILL -- node runner.mjs <run>`.
|
||||
The root is an empty tmpfs; each bind lands at its host path, so the paths
|
||||
in `session.json` mean the same inside and out. The namespaces are
|
||||
`--unshare-user --unshare-pid --unshare-ipc --as-pid-1`, so the runner is
|
||||
still pid 1 of its own PID namespace.
|
||||
|
||||
### Bind table
|
||||
|
||||
In mount order, as `binds()` returns them (each entry carries its `why`
|
||||
in the code too):
|
||||
|
||||
| Source | Inside | Mode | Why |
|
||||
|---|---|---|---|
|
||||
| `/usr` | same | ro | the system's programs and libraries: sh, node, git, coreutils |
|
||||
| `/bin`, `/lib`, `/lib64`, `/sbin` | same | symlink (or ro if a real dir) | the host's links into `/usr` |
|
||||
| `/etc` | same | ro | name resolution, CA certificates, passwd for the user's name |
|
||||
| (none) | `/dev` | private | null, zero, urandom and a pty |
|
||||
| (none) | `/proc` | private | the session's own PID namespace |
|
||||
| node's real path, if outside `/usr` | node's path | ro | node runs the runner and the gate |
|
||||
| the 10 `RUNNER_FILES` | same | ro | the repository files the runner loads |
|
||||
| the harness adapter | same | ro | the runner starts it each turn |
|
||||
| `<repo>/node_modules` (real path), pi only | `<repo>/node_modules` | ro | pi and its packages, on the session's PATH |
|
||||
| `MOSAIC_CLAUDE_BIN`'s real path, Claude Code only, if outside `/usr` | `MOSAIC_CLAUDE_BIN` | ro | the pinned Claude Code binary |
|
||||
| the broker socket | same | ro | the runner claims and calls through it |
|
||||
| the launch socket, the launching role only | same | ro | the `launch` tool calls it |
|
||||
| the instance's workspace | same | rw | where the harness works |
|
||||
| the run directory | same | rw | turns/, session/, tools.sock, turn.done, home/, tmp/ |
|
||||
| `<run>/bundle` | same | ro | stays as the host wrote it |
|
||||
| `<run>/session.json` | same | ro | stays as the host wrote it |
|
||||
| `<run>/tmp` | `/tmp` | rw | a private /tmp on disk in the run directory |
|
||||
|
||||
HOME is `<run>/home`, made empty (0700) before the spawn. Nothing else
|
||||
exists: no data root beyond the run directory, no other workspace or run,
|
||||
no `~/.config`, `~/.mosaic`, `~/.ssh` or `~/.claude`, no checkout beyond
|
||||
the runner's files. A bind to a socket is read-only as a mount, and
|
||||
`connect()` on a socket in a read-only mount still works; that is what the
|
||||
broker and launch sockets need.
|
||||
|
||||
## Refusals
|
||||
|
||||
Two steps join the launcher's checks, both before family and capacity and
|
||||
both through `refuse()`, so each is an `action.refused` event and a launch
|
||||
log line:
|
||||
|
||||
- `no-session-credential`: a Claude Code launch when none of the
|
||||
`SESSION_CREDENTIALS` sets is fully set in the bus host's environment:
|
||||
`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, or
|
||||
`ANTHROPIC_AUTH_TOKEN` with `ANTHROPIC_BASE_URL`. A pi launch doesn't
|
||||
check.
|
||||
- `sandbox-unavailable`: `sandboxProblem()` runs bwrap with the namespaces
|
||||
and the system binds and `true`. A missing bwrap or a failing one refuses
|
||||
with the first line of bwrap's stderr. There is no unconfined path left:
|
||||
`namespace: false` and `UNSHARE` are gone.
|
||||
|
||||
## Choices to review
|
||||
|
||||
1. **setpriv's parent-death signal, not bwrap's `--die-with-parent`.**
|
||||
`--die-with-parent` sets the death signal on bwrap itself as well as on
|
||||
the child. bwrap's parent is the bus host, so a host that dies hard
|
||||
would take every session with it, and the launcher's `recover()` (the
|
||||
next host finds the sessions and ends their runs) would have nothing to
|
||||
recover. That breaks the two "host that died hard" tests. So the
|
||||
runner gets `setpriv --pdeathsig KILL`: it dies when the outer bwrap
|
||||
does, as it did with unshare's `--kill-child`, and bwrap outlives the
|
||||
host. The race: if the outer bwrap dies between the fork and setpriv's
|
||||
`prctl`, the runner keeps running. The host ends the launch when the
|
||||
outer bwrap exits, and `endLaunch` voids every capability of the run,
|
||||
so the runner's next poll gets `unauthenticated` and it exits
|
||||
(`runner.mjs`, `SESSION_GONE`). A turn already running finishes first,
|
||||
up to the turn timeout. The window to hit is the few instructions
|
||||
between the fork and the `prctl`.
|
||||
2. **The refusal names the variables, not the file.** The brief said a
|
||||
Claude Code launch with no credential refuses "naming the missing
|
||||
file". Sage's one-path rule puts the path only in the unit template and
|
||||
the README paragraph, so the message names the variables and "the bus
|
||||
unit's EnvironmentFile". The README paragraph names the file.
|
||||
3. **Any provider credential passes, per decision 119.** The check accepts
|
||||
the OAuth token, an API key, or a proxy's token with its base URL, so
|
||||
the proxy-issued key (Vikunja #237) swaps in by editing the file. A
|
||||
proxy token without its URL doesn't count.
|
||||
4. **Pi sessions see the credential variables too.** `ENV_ALLOW` passes
|
||||
them to every session, so a pi session's bash can print them. That was
|
||||
so before this row for whatever the shell had; it is now so for the
|
||||
file's credential. The cli README says so. Narrowing `ENV_ALLOW` per
|
||||
harness would be a change to `sessionEnv`, outside this brief.
|
||||
5. **`claude` from `PATH` finds only `/usr`.** With `MOSAIC_CLAUDE_BIN`
|
||||
unset, the adapter runs `claude` from `PATH`, and inside the sandbox
|
||||
that is only a `claude` under `/usr`; a `~/.local/bin/claude` isn't
|
||||
there. The installed unit pins `MOSAIC_CLAUDE_BIN` (decision 91), so
|
||||
only a hand-started bus meets this. The cli README says so.
|
||||
6. **Edits past the brief's file list.** `host.test.mjs` gains the unit's
|
||||
one-`EnvironmentFile=` check, and `launch-host.mjs` passes the fake
|
||||
adapter as a read-only bind (the sandbox otherwise hides it). The cli
|
||||
README's refusal table gains its two rows, beyond the unit paragraph
|
||||
the brief names.
|
||||
7. **The test fixture strips the shell's credentials.** The cli fixture's
|
||||
env started from `process.env`, so a credential in the shell running
|
||||
the tests reached test sessions. `prepare()` now deletes every
|
||||
`SESSION_CREDENTIALS` variable and sets a stand-in.
|
||||
8. **NoNewPrivileges.** The unit sets `NoNewPrivileges=yes`. bwrap here
|
||||
is not setuid and uses user namespaces, which work under no-new-privs:
|
||||
`setpriv --no-new-privs` around `sandboxProblem()` returns null.
|
||||
|
||||
## Probes
|
||||
|
||||
Scratch probes, not part of the candidate:
|
||||
|
||||
- bwrap under `setpriv --no-new-privs`: works (choice 8).
|
||||
- The host's `claude` inside the Claude Code binds with an empty
|
||||
environment: `claude --version` gives 2.1.296; DNS resolves
|
||||
`api.anthropic.com` and a request reaches it (405); HOME is
|
||||
`<run>/home`; `claude -p` with no credential prints "Not logged in", exit
|
||||
1. The founder's login in `~/.claude` is out of reach. Afterwards HOME
|
||||
holds the `.claude` and `.claude.json` claude made itself.
|
||||
|
||||
## Tests
|
||||
|
||||
`packages/seat/tests/sandbox.test.mjs` (new):
|
||||
|
||||
1. `RUNNER_FILES` is exactly the runner's import graph plus the three
|
||||
files the harness starts: the test walks the imports, so a new import
|
||||
that isn't bound fails here, not in a live session.
|
||||
2. `sandboxProblem` names a missing bwrap and one that fails.
|
||||
3. Every bind has a `why`; `bundle/` and `session.json` are mounted
|
||||
read-only after the read-write run directory; `/tmp` is last; no
|
||||
launch socket for a role that may not launch; no `node_modules` for
|
||||
Claude Code.
|
||||
4. A real confined session (the real runner, a real broker, an adapter
|
||||
that runs each message's body in `sh`) answers a table of 20 checks
|
||||
against stand-ins under a scratch root: the stand-in
|
||||
`~/.config/mosaic-dev/secrets/stand-in.env` and `~/.mosaic`, another
|
||||
workspace, another run directory, the data root's bus store, the
|
||||
checkout (`AGENTS.md`, the seat's own source), writes to `/`, beside the
|
||||
workspace, in the data root, to a runner file, the bundle and the
|
||||
session file: all refused. Positive: HOME is the run's empty `home/`,
|
||||
TMPDIR is `/tmp`, the data root shows only this run, the workspace, the
|
||||
run directory and `/tmp` take a write and a read, node runs.
|
||||
5. Breaking it on purpose: the same session with the two stand-ins bound
|
||||
in fails exactly their two checks; a sandbox from `binds()` passes all
|
||||
20; without the workspace bind, exactly the workspace check fails.
|
||||
|
||||
`packages/seat/tests/session.test.mjs`: the existing real-session test now
|
||||
runs in the sandbox and checks the runner's HOME is `<run>/home`. A new
|
||||
test covers `sessionExit` (0, 23 and 128 stay codes; 137 and 143 become
|
||||
SIGKILL and SIGTERM, ending `killed`) and `hasSessionCredential` (each of
|
||||
the three sets alone passes; a proxy token or URL alone, empty strings and
|
||||
nothing fail; every name is in `ENV_ALLOW`).
|
||||
|
||||
`packages/cli/tests/launcher.test.mjs`:
|
||||
|
||||
- A Claude Code PM with only a proxy token refuses `no-session-credential`
|
||||
with the exact message, one log line, no session, no run directory.
|
||||
- A bwrap that fails, then a missing one, refuse `sandbox-unavailable`
|
||||
with bwrap's line and "is missing"; a session's launch request gets the
|
||||
same code through `refuse()`; no session, no run.
|
||||
- **The real Claude Code launch.** The PM (fake adapter) launches a coder
|
||||
whose harness is Claude Code, with the real `adapters/claude` and the
|
||||
host's `claude` pinned by `MOSAIC_CLAUDE_BIN`. Its credential is a
|
||||
stand-in proxy key, `ANTHROPIC_AUTH_TOKEN` with `ANTHROPIC_BASE_URL`
|
||||
pointing at the scripted Messages API from the harness tests, so no real
|
||||
credential or model is involved. The script makes one `Bash` call that
|
||||
tries nine things; the coder's reply carries the tool result. Refused:
|
||||
the stand-in secret (`FAKE_TOKEN=stand-in-value`), the stand-in
|
||||
`~/.mosaic`, the PM's run directory, the data root's `sessions.json`,
|
||||
the checkout, a write outside. Allowed: a write and read in its
|
||||
workspace and in `/tmp`, a writable HOME ending `/home`. The reply never
|
||||
contains the secret's value. The test passes in about 3 seconds; a rerun
|
||||
that printed the reply body gave
|
||||
`ANSWER [[false,"0 no\n1 no\n2 no\n3 no\n4 no\n5 no\n6 yes\n7 yes\n8 yes"]]`.
|
||||
With the stand-in secret added as a read-only bind, the test fails on
|
||||
exactly `'read the stand-in secret': true` (`real-exposed.txt`).
|
||||
- The test where the PM launches a coder now also reads both sessions'
|
||||
bwrap command lines: the PM's binds the launch socket, the coder's
|
||||
doesn't. This was the one mutant survivor (below).
|
||||
- `prepare()` strips the shell's credential variables (choice 7).
|
||||
|
||||
`packages/cli/tests/host.test.mjs`: the rendered unit has exactly one
|
||||
`EnvironmentFile=`, of the form `-%h/...`, and the cli README contains
|
||||
that line.
|
||||
|
||||
The unit renders, and `systemd-analyze --user verify` on it prints
|
||||
nothing.
|
||||
|
||||
## Mutants
|
||||
|
||||
`mutants.sh` with `agents/darkwing/work/queue-59-review/r1/mut.mjs`. Each
|
||||
mutant edits one site (exactly one match), runs the test files it names
|
||||
and restores the file. The run went 2026-10-12 20:43:33Z to 20:47:09Z,
|
||||
after Dewey's row 89 run had ended (`mutants.txt`).
|
||||
|
||||
26 mutants. 23 were killed on the first run. Three needed a second look:
|
||||
|
||||
- `bind-checkout` and `probe-always-ok` were killed by a SyntaxError, not
|
||||
by a test. Their replacement text was single-quoted, so it carried a
|
||||
literal `\n`. I fixed the quoting and reran both. Each is now killed by
|
||||
an assertion (the confined-shell table and the bwrap refusal test).
|
||||
- `launch-socket-always` survived: a coder session given the launch
|
||||
socket passed every test. The coder's sandbox couldn't reach the socket
|
||||
in any test, because no test asked. The launcher test above now reads
|
||||
the command lines, and the rerun kills it.
|
||||
|
||||
The table after the fixes, all 26 killed:
|
||||
|
||||
| Mutant | What it undoes | Killed by |
|
||||
|---|---|---|
|
||||
| no-workspace-bind | the workspace bind | confined shell table |
|
||||
| runDir-read-only | the run directory is read-write | bind order test |
|
||||
| bind-all-runs | the run bind is the run directory, not its parent | bind order test |
|
||||
| bundle-writable | `bundle/` read-only over the run directory | bind order test |
|
||||
| session-json-writable | `session.json` read-only | bind order test |
|
||||
| no-private-tmp | the private `/tmp` | bind order test |
|
||||
| bind-checkout | the checkout stays out | confined shell table |
|
||||
| node-modules-always | `node_modules` only for pi | bind order test |
|
||||
| runner-file-missing | a runner import left out of `RUNNER_FILES` | import walk |
|
||||
| no-remount-ro | the read-only root | confined shell table |
|
||||
| host-home | HOME in the run directory | confined shell table |
|
||||
| no-pid-namespace | the PID namespace | the PM launch test |
|
||||
| die-with-parent | bwrap's own `--die-with-parent` added | the host-died-hard test |
|
||||
| no-pdeathsig | `setpriv --pdeathsig KILL` removed | the host-died-hard test: the runner outlives bwrap |
|
||||
| exit-code-not-mapped | 128+n back to the signal | `sessionExit` test |
|
||||
| recover-matches-unshare | `recover()` matching bwrap's command line | the host-died-hard test |
|
||||
| no-sandbox-check | the `sandbox-unavailable` refusal | the bwrap refusal test |
|
||||
| probe-always-ok | the probe | the bwrap refusal test |
|
||||
| no-credential-check | the `no-session-credential` refusal | the credential refusal test |
|
||||
| proxy-key-alone | the proxy set needs its URL | credential tests |
|
||||
| proxy-key-ignored | the proxy set counts | credential tests |
|
||||
| any-variable-will-do | every variable of a set | credential tests |
|
||||
| claude-bin-not-bound | the pinned `claude` bind | the real Claude Code launch |
|
||||
| launch-socket-always | the launch socket only for the launching role | the PM launches a coder test |
|
||||
| unit-no-envfile | the unit's `EnvironmentFile=` | `host.test.mjs` |
|
||||
| unit-envfile-required | its leading `-` | `host.test.mjs` |
|
||||
|
||||
My three-mutant rerun overlapped Filbert's row 95 run for about a minute:
|
||||
I chained the busy check and the run in one command, so the run didn't
|
||||
stop when the check found Filbert's processes.
|
||||
|
||||
## Gate
|
||||
|
||||
My run on the candidate, in a worktree of `af01465c` holding the 13 paths,
|
||||
`TMPDIR` in my scratch (`gate.sh`, `gate-summary.txt`). Sequential, each
|
||||
package's own test command from its directory, then every
|
||||
`scripts/test-*.sh`, with `test-task.sh` under
|
||||
`DOCKER_HOST=unix:///nonexistent`. Nothing of another seat's was running:
|
||||
Filbert called the machine clear at 21:30:30Z and my check found no
|
||||
process. 2026-10-12T21:30:49Z to 21:34:10Z, all rc=0.
|
||||
|
||||
| Suite | Result |
|
||||
|---|---|
|
||||
| seat | 33 pass |
|
||||
| cli | 118 pass |
|
||||
| harness | 59 pass |
|
||||
| bus | 102 pass |
|
||||
| webui | 84 pass |
|
||||
| test-auth | 15 passed |
|
||||
| test-conductor | 17 passed |
|
||||
| test-config | 24 passed |
|
||||
| test-discord | 66 passed |
|
||||
| test-extension-package | 18 passed |
|
||||
| test-foundation | 44 passed |
|
||||
| test-queue | 27 passed |
|
||||
| test-release | 14 passed |
|
||||
| test-task | 26 passed; four Docker groups skip (adapter seam, workspace/capability, live task, live recall pair) |
|
||||
|
||||
No node suite skipped a test. The cli count includes the launch socket
|
||||
check that the third mutant rerun needed; this is its first unmutated run.
|
||||
One test made a compose network, `wt_default`, at 21:33:58Z; it was empty
|
||||
afterwards and I removed it. The worktree still checks 13/13 against the
|
||||
manifest after the run.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
614b128067559c0319bbf7dc41e51e821a2224f0cec39381866440d697d57438 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
|
||||
4b4a37e2b81490b01bdcb220949c63a213eacb7e7f3404310a74ee416b5997f1 packages/cli/tests/launcher.test.mjs
|
||||
de3e0d33e990b8157d383ccaee0f58ee7fa2ec0df67e6d072bfbe2709c16bc19 packages/harness/README.md
|
||||
bfb7ca89754e36f9f467b7ea1bad03b38ea34d5f9bf1e49000ec2643bad8c811 packages/harness/src/gate.mjs
|
||||
1da86919610090364d6617ad9a2d4393aa8768d07777477661e24d21a40e0fe1 packages/seat/README.md
|
||||
39702ce147a1d1e7fecaf7a57e34041728ad0412f2a841b925698ed00527ca9b packages/seat/src/sandbox.mjs
|
||||
c2e32e95884e734a92298d7d1e5e82445a91a7f5ca5c3d3741297d38c6c4edd6 packages/seat/src/session.mjs
|
||||
4fd2c5fd3fd5cca1e556925b72c12a1666fa96f71f821834c27aa4c243906b92 packages/seat/tests/sandbox.test.mjs
|
||||
0898f1db9e889d4a7ea1e266a450511c88127268db065be053462a4c2fe9d1c2 packages/seat/tests/session.test.mjs
|
||||
@@ -0,0 +1,16 @@
|
||||
start 2026-10-12T21:30:49Z base af01465c
|
||||
seat rc=0 21:30:49-21:30:53 ℹ pass 33 ℹ fail 0 ℹ skipped 0 0 marks
|
||||
cli rc=0 21:30:53-21:31:09 ℹ pass 118 ℹ fail 0 ℹ skipped 0 0 marks
|
||||
harness rc=0 21:31:09-21:31:20 ℹ pass 59 ℹ fail 0 ℹ skipped 0 0 marks
|
||||
bus rc=0 21:31:20-21:31:23 ℹ pass 102 ℹ fail 0 ℹ skipped 0 0 marks
|
||||
webui rc=0 21:31:23-21:32:19 ℹ pass 84 ℹ fail 0 ℹ skipped 0 0 marks
|
||||
test-auth rc=0 21:32:19-21:32:20 selftest: 15 passed, 0 failed
|
||||
test-conductor rc=0 21:32:20-21:32:27 selftest: 17 passed, 0 failed
|
||||
test-config rc=0 21:32:27-21:32:28 selftest: 24 passed, 0 failed
|
||||
test-discord rc=0 21:32:28-21:32:37 discord suite: 66 passed, 0 failed
|
||||
test-extension-package rc=0 21:32:37-21:32:38 extension package selftest: 18 passed, 0 failed
|
||||
test-foundation rc=0 21:32:38-21:33:25 selftest: 44 passed, 0 failed
|
||||
test-queue rc=0 21:33:25-21:33:57 queue suite: 27 passed, 0 failed
|
||||
test-release rc=0 21:33:57-21:34:08 selftest: 14 passed, 0 failed
|
||||
test-task rc=0 21:34:08-21:34:10 selftest: 26 passed, 0 failed
|
||||
end 2026-10-12T21:34:10Z
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Row 92 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/gate
|
||||
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,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# Row 92 (#1586) mutants. 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
|
||||
@@ -0,0 +1,30 @@
|
||||
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 1, ✖ packages/seat/tests/sandbox.test.mjs (59.818181ms))
|
||||
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.6731)
|
||||
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 2, ✖ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (31)
|
||||
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 1, ✖ sessionExit gives a runner's signal death back as the signal; a session credential is on)
|
||||
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, ✖ packages/cli/tests/launcher.test.mjs (57.324551ms))
|
||||
no-credential-check: killed (ℹ fail 1, ✖ a Claude Code launch with no session credential refuses before it starts (239.74836ms))
|
||||
proxy-key-alone: killed (ℹ fail 2, ✖ a Claude Code launch with no session credential refuses before it starts (226.683239ms))
|
||||
proxy-key-ignored: killed (ℹ fail 3, ✖ a Claude Code launch with no session credential refuses before it starts (134.150314ms))
|
||||
any-variable-will-do: killed (ℹ fail 2, ✖ a Claude Code launch with no session credential refuses before it starts (275.641647ms))
|
||||
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: survived
|
||||
unit-no-envfile: killed (ℹ fail 1, ✖ bus-service.sh renders the unit and installs it into a given directory (7.961969ms))
|
||||
unit-envfile-required: killed (ℹ fail 1, ✖ bus-service.sh renders the unit and installs it into a given directory (8.411344ms))
|
||||
-- 2026-10-12, after the run: bind-checkout and probe-always-ok had a quoting bug (their replacement held a literal \n, so the file didn't parse and the kill didn't count); fixed and rerun. launch-socket-always survived; a launcher test now checks the coder's bwrap command line, rerun --
|
||||
bind-checkout: killed (ℹ fail 2, ✖ a confined session's shell reaches its own workspace and run directory and nothing of th)
|
||||
probe-always-ok: killed (ℹ fail 2, ✖ a missing or failing bwrap refuses sandbox-unavailable before anything starts; there is )
|
||||
launch-socket-always: killed (ℹ fail 1, ✖ the PM launches a coder through its launch tool; the coder answers; refusals name their )
|
||||
@@ -0,0 +1,41 @@
|
||||
✖ a real Claude Code coder's shell can't read a stand-in secret or anything else its sandbox doesn't hold (1423.387435ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 1573.882071
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/launcher.test.mjs:295:1
|
||||
✖ a real Claude Code coder's shell can't read a stand-in secret or anything else its sandbox doesn't hold (1423.387435ms)
|
||||
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
|
||||
+ actual - expected
|
||||
|
||||
{
|
||||
"HOME is the run's home/": true,
|
||||
"read the PM's run directory": false,
|
||||
"read the data root's sessions file": false,
|
||||
'read the checkout': false,
|
||||
+ 'read the stand-in secret': true,
|
||||
- 'read the stand-in secret': false,
|
||||
'read the stand-in ~/.mosaic': false,
|
||||
'write and read /tmp': true,
|
||||
'write and read its workspace': true,
|
||||
'write outside': false
|
||||
}
|
||||
|
||||
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r92/wt/packages/cli/tests/launcher.test.mjs:345:10)
|
||||
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: true,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: { 'read the stand-in secret': true, 'read the stand-in ~/.mosaic': false, "read the PM's run directory": false, "read the data root's sessions file": false, 'read the checkout': false, 'write outside': false, 'write and read its workspace': true, 'write and read /tmp': true, "HOME is the run's home/": true },
|
||||
expected: { 'read the stand-in secret': false, 'read the stand-in ~/.mosaic': false, "read the PM's run directory": false, "read the data root's sessions file": false, 'read the checkout': false, 'write outside': false, 'write and read its workspace': true, 'write and read /tmp': true, "HOME is the run's home/": true },
|
||||
operator: 'deepStrictEqual',
|
||||
diff: 'simple'
|
||||
}
|
||||
Reference in New Issue
Block a user