The broker gains a read-only state verb (launch state with the event that set it, open tasks capped). The runner fetches it before each turn and puts a "Current state" block at the top of the request; a failed read fails the turn. turnRequest() adds the message time. roles/pm.md gets one rule: the block is the truth for launch state and task status. Seat session, seat sandbox and cli launcher tests re-anchored to the new request shape. Built by Researcher, packet875c515e9. Filbert approved round 3, comment 28333, candidate manifest 0a62225e (11 paths). Sage gate on9774d70dbplus row109.patch: every package node suite and every scripts/test-*.sh green on the first run, skipped 0 in bus, harness and seat. Vikunja #259. Co-Authored-By: Claude Opus 5.5 <[email protected]>
291 lines
17 KiB
Markdown
291 lines
17 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, 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`.
|
||
|
||
Row 109 (#1603): before each turn the runner calls the broker's `state`
|
||
verb and puts a block at the top of the request, headed `Current state,
|
||
read at <time>`: the launch state with the time and id of the event that
|
||
set it, and the open tasks (refs and titles, at most the stated cap, with
|
||
the total). The block is the truth over any older message. The message
|
||
header after it names when the message was sent. A failed read fails the
|
||
turn without running the adapter: the sender gets `The turn failed: could
|
||
not read the current state: <code>`, and the message is 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.
|