Files
stack/packages/harness
jason.woltjeandClaude Opus 5.5 11dae0c7d8 feat(bus): the bus host runs an explicit Claude Code binary (#1561, row 68)
MOSAIC_CLAUDE_BIN names the claude binary by absolute path instead of
finding it on PATH. scripts/bus-service.sh render|install --claude PATH
writes it into the unit (default: command -v claude in the installing
shell); the claude adapter, the launcher's version read and the seat
session environment all use it. Mos's Q18 ask: the unit's PATH found
/usr/bin/claude 2.1.259, which refuses claude-opus-5-5.

Candidate: round 2 manifest 21c1ab05, round 1 (a2008d8b) rebased onto
f2f2861b after row 55 changed docs/TOOLS.md and packages/cli/README.md;
the changed lines are byte-identical. Approved by Filbert (27349, 27374)
and Dewey (27350, 27375). Sage's gate on f2f2861b plus the patch: cli
112, seat 27, harness 57, nine scripts/test-*.sh 0 failed, Docker on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:42:23 -05:00
..

harness

What a managed role session runs from and runs as (slice 1 S6, issue #1523): the launch bundle generated from one resolved role instance, the tool gate both harnesses share, the typed bus tools, the Pi extension, the Claude Code hook and MCP server, and the session runner. The host's launcher (packages/cli/src/launcher.mjs, packages/seat/src/session.mjs) builds the bundle and starts the runner; nothing here spawns a session on its own.

Plain ESM, no dependencies, Node 24 or newer. Tests: npm test in this directory (the Pi and Claude Code session tests drive the real CLIs against a scripted Messages API on 127.0.0.1; the Claude Code ones skip when claude isn't on PATH).

What S6 relies on

Every harness layer below is a row S0 line (docs/plans/2026-10-04_slice-1.md, "What S6 can rely on"), and each bundle's manifest.json names the lines it relies on (reliesOn):

Pi Claude Code
1. block a tool tool_call handler returns a block PreToolUse command hook exits 2; --bare is never passed
2. gate crash a throw in the handler blocks the hook runs as <gate> || exit 2
3. gate missing a missing -e file refuses to start the wrapper blocks
4. gate hang the runner's wall clock plus the agent_end marker timeout -k 2 10 <gate> || exit 2, hook timeout 20
5. bash limited only by the tool limit limited only by the tool limit

Claude Code also runs with --restricted: no user, project or local settings, file tools confined to the working directory, and no CLAUDE.md file (user, parent or workspace) or auto-memory in the prompt. None of the S0 lines depend on it, and the gate doesn't rely on it for paths. It is the layer that keeps founder and repository memory out of the session's prompt, though, so claude-session.test.mjs fails if the adapter stops passing it, and shows the memory reaching the model without it.

The bundle

buildBundle({dir, resolved, contract, arbiter, tracker, credentials, session, piVersion, claudeVersion}) writes the bundle once into dir (0700; every file 0600, created with wx, so an existing bundle is never overwritten):

  • prompt.md: the role contract, then a generated "This session" section (business, instance, run, who launched it, workspace, typed tools).
  • policy.json: harness, workspace, built-in tools (limits.tools), typed tool names, network, the services the role needs, and the credential status the host read from the broker (metadata only, never a token).
  • tools.json: the typed tools (below).
  • manifest.json: bundle version, the instance and run, the resolved digest, harness, provider, model, thinking, the Pi or Claude Code version, built-in and typed tools, the actions without a typed tool, the S0 lines, the gate timeout, and the sha256 of every bundle file and of the gate, extension, MCP server and tools sources.
  • Claude Code only: settings.json (the wrapped PreToolUse hook, matcher *) and mcp.json (one stdio server, mosaic, serving the typed tools).

sessionModel(resolved) picks the harness, provider and model: the agent layer's harness and model variables win, else the system's execution adapter and model. Only pi and claude-code are accepted.

Typed tools

A session acts on the bus only through typed tools. Each one maps onto one broker verb, or for launch onto the host's launch socket. A session gets a tool only for an action its resolved instance holds (within-role or cross-role), plus the reads and raise_decision. The broker still decides every call; a tool being present is not permission.

The harness never holds the capability. Tools call the runner's tool socket (tools.sock, 0600) with {tool, args}; the runner checks the name against tools.json and forwards the call with its own capability. A refusal comes back to the model as refused: <code>.

The gate

gate.mjs decide(policy, tool, input) answers one tool call: the policy's built-in tools and typed tools pass, anything else is blocked, and every path argument of a file tool (and a find/Glob pattern) must resolve inside the workspace after symlinks and Pi's own path normalisation. A path that reaches a dangling symlink, at any depth, is refused even when the link points inside: a write through it would create the target wherever it names, and Pi's write makes the missing directories first. Pi calls it from the extension, Claude Code from claude-gate.mjs.

A relative path is resolved the way Pi resolves it: against its working directory. Both adapters cd into the workspace, and a process's working directory is the real path, so .. climbs the real path's parents. With the workspace or the dataRoot behind a symlink, those differ from the parents of the path as given, and ../../data/ws/x can be inside as given but outside for Pi. The gate requires a relative path to be inside from both the real path and the path as given. The second check is stricter than Pi: a path that only climbs out and back in through the real path's parents is refused. That keeps the gate fail-closed whichever directory it runs in. Claude Code isn't affected the same way: it makes a path absolute against its own (real) working directory before the PreToolUse hook sees it, so the gate gets the path Claude Code will open.

Pi's read doesn't always open the name it is given. When that name doesn't exist, it tries other spellings of the whole resolved path: a narrow no-break space (U+202F) before AM. or PM., the NFD form, a curly apostrophe (U+2019) for ', and NFD with the curly apostrophe. So for read, the gate also checks every one of those spellings that exists as a name, built from both the workspace as given and its real path (Pi's working directory). If any of them resolves outside the workspace or goes through a dangling symlink, the read is refused, whichever one Pi would pick. A spelling that doesn't exist is ignored. Claude Code's Read gets the same check, though in Darkwing's round 2 probe Claude Code's own symlink check already stopped the one variant it tries (AM/PM). write, edit, grep, find and ls take the name as given in Pi 0.85.1, so they get no such check.

The gate copies Pi 0.85.1 code, so recheck these files on a Pi upgrade: dist/utils/paths.js (normalizePath), dist/core/tools/path-utils.js (resolveToCwd, resolveReadPathAsync) and which tools in dist/core/tools/ call resolveReadPath*. The pi binary runs the bundled copy under dist/bundle/; in 0.85.1 it holds the same code, and only read and the CLI's own file arguments call resolveReadPath*.

The runner

node runner.mjs <run dir>, with {"cap": "..."} and a newline on stdin. The host writes the capability there after the broker bound the launch, so it never touches the disk and isn't in the environment or argv.

  1. Founder-credential stop (REQ-CRED-2): a known founder credential variable in the environment (GITEA_TOKEN, GH_TOKEN, SSH_AUTH_SOCK and the rest of FOUNDER_ENV), or a service the role needs with no usable role token, exits 20 before the role is claimed.
  2. role.claim.
  3. The tool socket serves the typed tools.
  4. One harness turn per received message, through the adapter (/bin/sh <adapter.sh>: the repository keeps adapters 0644 and only the image sets the mode), in its own process group, under the wall clock (turnTimeout, default 900 s). A turn counts only if it exits 0 and, on Pi, the extension wrote the turn marker at agent_end (S0 line 4). Its stdout goes back to the sender as a RESULT; a failed turn replies The turn failed: <reason>; an incoming RESULT never gets an automatic reply. Then message.read.
  5. SIGTERM or SIGINT kills the turn's process group, releases the role and exits 0. The handlers go in before anything else, because pid 1 of a PID namespace never sees a signal it has no handler for; a stop before the claim exits 0 without claiming. Node's own startup still leaves a short window, so the host and mosaic stop send SIGTERM again every second until the runner is gone.
Exit Meaning Launch end reason
0 stopped stopped
2 bad run directory or stdin failed
20 founder credentials founder-credentials
21 role.claim refused claim-refused
22 the capability or claim stopped working (run ended, claim revoked) session-invalid
23 role.claim got no answer, or the broker stayed unreachable for brokerRetries polls (default 30) broker-unreachable

Each turn's stderr goes to turns/NNNN.stderr (0600) in the run directory.

Limits

These are limits, not bugs, and nothing in this package claims otherwise.

  • The gate is not a sandbox. It gates the harness's own tool calls. bash, where the role's tool limit grants it, can reach anything the OS user can (S0 line 5).
  • Same UID. Every session runs as the host's user. Founder files in HOME (~/.git-credentials, the tea config, ~/.ssh) are readable through bash; only the environment is filtered. /tmp is shared. The PID namespace (below) hides other processes, so a session can't signal or ptrace another one by pid.
  • The broker socket is shared. A session that can run bash can connect to it; it still needs a capability to do anything there. The tool socket is reachable through bash as well, and acts with the session's own capability only.
  • Network. Abstract Unix sockets and the network are not confined; the policy's network field is recorded, not enforced.
  • PID namespace. Each session's runner is pid 1 of its own namespace (unshare --user --map-current-user --pid --fork --kill-child --mount-proc). It doesn't reap zombies; turns are reaped by the runner's own waits, and the namespace dies with it. A same-UID process can still ask something outside its tree (a systemd user manager, an existing tmux server) to run a command.
  • mosaic stop is same-UID process control: it checks the registry's start time and that the process is a runner before sending SIGTERM.
  • If the host dies, sessions keep running until their broker polls fail brokerRetries times, then exit 23. The next host kills what still runs and ends those launches as host-lost (packages/cli/README.md, "Sessions").
  • Resolution runs without the project layer, there is no skills source (bundles list none), and the resolved thinking level is recorded in the manifest but not applied to either harness.
  • --restricted (Claude Code) is not one of the S0 lines. It is what keeps CLAUDE.md files and auto-memory out of the prompt (above).
  • Other spellings. A read is refused when a spelling Pi might open points outside the workspace, even if the name as given exists and is inside, and Pi would open that one. The same holds when a directory next to the workspace has another spelling of its path, for example jo’s/ws beside a workspace in jo's/ws.
  • The gate checks a path when the call is made. A link created or changed between the check and the tool's own open (by bash, or by another process of the same user) isn't seen. That is the same reach as bash itself.
  • The system prompt is in argv for both adapters, so the same UID can read it in /proc/<pid>/cmdline; the PID namespace hides it from other sessions. It holds no secret.
  • Pi adapter paths with spaces break its unquoted -e and skill splitting; the launcher's paths don't contain spaces.