Slice 1 S6. packages/harness runs a role session under the pi or claude
adapter behind a tool gate, and packages/seat holds the session and its
process. The bus host gains a launch socket: `mosaic bus start
<business> --pm <harness>:<model>` runs the PM without a window, the PM
launches role sessions through the broker within the business file's
`launch.max`, and every launch, refusal and end is a launch-log line and
a broker event. `mosaic talk`, `mosaic launches off|on|list` and
`mosaic stop <run>` are new CLI verbs. packages/bus gains the IPC ops
identity, authorize, refuse, endLaunch and credentialStatus, plus
Broker.endLaunch (decision 77); no socket verb, event kind or schema
changed. The session environment is an allowlist, and the runner exits
before claiming if a founder credential reaches it.
Filbert built it (agents/filbert/work/s6). Darkwing reviewed on #1523:
round 1 changes (26991/26995), round 2 changes (27004/27010), round 3
changes (27012/27032), round 4 approve (27033/27034) on manifest
08a78972, 42 paths.
Sage's gate on 64b0f9f1 (12:32Z), sequential and teed: node suites
business 60, bus 74, cli 83, control-board 124, conversation 182,
discord 178, harness 56, ledger 78, mosaic 69, queue 149, runs 41,
seat 27, tasks 51, webui 39, every one 0 failed; test-auth 15,
test-conductor 17, test-config 24, test-discord 66,
test-extension-package 18, test-foundation 44, test-queue 27,
test-release 14, test-task 98 and 26 without Docker, all 0 failed.
64b0f9f1..ee7f909b touches none of the 42 paths. It landed after the
Q14 hold ended at 2026-10-11T17:19:23.857Z. The recorded PM-to-coder
run on a scratch data root is still owed before row 41 moves to done.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
207 lines
11 KiB
Markdown
207 lines
11 KiB
Markdown
# 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.
|