Candidate manifest 08a78972 (42 files). Gate at 0a4c8f13.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
571 lines
29 KiB
Markdown
571 lines
29 KiB
Markdown
# Row 41 (#1523): slice 1 S6, candidate packet, round 4
|
||
|
||
Author: Filbert. Reviewer: Darkwing. Brief: `docs/plans/2026-10-04_slice-1.md`,
|
||
section "Slice 1 S6", blob `72d11de2`. Plan: `PLAN.md` here (b6d2fe2b).
|
||
Rulings: lead decisions 77 and 78, Sage's round 2 ruling (fix R1, R2
|
||
and R3), round 3 ruling (fix R4) and round 4 ruling (fix R5). Base:
|
||
`915e00e5` (`base.txt`). None of the 42 files changed between the base and
|
||
`0a4c8f13`, where the round 4 gate ran. The candidate source is uncommitted. There are no pushes. No token, private
|
||
binding or tracker host was read or written, and nothing touched the live
|
||
`mosaic-bus@mosaic-stack` unit or `~/.mosaic-dev/bus/`.
|
||
|
||
## Round 4
|
||
|
||
Darkwing's round 3 asked for changes (#1523 comment 27032, rev 271, packet
|
||
`agents/darkwing/work/s6-review/review-r3.md` and `r3/`, 974da6ed). R4 is
|
||
verified fixed. Sage ruled: fix R5 in this round; leave R1 to R4 alone, no
|
||
other scope. Four files changed since round 3, and none is new:
|
||
|
||
- `packages/harness/src/gate.mjs`, `README.md`
|
||
- `packages/harness/tests/gate.test.mjs`, `pi-session.test.mjs`
|
||
|
||
No source outside `insideWorkspace` in the gate changed.
|
||
|
||
### R5: a relative path is resolved from the real path of Pi's cwd
|
||
|
||
Both adapters `cd "$MOSAIC_WORKSPACE"` before they start the harness, and
|
||
a process's cwd is the real path (`getcwd`). Pi 0.85.1 resolves a relative
|
||
path lexically against `process.cwd()` (`resolveToCwd`), so `..` climbs the
|
||
real path's parents. Round 3's `insideWorkspace` resolved a relative path
|
||
against the workspace as given. With the workspace or the dataRoot behind
|
||
a symlink, the two climb different directories. In Darkwing's layout
|
||
(`$R/data -> $R/deep/store`, workspace `$R/data/ws`), `../../data/ws/X` is
|
||
`$R/data/ws/X` to the gate and `$R/deep/data/ws/X` to Pi. A real Pi
|
||
session read a file there and wrote a new one.
|
||
|
||
The fix: a relative path must resolve inside from both the workspace's
|
||
real path, as Pi resolves it, and the path as given. An absolute path is
|
||
unchanged. `spellings()` already built from both bases (R4), so `read`'s
|
||
other-spelling check needed no change.
|
||
|
||
The given-path check is a choice, and it is stricter than Pi. A path that
|
||
is inside for Pi but climbs out of the given path is refused, for example
|
||
`../../store/ws/x` when the workspace is `$R/a/ws -> $R/deep/store/ws`. I
|
||
kept it so the gate stays fail-closed whatever directory it runs in, and
|
||
doesn't rest on the adapter's `cd` alone. The cost is that such a path is
|
||
refused even though it is safe. A session has no need for it: the plain
|
||
relative path or the absolute one works.
|
||
|
||
### Claude Code isn't affected
|
||
|
||
Claude Code makes a `file_path` absolute against its own cwd before the
|
||
`PreToolUse` hook runs. Its cwd is also the real path, so `claude-gate.mjs`
|
||
gets the path Claude Code will open, and the absolute branch checks it.
|
||
Darkwing's `claude-cwd-dotdot.sh` shows this: the hook's refusal names
|
||
`<R>/deep/data/ws/secret.txt`, not the relative name. My rerun against
|
||
round 4 gives the same (below). The new check covers a relative path from
|
||
Claude Code too, if a later version passes one through.
|
||
|
||
### Tests
|
||
|
||
- `gate.test.mjs`, "a relative path climbs from the workspace's real path,
|
||
in both harnesses". Two layouts, each in Pi and Claude Code:
|
||
- workspace behind a symlink: `<dir>/a/ws -> <dir>/deep/store/ws`;
|
||
- dataRoot behind a symlink: `<dir>/data -> <dir>/deep/store`, workspace
|
||
`<dir>/data/workspaces/b/i`, the launcher's shape.
|
||
|
||
In each, the `../..` escape is inside as given and lands on a planted
|
||
file outside for Pi (the test asserts both). Read and write of the escape
|
||
are refused. Read and write of `../ws/...` or `../i/...`, through the
|
||
symlinked root and back in, are allowed. A path that is inside for Pi
|
||
but outside as given is refused.
|
||
- `pi-session.test.mjs`, "pi: a relative path climbs from the real path of
|
||
a workspace behind a symlink", and the same for a dataRoot. A real Pi
|
||
0.85.1 session runs through the adapter and the mock API, in the
|
||
symlinked workspace:
|
||
- the escape read and write come back as gate errors;
|
||
- the outside file's text never reaches stdout;
|
||
- the outside directory holds only the planted secret, and no
|
||
`planted.txt` appears in the workspace;
|
||
- the inside read returns its content, and the inside write lands.
|
||
|
||
`session()` in that file gains an optional workspace placement for this;
|
||
the other tests keep `<dir>/ws`.
|
||
|
||
### Mutants
|
||
|
||
Each was made on `gate.mjs`, restored from a copy and checked with `cmp`.
|
||
The base passes `gate.test.mjs` and `pi-session.test.mjs`, 19/19.
|
||
|
||
| Mutant | Change | Result |
|
||
|---|---|---|
|
||
| given | the given path only (round 3) | 16/3: the gate test and both Pi sessions |
|
||
| real | the real path only | 18/1: the gate test's stricter case |
|
||
|
||
The first draft of the gate test let `real` survive. The stricter case was
|
||
added for it.
|
||
|
||
### Darkwing's probes against round 4
|
||
|
||
`r3/probe/pi-cwd-dotdot.sh` and `claude-cwd-dotdot.sh`, run unchanged with
|
||
`WT` set to the round 4 build, both modes each. Outputs are in
|
||
`out/probe-pi-cwd-dotdot.txt` and `out/probe-claude-cwd-dotdot.txt`.
|
||
|
||
| Probe | Mode | Result |
|
||
|---|---|---|
|
||
| Pi | read | gate refuses `../../data/ws/secret.txt`; the tool result is the refusal; the secret isn't read |
|
||
| Pi | write | gate refuses `../../data/ws/planted.txt`; nothing is written outside or in the workspace |
|
||
| Claude Code | read | the hook refuses `<R>/deep/data/ws/secret.txt` (absolute) |
|
||
| Claude Code | write | the hook refuses `<R>/deep/data/ws/planted.txt`; nothing is written |
|
||
|
||
### Darkwing's other round 3 notes
|
||
|
||
Per Sage's ruling, nothing else changed this round. These go to
|
||
follow-ups:
|
||
|
||
- Mw, Mx, My and Mz survive the spelling tests. Each flips some of
|
||
Darkwing's spell-edge cases from refused to allowed: lowercase `am`,
|
||
two apostrophes, a path below a respelled self-loop, and the
|
||
normalised forms (`@`, NBSP before AM, `file://`, `%27`).
|
||
- Darkwing's fix 2: realpath the workspace in the seat's `workspaceDir`
|
||
(`packages/seat/src/session.mjs:46`), so a symlinked dataRoot never
|
||
reaches the harness as a given path.
|
||
- The Ma leftover (`fake-adapter.mjs`) reproduced, and stays a follow-up.
|
||
|
||
## Round 3
|
||
|
||
Darkwing's round 2 asked for changes (#1523 comment 27010, rev 259, packet
|
||
`agents/darkwing/work/s6-review/review-r2.md` and `r2/`). Sage ruled: fix
|
||
R4 in this round; R1 to R3 hold and stay closed. Six files changed since
|
||
round 2, and none is new:
|
||
|
||
- `packages/harness/src/gate.mjs`, `README.md`
|
||
- `packages/harness/tests/gate.test.mjs`, `pi-session.test.mjs`
|
||
- `packages/cli/tests/host.test.mjs`, `launcher.test.mjs`
|
||
|
||
No source outside the harness gate changed.
|
||
|
||
### R4: a read is checked under every spelling Pi's read would open
|
||
|
||
When the name it is given doesn't exist, Pi 0.85.1's `read` opens another
|
||
spelling. `resolveReadPathAsync` (`dist/core/tools/path-utils.js`) tries
|
||
these in order, and opens the first that exists:
|
||
|
||
1. ` AM.` or ` PM.` with U+202F before the `AM`/`PM`;
|
||
2. the NFD form;
|
||
3. `'` changed to U+2019;
|
||
4. both 2 and 3.
|
||
|
||
The `pi` binary runs the same code from `dist/bundle/`. In 0.85.1 only
|
||
`read` and the CLI's file arguments call it. `write`, `edit`, `grep`,
|
||
`find` and `ls` take the name as given. The gate checked only the given
|
||
name, so asking for `notes's.txt` could read a link `notes’s.txt` that
|
||
points outside.
|
||
|
||
The fix is `otherSpelling` in `gate.mjs`. It covers Pi's `read` and
|
||
Claude Code's `Read` (Darkwing's note 3):
|
||
|
||
- It builds every spelling from the workspace path and from its real
|
||
path. The adapter starts Pi with `cd "$MOSAIC_WORKSPACE"`, so Pi resolves
|
||
a relative name against the real path.
|
||
- A spelling that exists and resolves outside the workspace refuses the
|
||
read. So does one that goes through a dangling link.
|
||
- A spelling that doesn't exist is skipped, and so is ENOTDIR. Any other
|
||
`lstat` error refuses the read.
|
||
|
||
It doesn't depend on which spelling Pi would open. So a read is refused
|
||
even when the exact name exists inside and some spelling points outside.
|
||
The README's "Limits" says so, with the example of a sibling `jo’s/ws`
|
||
beside a workspace in `jo's/ws`. Its pin note lists what to recheck on a
|
||
Pi upgrade.
|
||
|
||
Tests:
|
||
|
||
- `gate.test.mjs`, "read is checked under every spelling pi's read would
|
||
open, in both harnesses". It asks for six names, relative and absolute,
|
||
in both harnesses, covering each family and their mixes:
|
||
- straight quote
|
||
- AM/PM
|
||
- NFD
|
||
- NFD with a quote
|
||
- NFD with a straight quote kept
|
||
- a quote with composed accents
|
||
|
||
Each is refused when its other spelling links outside. It is allowed
|
||
when that spelling links to a file inside. A `write` of the asked name is
|
||
allowed.
|
||
- `gate.test.mjs`, "other spellings cover directories, dangling links and
|
||
pi's cwd". These are refused:
|
||
- a directory spelling;
|
||
- a dangling spelling;
|
||
- a workspace given through a link, whose real path has a sibling
|
||
`jo’s/ws`.
|
||
|
||
A name with no other spelling is allowed, and so is ENOTDIR.
|
||
- `pi-session.test.mjs`, "pi: a read is refused when pi would open another
|
||
spelling outside". A real Pi 0.85.1 session runs through the adapter and
|
||
the mock API. It reads the six names, plus one whose other spelling is
|
||
inside:
|
||
- each of the six comes back as an error naming the other spelling;
|
||
- the outside file's text never reaches stdout;
|
||
- the inside one returns its content.
|
||
|
||
Darkwing's `r2/probe/pi-variant.sh` against this round refuses all four
|
||
modes: plain, quote, ampm and nfd. Plain is refused by the existing check.
|
||
|
||
Mutants: each was made on `gate.mjs`, restored from a copy and checked
|
||
with `cmp`. The base passes `gate.test.mjs` and `pi-session.test.mjs`,
|
||
16/16.
|
||
|
||
| Mutant | Change | Result |
|
||
|---|---|---|
|
||
| none | no other-spelling check | 13/3 |
|
||
| pibase | spellings from the given workspace path only | 15/1: Pi's cwd case |
|
||
| ampm | no AM/PM spelling | 14/2 |
|
||
| nfd | no NFD spelling | 14/2 |
|
||
| curly | no U+2019 spelling | 14/2 |
|
||
| nfdcurly | no NFD-with-U+2019 spelling | 14/2 |
|
||
| pionly | Pi's `read` only, not Claude's `Read` | 15/1 |
|
||
| danglingok | a dangling spelling is skipped | 15/1 |
|
||
| enotdir | ENOTDIR refuses | 15/1 |
|
||
| existsonly | a spelling that is a link is skipped | 13/3 |
|
||
|
||
The first draft of the tests let `nfd` and `curly` survive. Their names
|
||
had both features, so the combined spelling alone caught them. The two
|
||
single-feature names were added for that.
|
||
|
||
### Darkwing's other round 2 notes
|
||
|
||
1. **Mr.** `host.test.mjs` has a new test, "a broker reply with no
|
||
request waiting breaks the channel and the host exits 1". It binds a
|
||
coder, then has the broker child send an `identity` reply with an id
|
||
that no request waits for. The host logs "broker channel broken (reply
|
||
with no request waiting)" and exits 1, and a later op refuses with
|
||
`broker-channel-broken`. Under Mr (such a reply is ignored), the test
|
||
fails at its 5 s limit. No code changed.
|
||
2. **Mv.** Every `launcher.test.mjs` test now has a 30 s timeout. The
|
||
longest real run is 3.7 s. Under Mv the file ends in 62 s with 6
|
||
failed, 2 cancelled and 1 passed; round 2's run hung.
|
||
3. **Claude's `Read`.** It gets the same check (R4 above).
|
||
4. **The leftover after Ma.** Not traced this round. It goes to
|
||
follow-ups.
|
||
5. **Mq** still fails only at the 30 s test timeout. That is a failure,
|
||
not a hang, so I left it.
|
||
|
||
A related leftover, not from this candidate: a `conversation/src/shim.mjs`
|
||
from my round 2 gate's conversation suite was still running about an hour
|
||
later. It belonged to a removed worktree, under my scratch `TMPDIR`. It
|
||
ignored SIGTERM, and I killed it by PID with SIGKILL. The conversation
|
||
package is row 40's, so it goes to follow-ups for its owner.
|
||
|
||
## Round 2
|
||
|
||
Darkwing's round 1 asked for changes (#1523 comment 26995, rev 247,
|
||
packet `agents/darkwing/work/s6-review/` at `f19787ee`). Sage ruled that all three
|
||
findings are fixed in this round. R1 sits inside decision 77: it's trusted
|
||
IPC, with no socket verb, event kind or schema change. This round adds one
|
||
file, `packages/cli/tests/host.test.mjs`, which round 1 didn't touch.
|
||
|
||
### R1: host↔broker replies carry the request's id
|
||
|
||
- `host.mjs`: every request gets `id = ++seq`, and the broker process echoes
|
||
it on every launch-op, bind and startup reply (`tag()` in
|
||
`packages/bus/src/process.mjs`). A reply is taken only if its id is the
|
||
waiting request's.
|
||
- Any of these breaks the channel (`breakChannel`): no reply within
|
||
`REQUEST_TIMEOUT_MS` (10 s), a reply with another id, a reply with no id,
|
||
or a reply with no request waiting.
|
||
- Once the channel is broken:
|
||
- the waiting request refuses with `broker-channel-broken`, and so does
|
||
every later one, including those already queued;
|
||
- the host logs it and closes with exit 1, for the unit to restart;
|
||
- a launch waiting on the channel is refused, never allowed.
|
||
- Tests:
|
||
- `host.test.mjs` "a broker reply that misses the wait breaks the
|
||
channel …". The broker is SIGSTOPped with one request waiting and a
|
||
bind queued behind it. Both refuse, the broker is resumed and its late
|
||
reply answers nothing, the host exits 1, and a later request refuses.
|
||
- "a broker reply with another request's id, or none, breaks the
|
||
channel …" changes the id on the way out, so the broker echoes the
|
||
wrong one, or drops it.
|
||
- `end-launch.test.mjs` checks the echo, refusals included.
|
||
- The cli and bus READMEs say this.
|
||
|
||
Darkwing's `probe/desync.mjs` against this round: after the stall, p1 to p4
|
||
all refuse with `broker-channel-broken`, and the host logs "broker channel
|
||
broken (no reply within 10 s); stopping the host". The probe's second half
|
||
(two binds during a stall) then fails with `kill ESRCH`, because the host
|
||
has already stopped the broker. Round 1 cross-wired the replies there
|
||
(`b2 bind run-y (reviewer) got: {"run":"run-x"}`).
|
||
|
||
### R3: the gate refuses dangling symlinks
|
||
|
||
`gate.mjs` `real()` walks up with `lstat`, not `exists`, so a dangling
|
||
symlink counts as an existing name. It then takes the realpath of the
|
||
nearest existing ancestor, and an `ENOENT` from `realpathSync` means the
|
||
path reaches a dangling link. `decide()` refuses that as "path goes through
|
||
a dangling symlink", at any depth, even when the link points inside the
|
||
workspace. A write through the link would create its target wherever the
|
||
link names, and Pi's `write` creates missing parent directories first. A
|
||
link whose target exists is checked as before, so one pointing outside the
|
||
workspace is refused as outside it.
|
||
|
||
- `gate.test.mjs` "a dangling symlink is refused at any depth, in both
|
||
harnesses" covers Pi `write` and Claude Code `Write`, by relative and
|
||
absolute path. `notes.md` points outside, `dangling` points nowhere and
|
||
`inner` points to a missing name inside the workspace. All of
|
||
`notes.md`, `dangling/x`, `dangling/deep/x` and `inner` are refused. Once
|
||
the targets exist, `notes.md` is refused as outside and `inner` is
|
||
allowed. A path under a regular file is refused as `ENOTDIR`.
|
||
- `pi-session.test.mjs` "pi: a write through a dangling symlink is blocked,
|
||
and nothing appears outside" runs a real Pi session's `write` through the
|
||
extension. `notes.md` and `gone/x.md` come back as tool errors, a plain
|
||
`fine.md` lands in the workspace, and the outside directory stays empty.
|
||
- Claude Code: `--restricted` stays defence in depth. The gate refuses the
|
||
path whether or not the flag is set.
|
||
|
||
Darkwing's probes against this round:
|
||
- `pi-dangling.sh`: the dangling link gives "mosaic gate: write path goes
|
||
through a dangling symlink: notes.md", and the outside file stays absent.
|
||
With the target present, the write is refused as outside the workspace
|
||
and the file keeps its content.
|
||
- `claude-dangling.sh`: the hook refuses with the same reason, and the
|
||
outside file stays absent.
|
||
- `gate-edges.mjs`: the output matches round 1 except one line, `write
|
||
dangling/x`, which went from ALLOW to the dangling-symlink refusal.
|
||
|
||
The README's "Limits" gains a TOCTOU line: the gate checks a path when the
|
||
call is made, so a link that `bash` or another process of the same user
|
||
changes between the check and the open isn't seen. That reach is the same
|
||
as `bash` itself.
|
||
|
||
### R2: a half-open `launch.sock` client can't hold `close()`
|
||
|
||
The launcher keeps its `launch.sock` connections in a set. `close()` calls
|
||
`server.close` and then destroys each connection, before it waits for the
|
||
server to close. `launcher.test.mjs` "a launch client that never closes its
|
||
side doesn't hold the host's close" opens one client that was answered and
|
||
keeps its side open, and one that never sends. `close()` must finish within
|
||
5 s. Darkwing's `close-hang.mjs`, in both modes (`silent`, `line`), now
|
||
gives `closed 0` about 15 ms after `host.close(0)`. In round 1, the close
|
||
waited until the probe destroyed its client at 25 s. The cli README says
|
||
so.
|
||
|
||
### Notes 1 to 5
|
||
|
||
1. **`--restricted`.** The harness README and the `adapters/claude/adapter.sh`
|
||
comment now say that the flag keeps `CLAUDE.md` files (user, parent,
|
||
workspace) and auto-memory out of the prompt, and that the gate doesn't
|
||
rely on it for paths. Two tests cover it:
|
||
- "claude adapter: --restricted is always passed" runs the adapter
|
||
against a stand-in `claude` and checks argv. It runs without Claude
|
||
Code installed.
|
||
- "claude: CLAUDE.md files and auto-memory don't reach the model;
|
||
without --restricted they do" plants the four markers and runs the
|
||
real adapter against the mock API: none reaches the request. A copy of
|
||
the adapter without the flag sends all four.
|
||
|
||
Darkwing's `claude-memory.sh` gives the same result for this round: 0 of
|
||
4 with the candidate, 4 of 4 without the flag.
|
||
2. **`launchPm` skips `authorizeLaunch`.** The cli README now says so:
|
||
`launches off` doesn't stop `bus start --pm`, because the human launches
|
||
the PM, and the other launcher checks still apply.
|
||
3. **Policy parse.** `runner.mjs` reads and parses the policy inside the
|
||
`try`, so a missing or malformed policy exits 2 (usage) before the claim.
|
||
Test: "a missing or malformed policy exits 2 before the claim".
|
||
4. **System prompt in argv.** This is now a harness README "Limits" line.
|
||
No code change.
|
||
5. **Mutants.** Each new test was run against its mutant and fails. Each
|
||
source file was restored from a copy and checked with `cmp`.
|
||
|
||
| Mutant | Change | Round 1 | Round 2 |
|
||
|---|---|---|---|
|
||
| R1a | the host takes any reply while a request waits | (finding) | "another request's id, or none" fails |
|
||
| R1b | no reply timer | (finding) | the stall test fails at its 30 s test timeout instead of hanging |
|
||
| R2 | `close()` doesn't destroy connections | (finding) | the half-open test fails within its 5 s bound |
|
||
| R3 | round 1's `existsSync` walk in `real()` | (finding) | the gate and pi-session dangling tests fail |
|
||
| N1 | the adapter without `--restricted` | (note) | both claude-session tests fail |
|
||
| Mg | `founderCheck` returns null | runner test hangs | the unit test fails; the runner test fails at its 60 s clock |
|
||
| Mh | no SIGKILL after the stop timeout in `close()` | survives | "a runner that ignores SIGTERM is killed when the host closes" fails |
|
||
| Mi | no SIGKILL in `recover()` | survives | the died-hard "kills them" case fails |
|
||
| Mj | no 4096-byte stdin cap | survives | "no capability, or a malformed one, on stdin" fails |
|
||
| Mk | no `LINE_MAX` on `launch.sock` | survives | "an over-long launch request is refused at once …" fails |
|
||
|
||
How the new tests separate the mutants:
|
||
- **Mh and Mi** rely on a SIGSTOPped process, which ignores SIGTERM and
|
||
dies only on SIGKILL.
|
||
- **Mi:** in the died-hard test, the leftover session and its runner are
|
||
stopped before the next host starts. Without the stop, the runner exits
|
||
on its own once the broker is gone, which is why Mi survived round 1.
|
||
- **Mh** uses a test-only `stopTimeoutMs` (default 30000), set to 1 s.
|
||
- **Mg and Mj:** `startRunner` in `runner.test.mjs` now SIGKILLs a runner
|
||
still alive after 60 s. A regression fails there instead of hanging the
|
||
file.
|
||
- **The stall test** registers its SIGCONT hook before `host.close`. Hooks
|
||
run in order, so a stopped broker can't hold the cleanup.
|
||
|
||
## Files (`files.txt`, 42)
|
||
|
||
`build.patch` is `git diff --cached --binary 915e00e5` over those files,
|
||
+4998/−63. It applies cleanly to `0a4c8f13` with `git apply`, and
|
||
`sha256sum -c candidate-manifest.sha256` passes in that tree.
|
||
|
||
| Area | Files | What |
|
||
|---|---|---|
|
||
| Harness (new) | `packages/harness/` | bundle, typed tools, gate, Pi extension, Claude Code gate and MCP server, runner, tests, README |
|
||
| Adapters | `adapters/claude/adapter.sh` (new), `adapters/pi/adapter.sh`, `adapters/README.md` | Claude Code adapter; Pi takes `MOSAIC_EXTENSIONS` as `-e`, and `--no-approve` |
|
||
| Sessions | `packages/seat/src/session.mjs`, `proc.mjs` (new), tests, README | spawn under `unshare`, registry, launch log, `stop` |
|
||
| Launcher and verbs | `packages/cli/src/launcher.mjs` (new), `host.mjs`, `cli.mjs`, tests, README | the launch socket in the trusted host; `mosaic talk`, `stop`, `launches off\|on\|list`; `bus start --pm` |
|
||
| Broker | `packages/bus/src/{broker,process,runtime}.mjs`, `tests/end-launch.test.mjs`, README | trusted IPC launch ops; `Broker.endLaunch` |
|
||
| Other | `docs/TOOLS.md`, `scripts/mosaic`, `scripts/agent-host-dev.sh` | verb reference; host seats pass `--no-approve` (the DEFERRED entry) |
|
||
|
||
## Against the brief
|
||
|
||
- **Bundles, Pi then Claude Code** (`packages/harness/README.md`, "The
|
||
bundle"): prompt, policy, typed tools and a manifest from one resolved
|
||
instance. Each manifest names the S0 lines it relies on (`reliesOn`).
|
||
Skills: resolution runs without a skills source, so bundles list none
|
||
(README "Limits").
|
||
- **S0 lines** (README table): Pi blocks in a `tool_call` handler, a throw
|
||
blocks, a missing `-e` refuses to start, and a hang is bounded by the
|
||
runner's wall clock plus the `agent_end` turn marker. Claude Code uses a
|
||
command hook run as `timeout -k 2 10 <gate> || exit 2` with hook timeout
|
||
20; `--bare` is never passed. `--restricted` is defence in depth for the
|
||
gate, and it keeps `CLAUDE.md` files and auto-memory out of the prompt.
|
||
Line 5 (`bash`) is the tool limit and is written as a limit.
|
||
- **The PM launches through the broker, within `launch`**: the host's
|
||
launch socket checks the instance list, that the instance isn't already
|
||
running, the model family against `launch.max` (every running session
|
||
counts, the PM's too), then the broker's own `role.launch` authorization.
|
||
Every launch, refusal and end is a launch-log line and a broker event.
|
||
`mosaic launches off` is Jason's one word (`launch.revoke`). `mosaic stop
|
||
<run>` ends a session.
|
||
- **Founder credentials (REQ-CRED-2)**: the session environment is an
|
||
allowlist (`ENV_ALLOW`). The runner exits 20 before claiming if a known
|
||
founder variable reached it, or if a service the role needs has no usable
|
||
role token (the broker's credential status, metadata only).
|
||
- **The PM moves off T3**: `mosaic bus start <business> --pm` launches the
|
||
business's `launch.by` instance without a window, and `mosaic talk`
|
||
reaches it. The handover of Sage's T3 thread is the acceptance run below.
|
||
|
||
## Changes from the plan
|
||
|
||
1. **The capability goes to the runner as one stdin line**, not a 0600
|
||
file. It is written after the bind and is never on disk, in argv or in
|
||
the environment.
|
||
2. **Verbs live in `packages/cli`**, not `packages/seat` (lead decision
|
||
77). `packages/seat` keeps the session module and the moved `/proc`
|
||
helpers (`proc.mjs`).
|
||
3. **`--pm` takes no argument.** The PM is the business file's `launch.by`
|
||
instance; its harness and model come from resolution.
|
||
4. **Runner exit 23** covers a `role.claim` with no answer as well as
|
||
`brokerRetries` failed polls.
|
||
5. **The adapter runs as `/bin/sh <adapter.sh>`**, because the repository
|
||
keeps adapters 0644 and only the image sets the mode.
|
||
|
||
## Broker surface
|
||
|
||
No socket verb, no event kind and no schema change. The new launch ops are
|
||
trusted IPC from the host to the broker process: `identity`,
|
||
`authorizeLaunch`, `refuse`, `endLaunch`, `credentialStatus`, beside the
|
||
existing `bindLaunch`. `bindLaunch` records the run only after its checks,
|
||
and a rebind of an ended run refuses with `run-ended`, also across a broker
|
||
restart. The host gains `op(message)` and `beforeClose(fn)`.
|
||
|
||
## Process control
|
||
|
||
- **Early signals.** The runner installs its SIGTERM and SIGINT handlers
|
||
before anything else, because pid 1 of a PID namespace ignores a signal
|
||
with no handler. Node's own startup still leaves a window, so the
|
||
launcher and `mosaic stop` resend SIGTERM every second.
|
||
- **`isRunner(pid)`** is `cmdline[1] === RUNNER`, which skips `unshare`'s
|
||
forked child before its exec (its argv still names the runner).
|
||
- **Host lost.** A starting host reads the last host's `sessions.json`.
|
||
Before the launch socket opens, for each entry of this business it
|
||
SIGKILLs the session if it still runs, rebinds the run with the recorded
|
||
identity and ends it as `host-lost`, which releases the role. A rebind
|
||
that refuses with `run-ended` logs "was already ended". An unreadable or
|
||
malformed `sessions.json` refuses the host start with exit 3.
|
||
- **PID namespace limits** are in `packages/harness/README.md`, "Limits":
|
||
same UID, shared broker socket and `/tmp`, network not confined, and a
|
||
same-UID process can still ask something outside its tree (a systemd user
|
||
manager, an existing tmux server) to run a command. The README no longer
|
||
says the namespace blocks `ptrace` in general; it hides other sessions by
|
||
pid.
|
||
|
||
## `--no-approve` (DEFERRED: "Host seats launch Pi with `--approve`")
|
||
|
||
`scripts/agent-host-dev.sh` and `adapters/pi/adapter.sh` now pass
|
||
`--no-approve`. Pi 0.85.1 (`trust-manager.js`) gates project `.pi/`
|
||
resources on trust: `settings.json`, extensions, skills, prompts, themes,
|
||
`SYSTEM.md`, `APPEND_SYSTEM.md`. `-e` paths load in the "temporary" scope,
|
||
which trust doesn't gate. A scratch probe against a workspace saved as
|
||
trusted, with a mock Messages API, showed:
|
||
|
||
| argv | project `SYSTEM.md` | explicit `--skill` | generated prompt | `-e` extension |
|
||
|---|---|---|---|---|
|
||
| host-seat, `--approve` | loaded | loaded | loaded | loaded |
|
||
| host-seat, `--no-approve` | ignored | loaded | loaded | loaded |
|
||
| `adapters/pi/adapter.sh` | ignored | loaded | loaded | loaded |
|
||
|
||
The skill appears only when the session has a tool to read it; the probe's
|
||
first run used `--no-tools` and showed no skill in any case, the
|
||
`--approve` control included.
|
||
|
||
`agent-host-dev.sh` keeps `--append-system-prompt`, not the entry's
|
||
"explicit system prompt", because its comment preserves Pi's built-in coding
|
||
prompt on purpose, and `--no-approve` already closes the project
|
||
`SYSTEM.md`. I didn't edit `docs/plans/DEFERRED.md`: it holds another
|
||
seat's uncommitted changes. The entry can close when this lands.
|
||
`scripts/test-goal-native.py` still passes `--approve` on purpose (it tests
|
||
project extensions) and is untouched.
|
||
|
||
## Gate
|
||
|
||
Run at `0a4c8f13` with this round's patch applied, in a detached worktree.
|
||
`sha256sum -c candidate-manifest.sha256` passed there (42 OK). Suites ran
|
||
sequentially, each output in `out/` (`out/summary.txt`). `TMPDIR` was on
|
||
the scratch disk and `DOCKER_HOST=unix:///nonexistent.sock`, so nothing
|
||
reached Docker. Round 1's outputs stay in this directory at `9b670e27`,
|
||
round 2's at `779e9780` and round 3's at `0f38236f`. Darkwing's R5 probe
|
||
outputs from round 4 are `out/probe-pi-cwd-dotdot.txt` and
|
||
`out/probe-claude-cwd-dotdot.txt`.
|
||
|
||
| Suite | Pass | Fail |
|
||
|---|---|---|
|
||
| node: bus, business, cli, control-board | 74, 60, 83, 124 | 0 |
|
||
| node: conversation, discord, harness, ledger | 161, 178, 56, 78 | 0 |
|
||
| node: mosaic, queue, seat, tasks, webui | 69, 148, 27, 51, 22 | 0 |
|
||
| test-auth, conductor, config, discord | 15, 17, 24, 66 | 0 |
|
||
| test-extension-package, foundation, queue, release | 18, 44, 27, 4 | 0 |
|
||
| test-task | 26 | 2 |
|
||
|
||
Against round 3, harness gains 3 tests (R5). Conversation (161) and webui
|
||
(22) grew because row 40 landed (08b428ec); none of their files is in this
|
||
candidate.
|
||
|
||
The two `test-task` failures are "user recall run succeeds (exit 1)" and
|
||
"recalled user name". That check runs a live worker and needs Docker. The
|
||
unpatched base at `0a4c8f13` fails the same two (`out/base-test-task.txt`,
|
||
identical PASS/FAIL lines), so they come from the environment, not this
|
||
candidate. No test process was left running after the gate.
|
||
|
||
## Acceptance run: waiting
|
||
|
||
The recorded run (the host started with `--pm`, `mosaic talk` asks the PM
|
||
to launch a coder, `mosaic agents` shows both claims) waits on the Vikunja
|
||
move to tasks.woltje.com (Q14), as Sage ruled: the integration commit and
|
||
the acceptance run wait for that cutover; build and review continue. It
|
||
will not run on Astra (R26) or against the live unit. The tracker host
|
||
comes only from `vars.tracker.baseUrl`; no code, test or launcher config
|
||
names one, and fixtures use 127.0.0.1 and example.test.
|
||
|
||
## Follow-ups (not in this row)
|
||
|
||
- `packages/queue/tests/commit.test.mjs`, `pausedCommit`, awaits the
|
||
child's `exit` rather than `close`, so its stderr can be unread when
|
||
`test-queue` F1 matches it.
|
||
- `scripts/test-task.sh` (:514-518): the live user-recall check needs
|
||
Docker and isn't skipped when Docker is unavailable.
|
||
- Darkwing's round 2 note 4: after the Ma mutant's harness run, a
|
||
`fake-adapter.mjs` was left running. Which test spawned it, and whether
|
||
that test cleans up when it fails, isn't traced.
|
||
- `packages/conversation` (row 40): a `shim.mjs` from a gate's
|
||
conversation suite outlived the run, and it ignored SIGTERM.
|
||
- Darkwing's round 3 survivors Mw, Mx, My and Mz in the gate's spelling
|
||
check (lowercase `am`, two apostrophes, a path below a respelled
|
||
self-loop, the normalised forms). Each needs a test; Sage kept them out
|
||
of round 4.
|
||
- Realpath the workspace in the seat's `workspaceDir`
|
||
(`packages/seat/src/session.mjs:46`), Darkwing's round 3 fix 2.
|