R1 request ids on host-broker IPC, R2 launch.sock close bound, R3 dangling symlinks refused, notes 1-5. 42 files, +4679/-63, base915e00e5, gate at8dd5ff00. Candidate manifest digest fd21bdc2...eb02. Source uncommitted. Co-Authored-By: Claude Opus 5.5 <[email protected]>
18 KiB
Row 41 (#1523): slice 1 S6, candidate packet, round 2
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, and Sage's round 2 ruling (fix R1, R2
and R3). Base: 915e00e5 (base.txt). None of the 42 files changed between
the base and 8dd5ff00, where the round 2 gate ran, or 54cb7ab8. 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 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 getsid = ++seq, and the broker process echoes it on every launch-op, bind and startup reply (tag()inpackages/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 withinREQUEST_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.
- the waiting request refuses with
- 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.mjschecks 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 Piwriteand Claude CodeWrite, by relative and absolute path.notes.mdpoints outside,danglingpoints nowhere andinnerpoints to a missing name inside the workspace. All ofnotes.md,dangling/x,dangling/deep/xandinnerare refused. Once the targets exist,notes.mdis refused as outside andinneris allowed. A path under a regular file is refused asENOTDIR.pi-session.test.mjs"pi: a write through a dangling symlink is blocked, and nothing appears outside" runs a real Pi session'swritethrough the extension.notes.mdandgone/x.mdcome back as tool errors, a plainfine.mdlands in the workspace, and the outside directory stays empty.- Claude Code:
--restrictedstays 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
-
--restricted. The harness README and theadapters/claude/adapter.shcomment now say that the flag keepsCLAUDE.mdfiles (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
claudeand 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.shgives the same result for this round: 0 of 4 with the candidate, 4 of 4 without the flag. - "claude adapter: --restricted is always passed" runs the adapter
against a stand-in
-
launchPmskipsauthorizeLaunch. The cli README now says so:launches offdoesn't stopbus start --pm, because the human launches the PM, and the other launcher checks still apply. -
Policy parse.
runner.mjsreads and parses the policy inside thetry, so a missing or malformed policy exits 2 (usage) before the claim. Test: "a missing or malformed policy exits 2 before the claim". -
System prompt in argv. This is now a harness README "Limits" line. No code change.
-
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:
startRunnerinrunner.test.mjsnow 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,
+4679/−63. It applies cleanly to 8dd5ff00 with git apply --index, 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_callhandler, a throw blocks, a missing-erefuses to start, and a hang is bounded by the runner's wall clock plus theagent_endturn marker. Claude Code uses a command hook run astimeout -k 2 10 <gate> || exit 2with hook timeout 20;--bareis never passed.--restrictedis defence in depth for the gate, and it keepsCLAUDE.mdfiles 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 againstlaunch.max(every running session counts, the PM's too), then the broker's ownrole.launchauthorization. Every launch, refusal and end is a launch-log line and a broker event.mosaic launches offis 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> --pmlaunches the business'slaunch.byinstance without a window, andmosaic talkreaches it. The handover of Sage's T3 thread is the acceptance run below.
Changes from the plan
- 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.
- Verbs live in
packages/cli, notpackages/seat(lead decision 77).packages/seatkeeps the session module and the moved/prochelpers (proc.mjs). --pmtakes no argument. The PM is the business file'slaunch.byinstance; its harness and model come from resolution.- Runner exit 23 covers a
role.claimwith no answer as well asbrokerRetriesfailed polls. - 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 stopresend SIGTERM every second. isRunner(pid)iscmdline[1] === RUNNER, which skipsunshare'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 ashost-lost, which releases the role. A rebind that refuses withrun-endedlogs "was already ended". An unreadable or malformedsessions.jsonrefuses 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 blocksptracein 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 8dd5ff00 with this round's patch applied, in a detached worktree,
sequentially, each output in out/ (out/summary.txt). TMPDIR on the
scratch disk; DOCKER_HOST=unix:///nonexistent.sock, so nothing reaches
Docker. Round 1's outputs stay in this directory at 9b670e27.
| Suite | Pass | Fail |
|---|---|---|
| node: bus, business, cli, control-board | 74, 60, 82, 124 | 0 |
| node: conversation, discord, harness, ledger | 152, 178, 50, 78 | 0 |
| node: mosaic, queue, seat, tasks, webui | 69, 148, 27, 51, 14 | 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 1, cli gains 5 tests and harness 5.
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 fails the same two (out/base-test-task.txt, identical
PASS/FAIL lines), so they are the environment, not this candidate.
One edit came after the gate started: the R2 sentence in
packages/cli/README.md ("drops every open launch.sock connection …").
No suite reads that file, and it is the only file where the manifest and
the gated tree differ.
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'sexitrather thanclose, so its stderr can be unread whentest-queueF1 matches it.scripts/test-task.sh(:514-518): the live user-recall check needs Docker and isn't skipped when Docker is unavailable.