Files
stack/packages/harness/README.md
T
jason.woltjeandClaude Opus 5.5 4da6815293 review loop: working repositories, git socket, push and review verdicts, row 93 (#1587)
Commits move as v2 bundles over bus-host/git.sock (decision 135). The bus
host keeps one bare working repository per business and project, seeds main
from the project root under a per-repository lock, fetches a pushed bundle
with fsck into a temp ref, requires history shared with main, and moves the
task's work ref only as a fast-forward. Host and runner git run with no
system or global config, hooks at /dev/null and local transports only. The
socket binds only for roles holding git.push.working or review.verdict.

The broker records a push as the action.allowed for git.push.working with
its commit, takes review.request for the latest push and one review.verdict
per request, and refuses task.close on a pushed task without an approve
verdict for the latest push. recordEvent reserves both shapes, so agents
can't forge either.

Reviewed by Dewey (28196) and Sage (28197), both approve. Gate on 7a729aef:
14 node suites and 9 scripts green.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 10:48:34 -05:00

282 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, built-in and typed
tools). The built-in tools are named as the harness names them, as the
manifest lists them: `limits.tools` for Pi, `claudeBuiltins` for Claude
Code (Read, Write, Edit, Grep, Glob).
- `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>`.
A reply goes to the model as compact JSON, from the MCP server and the Pi
extension alike (`replyText` in `tools.mjs`). Over `REPLY_CAP`, 40000
characters, the tool returns an error that names the reply's size and the
cap and asks the session to narrow the query, never a cut-off reply. The
pinned Claude CLI (2.1.296) saves an MCP result over 50000 characters to a
file outside the workspace, which the gate refuses to read (row 89, #1583;
`agents/dewey/work/queue-89/build.md` has how that size was found).
`list_tasks` (tracker holders) takes the broker's `tasks` filters, `done`,
`assignee`, `label`, `requirement`, `limit` and `after`, and sends
`done: false` when the session leaves `done` out, so its default is open
tasks, 50 at a time, without descriptions. `get_task` takes a `task_ref`
and returns that task in full. `packages/bus/README.md` has the reply.
The review loop (row 93, #1587) adds four tools:
- `push_working` (git.push.working holders with a clone) pushes a local
branch of one clone to the task's work branch,
`work/vikunja-<project>-<task>`, in the business's working repository.
The host takes only a fast-forward of what the task pushed before. The
reply is `{ref, commit, previous}`.
- `request_review` (review.request) names that commit and a reviewer role.
The broker refuses a commit that isn't the task's latest push.
- `fetch_review` (review.verdict holders with a clone) fetches the commit
a review request names into the clone of its project, as
`refs/remotes/working/<work ref>`. Nothing is merged or checked out.
- `review_verdict` (review.verdict) records `approve` or `changes`, one per
request.
`request_review` and `review_verdict` are broker verbs. `push_working`
(verb `working.push`) and `fetch_review` (verb `working.fetch`) are not:
the runner answers them itself through `git-tools.mjs`, which moves the
commits as a git bundle over the host's git socket
(`packages/cli/src/git-socket.mjs`). git there runs with no system or
global config and no hooks, the fetch has fsck on, and the bundle file in
the run's `/tmp` is deleted on every path. A session without a clone (no
`projects` in `typedTools`) gets neither of the two. `task_close` cites the
verdict id `review_verdict` returned; for a task with pushed work the
broker takes only an `approve` verdict for the latest push.
## 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. `launch` goes to the host's
launch socket, `push_working` and `fetch_review` to its git socket, and
every other tool to the broker.
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 the sandbox.** It gates the harness's own tool calls.
`bash`, where the role's tool limit grants it (S0 line 5), reaches what
the session's sandbox holds (`packages/seat/README.md`, "The sandbox"):
the system directories, the runner's own repository files, its
workspace and its run directory. The data root, other workspaces and
runs, `~/.config`, `~/.mosaic`, `~/.ssh`, `~/.claude` and the rest of
the checkout aren't there. `HOME` and `/tmp` are the run's own. The
session's credential variable is in its environment, so `bash` (or
Claude Code's `Bash`) can read it (`packages/cli/README.md`,
"Sessions").
- **Same UID.** The sandbox's user namespace maps the session to the
host's user. It can't reach the host's other files, but anything outside the
sandbox that it can talk to (below) acts with that user's full rights.
- **Network and abstract sockets aren't confined.** The session shares
the host's network namespace: it reaches the network, every service on
`127.0.0.1`, and every abstract Unix socket (a name starting with a NUL
byte, which no mount hides). A service listening there, an X server or a
session bus that offers one, acts for the session outside the sandbox.
The shared namespace also shows up in `/proc/net/unix`, which lists the
host's Unix sockets by name, path ones included; a name gives no access
to a path socket the mounts hide.
The policy's `network` field is recorded, not enforced.
- **The broker socket is bound in.** A session that can run `bash` can
connect to it; it still needs a capability to do anything there. The
tool socket is in the run directory and acts with the session's own
capability only. The launch socket is bound in only for the role whose
launch tool calls it. The git socket is bound in only for a role that
holds git.push.working or review.verdict, and every request on it needs
a capability the broker then checks.
- **Git tool timeouts disagree.** git in `git-tools.mjs` may run for 300 s,
but the tool call itself (`callTool`) gives up after 60 s and the tool
socket after 150 s. A push or fetch slower than 60 s reaches the model
as a timeout while the runner still finishes it. When it landed, the
clone's `refs/remotes/working` already has the commit, so pushing again
refuses `nothing-to-push`; `trail` on the task shows the
push, an `action.allowed` for `git.push.working`. Bundles are capped at 32 MiB, which keeps this
rare.
- **PID namespace.** Each session's runner is pid 1 of its own namespace
(bwrap `--unshare-pid --as-pid-1`), with its own IPC namespace. A
session sees no other process, so it can't signal or `ptrace` one by
pid. The runner doesn't reap zombies; turns are reaped by the runner's
own waits, and the namespace dies with it.
- **`mosaic stop`** is same-UID process control from outside the sandbox:
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.
- **The reply cap is per reply.** Claude Code also persists the largest
results of one message when their total passes 200000 characters, so
six or more replies near the cap in one turn can still be saved to a
file. A single task whose full row is over the cap (a description near
40000 characters) can't be read through `get_task`. The cap matches
Claude CLI 2.1.296; the binary the bus unit names updates itself, so a
later version can move the threshold.
- **`--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` outside the sandbox; 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.