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, 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.toolsfor Pi,claudeBuiltinsfor 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<skill-file skill="NAME" path="REL">and</skill-file>, byte for byte, in sorted path order.skills/<name>/: the bundle's copy of each listed skill, taken fromskillsDir(<dataRoot>/skills-enabled, whichscripts/skill.sh activatefills). 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 refusesskill-not-enabled. A symlinked skill,SKILL.mdor sub-file, a file name outside[A-Za-z0-9._/-], non-UTF-8 bytes, a file holding</skill-file>, or a role whose skills add up to more thanSKILL_BYTES(64 KiB; the prompt goes to the harness as one argv argument, and Linux caps one argument at 128 KiB) refusesbundle-refused, which the launcher reports asskill-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 (primaryorfallback) and why (routeReason), the harness module'sversion()(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 0so it never blocks) andmcp.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: <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.
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) sendsrole.revokewithroleand areasonof 1 to 500 characters, never adecision. The broker refuses the caller's own run withrole-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. Arole.endholder'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) sendsorganization.revisionwith no arguments and returns{revision}alone, the valuechange_organizationmust send. A read never spends the grant.change_organization(organization.change holders) sendsorganization.setwithrevisionand the keys that verb takes,limits,models,instancesandassistants(row 155: an assistant instance'svars,trackerornull, anddefinition, whose schema enum isTHREAD_DEFINITION).ORGANIZATION_KEYSintools.mjsis the broker's list; a test fails when the two differ. The description points the chief of staff atorganization_revisionfor the revision, and the broker refuses a stale one withrevision-conflict.set_name(instance.name holders) sendsname.setwithinstanceandname. The broker checks the grant, the name pattern and the refusalsname-is-instance,name-is-roleandname-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-<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, asrefs/remotes/working/<work ref>. Nothing is merged or checked out.review_verdict(review.verdict) recordsapproveorchanges, 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 <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.
-
Founder-credential stop (REQ-CRED-2): a known founder credential variable in the environment (
GITEA_TOKEN,GH_TOKEN,SSH_AUTH_SOCKand the rest ofFOUNDER_ENV), or a service the role needs with no usable role token, exits 20 before the role is claimed. -
role.claim. -
The tool socket serves the typed tools.
launchgoes to the host's launch socket,push_working,fetch_reviewandland_workto its git socket, and every other tool to the broker. -
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 atagent_end(S0 line 4). Its stdout goes back to the sender as a RESULT; a failed turn repliesThe turn failed: <reason>; an incoming RESULT never gets an automatic reply. Thenmessage.read.Row 109 (#1603): before each turn the runner calls the broker's
stateverb and puts a block at the top of the request, headedCurrent 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 getsThe turn failed: could not read the current state: <code>, and the message is read.Row 66 (#1559): the unslop check. The runner sets
MOSAIC_UNSLOP_LOGto<run>/unslop.jsonl, after the harness module's variables (in the run directory itself, since a thread run has no<run>/session/). Pi's second extension and Claude Code's Stop and UserPromptSubmit hooks (tools/unslop-hook) check each reply againstlists.jsonand logcheckedorflagged(each finding names its pattern id, the entry'ssource),notice_injectedwhen the next turn gets the correction notice, andbroken_lists,skipped_brokenorhook_failedwhen there is no verdict. After each turn the runner reads what the log gained, and the next RESULT it sends ends with a note naming the flagged replies; a broken check is told once a run. The whole note, both parts together, is at most 600 characters, and the answer before it is clipped at 32000, so the RESULT stays under the bus's 32768. A turn that sends no reply (an incoming RESULT, a landing turn) keeps its note for the next reply, and so does a send that failed. That RESULT is whatmosaic talkandmosaic followprint, so the operator sees the note there.unslopCounts(path)reads a log after the session ended: replies checked, flagged, findings per pattern id, notices, and the first reason the check broke. The check never blocks a reply (Q84 option B is a later decision), and the session can write its run directory, so the log is the session's own report, not the host's evidence. -
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 stopsend 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,~/.claudeand the rest of the checkout aren't there.HOMEand/tmpare the run's own. The session's credential variable is in its environment, sobash(or Claude Code'sBash) 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'snetworkfield is recorded, not enforced. - The broker socket is bound in. A session that can run
bashcan 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, or holds a merge decision it could land, and every request on it needs a capability the broker then checks. - Git tool timeouts disagree. git in
git-tools.mjsmay 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'srefs/remotes/workingalready has the commit, so pushing again refusesnothing-to-push;trailon the task shows the push, anaction.allowedforgit.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 orptraceone by pid. The runner doesn't reap zombies; turns are reaped by the runner's own waits, and the namespace dies with it. mosaic stopis 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
brokerRetriestimes, then exit 23. The next host kills what still runs and ends those launches ashost-lost(packages/cli/README.md, "Sessions"). - Resolution runs without the project layer. The route's
thinkinglevel reaches the adapter asMOSAIC_THINKING: pi gets--thinking, Claude Code--effort(adapters/README.md). - 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 keepsCLAUDE.mdfiles 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/wsbeside a workspace injo'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 asbashitself. - Skills are prompt text, not harness skills. Pi keeps
--no-skillsand Claude Code keeps--disable-slash-commands, so neither harness discovers or loads a skill itself; the role reads its skills in the prompt. A skill's scripts or assets are text there too, never run. - The system prompt is in argv for both adapters, so the same UID can
read it in
/proc/<pid>/cmdlineoutside the sandbox; the PID namespace hides it from other sessions. It holds no secret. - Pi adapter paths with spaces break its unquoted
-eand skill splitting; the launcher's paths don't contain spaces.
File-backed project records (row138)
Every agent gets read_spec(project,commit?), which returns the committed north-star text and path history. A role holding record.write also gets write_record(project,path,record,decision?), for validated docs/requirements/ decisions committed as its bound instance. The broker supplies author metadata; there is no author argument and no agent spec writer. A direct attempt to write spec/ refuses even if a caller bypasses the offered tool schema.
raise_decision accepts optional project, spec_path and spec_commit. Cite both spec fields together, using the current confirmed file commit returned by read_spec, not the repository HEAD. Uncited raises remain valid. Later human spec commits do not reopen decisions already settled.
raise_decision also accepts optional domain, technical or delivery (row 149, #1643). A routed raise, cross-role or gated with a current spec citation, goes to the business arbiter for that domain. Without domain the broker uses delivery, and the tool sends no domain key.