# 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 ` \|\| 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 \|\| 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, harness, route, routeReason, harnessVersion, models, land, skillsDir})` 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). When the role file lists skills, a "Skills" section follows, holding every file of each listed skill between `` and ``, byte for byte, in sorted path order. - `skills//`: the bundle's copy of each listed skill, taken from `skillsDir` (`/skills-enabled`, which `scripts/skill.sh activate` fills). The prompt text is read from the copy, so a skill deactivated after the build leaves the run's copy as it was. A listed skill that isn't enabled refuses `skill-not-enabled`. A symlinked skill, `SKILL.md` or sub-file, a file name outside `[A-Za-z0-9._/-]`, non-UTF-8 bytes, a file holding ``, or a role whose skills add up to more than `SKILL_BYTES` (64 KiB; the prompt goes to the harness as one argv argument, and Linux caps one argument at 128 KiB) refuses `bundle-refused`, which the launcher reports as `skill-refused`. - `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 route taken (`primary` or `fallback`) and why (`routeReason`), the harness module's `version()` (`harnessVersion`), 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, and of the unslop check's files (row 66: `lists.json`, the checker, and pi's extension or Claude Code's hook script). - Claude Code only: `settings.json` (the wrapped PreToolUse hook, matcher `*`, and the two unslop hooks, Stop and UserPromptSubmit, each ending `; exit 0` so it never blocks) and `mcp.json` (one stdio server, `mosaic`, serving the typed tools). `harness` is the route's module from `packages/plugins/harness/`, which the launcher loaded; the bundle calls it for its own files and names and never names a harness itself (row 65). `sessionModel(resolved, models, which)` reads one route from the instance's vars only: `harness`, `model` and `thinking` for `primary`, the three `fallback.` vars for `fallback` (`ROUTE_VARS`). A missing var throws `no-route` and names it; the system's `execution.adapter` and `execution.model` never fill one, and stay for `run-task.sh`. `model` is a `launch.models` key, and the entry gives the provider and model. `hasFallback(resolved)` says whether the instance states a fallback. ## 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: `. 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. `request_staff` (row 140, #1634) is the `pm` definition's tool for asking the chief of staff for role sessions. It spends `message.send` through the `staff.request` verb and takes `task_ref`, `roles` (1 to 10 entries of `role` and `count`, 1 to 20) and an optional `note`. A pm has no `launch` tool, and the chief of staff's `launch` offer is still its launch block's instances. The chief of staff's two grants from row 123 have tools (row 132, #1628), and its `instance.name` grant (row 141, #1635) has `set_name` since row 155 (#1649), so its manifest lists all three in `typed` and its `untyped` list is empty. The tools: - `end_run` (role.end holders) sends `role.revoke` with `role` and a `reason` of 1 to 500 characters, never a `decision`. The broker refuses the caller's own run with `role-end-refused`. Since row 139 (Jason's ruling Q169) the chief of staff's end of the assistant gets past that check, and since row 155 (#1649) it revokes every assistant thread run; the tool's text and the prompt line say so. A `role.end` holder's prompt names the tool in place of the "only the human stops a run" line; every other role keeps that line. - `organization_revision` (organization.change holders, row 134) sends `organization.revision` with no arguments and returns `{revision}` alone, the value `change_organization` must send. A read never spends the grant. - `change_organization` (organization.change holders) sends `organization.set` with `revision` and the keys that verb takes, `limits`, `models`, `instances` and `assistants` (row 155: an assistant instance's `vars`, `tracker` or `null`, and `definition`, whose schema enum is `THREAD_DEFINITION`). `ORGANIZATION_KEYS` in `tools.mjs` is the broker's list; a test fails when the two differ. The description points the chief of staff at `organization_revision` for the revision, and the broker refuses a stale one with `revision-conflict`. - `set_name` (instance.name holders) sends `name.set` with `instance` and `name`. The broker checks the grant, the name pattern and the refusals `name-is-instance`, `name-is-role` and `name-taken`. 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--`, 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/`. 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. Row 137 (#1631) adds `land_work` (verb `working.land`, action `git.merge.protected`), between `push_working` and `request_review`. Only a role the broker's `landHeld` says holds an approved merge decision gets it, with or without a clone. It takes `task_ref` and `decision` and sends one `land` line to the git socket. The reply is `{landing: "started", commit, base, reviewed, patch_id}`, where `commit` is the squash that will land on the working repository's `main`. The gate runs after the reply, and the outcome comes later as a RESULT from the bus: the landing, or the refusal code. The role's prompt says so in one line. A second `land_work` while one runs refuses `land-busy`. ## 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 `, 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`, `fetch_review` and `land_work` to its git socket, and every other tool to the broker. 4. One harness turn per received message, through the adapter (`/bin/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: `; 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