@sagentlab/navarch-runtime
v0.1.57
Published
Navarch machine-side session manager: claims delivery tasks and runs them through local coding agents.
Maintainers
Readme
navarch-runtime
The machine-side half of Navarch (WP-07): registers this machine with the
control plane, then loops claim → sandbox → agent CLI → report until you stop
it. Plain Node/TypeScript, no Next.js coupling, and no shared app-code coupling
— this directory is a self-contained package you can npx on any fresh
machine.
The machine operator selects Claude Code, OpenAI Codex, Google Gemini CLI, OpenCode, or an Agent Client Protocol (ACP) agent when connecting a worker. Any selected agent can run any project task; task eligibility depends on capabilities and project gates, not agent type — see "Choosing an agent" below.
See docs/agent-platform-project-plan.md
§3.8/§3.9/§3.11 and docs/navarch/implementation-plan.md
WP-07 for the design this implements, and
docs/navarch/schema-design.md §7 for
the API contract.
Quick start on a fresh machine
For a project machine, use the command generated by Onboard → Connect an agent or Project settings → Connect an agent. It includes a single-use, project-scoped enrollment token and the correct control-plane origin. The recommended npm package requires Node.js 20 or later.
Before generating the command, prepare the machine:
- install Node.js 20 or later (the standard installation includes
npmandnpx) and Git; - install the coding-agent CLI selected in Navarch —
claude,codex,gemini,opencode, ordshfor the default ACP integration — and complete its normal authentication flow; and - for Docker-backed sessions, install and start Docker and use an image that contains the selected agent CLI and the repository toolchain, with provider credentials supplied through the approved machine or project configuration.
The generated command installs/runs only the Navarch runtime. It does not install Git, Docker, or a coding-agent CLI, and it cannot sign in to the agent provider for you. Verify the host path before enrollment (substitute the CLI selected in the UI):
node --version # 20 or later
npm --version
npx --version
git --version
codex --version # or: claude / gemini / opencode / dsh --versionEvery command above must succeed before the long-running worker starts. See Choosing an agent for runtime-specific environment variables and isolation requirements.
# Run the two commands copied from Navarch. Their values resemble:
npx --yes @sagentlab/navarch-runtime@latest connect \
--token flmt_<single-use-token> --project <project-id> \
--name <machine-name> --agent codex \
--capabilities shell,browser-use --max-sessions 5 \
--api-base https://www.sagentlab.com --config-dir ~/.navarch/<project-slug>
npx --yes @sagentlab/navarch-runtime@latest supervise \
--config-dir ~/.navarch/<project-slug>Keep the same config directory for connect, supervise, doctor, and later
upgrades. connect stores the machine identity in <config-dir>/machine.json
with mode 0600; the raw machine token is not shown again. Do not copy that
file or the enrollment command into logs.
In another terminal, verify the machine without starting a second worker:
npx --yes @sagentlab/navarch-runtime@latest doctor \
--config-dir ~/.navarch/<project-slug>For the BYO-key sandbox path, follow the generated Claude/Docker command
exactly. Docker must be running, and NAVARCH_DOCKER_IMAGE must identify an
image containing Git, Node.js 20 or later, Claude Code 2.1.83 or later, and
the build/test tools required by the repository. The current default
node:20-slim image does not satisfy those requirements; a first-party image
is tracked in issue #229.
The complete public flow is in
the Navarch quickstart.
Install from source
Use this path for runtime development or when validating an unreleased runtime change:
git clone https://github.com/sagentlab/navarch.git
cd navarch/runtime
./install.sh
node bin/navarch.cjs connect <options copied from Navarch>
node bin/navarch.cjs supervise --config-dir ~/.navarch/<project-slug>The installer checks Node, installs the locked package dependencies, and builds
the runtime. The UI-generated npm commands remain the source of truth for the
token, project, agent, capacity, API origin, and config directory; translate
those same options to node bin/navarch.cjs for a source install.
Releasing a runtime change
Publication is triggered by the version, not by the code: a push to main that
changes runtime/package.json publishes that exact version of
@sagentlab/navarch-runtime and rolls it out. So a pull request that edits
runtime/src/** or runtime/bin/** without bumping the version merges green
and never reaches a machine. Bump runtime/package.json and
runtime/package-lock.json to the same new version in the pull request that
carries the change; CI's Runtime job fails the pull request when that bump is
missing, when the two manifests disagree, or when the version is one the base
branch tip or npm already carries.
playbooks/runtime-releases.md is the full
release contract — the guard's exact cases, the post-merge duplicate backstop,
and the recovery steps.
The operator-only register command enrolls a globally managed machine. It is
not the normal public onboarding path. register prints the machine auth token
once and stores the same machine.json identity used by connect. For
twelve-factor deployments (systemd EnvironmentFile, container secrets), set
NAVARCH_MACHINE_TOKEN and NAVARCH_MACHINE_ID directly.
Connecting an agent to one project
connect is the project-scoped sibling of register — "Connect an agent to
a project" (self-hosted-runner style, like a GitHub Actions self-hosted
runner token): a project owner mints a single-use token from the platform
UI (the project settings page's "Connect
an agent" panel, or the onboarding wizard's Agent step),
POST /api/projects/:id/enrollment-tokens, and pastes you the ready-to-run
command. Unlike register, no NAVARCH_ENROLLMENT_SECRET or
--owner-zone is needed — the token is already scoped to exactly one
project, and the resulting machine's project_bindings is set to that
project only (it will never be dispatched work from any other project).
npx @sagentlab/navarch-runtime connect --token flmt_<...> --project <project-id> \
--name my-agent-1 --agent codex --api-base https://www.sagentlab.com \
--config-dir ~/.navarch/my-agent-1
npx @sagentlab/navarch-runtime supervise --config-dir ~/.navarch/my-agent-1(@sagentlab/navarch-runtime is published to npm, so npx
@sagentlab/navarch-runtime <cmd> works on a fresh machine with no clone.
The from-source flow — git clone + ./install.sh + node bin/navarch.cjs
<cmd> — remains available for local development.)
Commands
| Command | Purpose |
|---|---|
| register --token <t> --name <n> [--agent claude-code\|codex\|gemini\|opencode\|acp\|qoder] […] | Registers this machine, saves its local agent choice, and prints the token once. |
| connect --token <t> --name <n> [--agent claude-code\|codex\|gemini\|opencode\|acp\|qoder] [--project <id>] […] | Connects this machine to one project, saves its local agent choice, and prints the token once. |
| start [--agent claude-code\|codex\|gemini\|opencode\|acp\|qoder] | Runs the daemon. A start-time agent choice overrides the saved choice. |
| supervise [--agent claude-code\|codex\|gemini\|opencode\|acp\|qoder] | Runs the daemon under the update supervisor, enabling drain-safe automatic updates and rollback. |
| doctor | Prints resolved config + Docker/registration status; no side effects. |
Running multiple agents on one machine
Every agent instance must have its own config directory. The directory contains
the machine identity, update state, managed runtime versions, and (by default)
session workspaces. Reusing it lets a later connect replace machine.json and
causes both processes to race over update and workspace state.
Pass the same --config-dir to connect and supervise for each instance:
npx @sagentlab/navarch-runtime connect <pebble enrollment options> \
--name pebble-agent-2816 --agent codex --config-dir ~/.navarch/pebble
npx @sagentlab/navarch-runtime supervise --config-dir ~/.navarch/pebble
npx @sagentlab/navarch-runtime connect <tobi enrollment options> \
--name tobi-agent-1521 --agent codex --config-dir ~/.navarch/tobi
npx @sagentlab/navarch-runtime supervise --config-dir ~/.navarch/tobiNAVARCH_CONFIG_DIR remains equivalent for service managers and environment
files. A supervisor also pins its starting identity into replacement workers,
so an accidental later edit to machine.json cannot change that running
instance during an automatic-update handoff.
Active-task guidance
Guidance added to a task that is already running is delivered on that
task's next lease heartbeat (every
NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS, five minutes by default). The runtime
stops the current agent process and starts a new agent turn with the
original task context plus all guidance received so far. This is a turn
restart, not a new dispatch:
- The task keeps the same session and lease, and lease heartbeats continue.
- The replacement turn uses the same worktree (and the same sandbox container in Docker mode), so committed and uncommitted changes from the interrupted turn remain available. It should inspect those changes before continuing.
- The replacement turn is not a resumed agent CLI conversation. The corrected prompt carries the prior task context and guidance instead.
- Transcript, token, and cost accounting is cumulative across turns: the upload includes every turn under a separate label, and reported token and cost totals include every turn. The lease is completed only after the corrected turn finishes.
The daemon logs restarting the agent turn in the same worktree when it
delivers guidance. Lowering the lease-heartbeat interval makes guidance arrive
sooner, but keep it comfortably below the 60-minute lease TTL.
Temporary DNS, connection, timeout, rate-limit, and server failures during a lease heartbeat are retried at the configured heartbeat interval while the last server-confirmed lease remains valid. A separate expiry timer aborts the agent at that deadline even if a renewal request is still pending. Successful renewals move the deadline; transport failures never extend it. Terminal lease rejections still abort immediately. This tolerance applies to lease heartbeats; completion and transcript upload failures retain their existing behavior.
Long-running jobs and recovery
There is no default total-duration cutoff. Successful lease heartbeats renew ownership independently of agent progress, so a healthy job can run for hours. The default watchdog instead requires 45 minutes of observed inactivity before stopping an agent. Stdout/stderr, changing CPU time, and changes in the child process tree restart that window. Silent CPU-intensive builds and tests count as active. On Unix hosts, agents use a separate process group so cancellation also stops their tools; Docker sessions stop their dedicated container.
The watchdog samples ps on the host or docker top for a container every
30 seconds. CPU activity is a liveness heuristic, not proof of useful progress:
a busy loop or repeated output can keep the job alive. Quiet external waits
may need a larger idle window or idle detection disabled. If process telemetry
is unavailable, silence alone does not trigger an idle kill; an explicitly
configured absolute limit and lease expiry still apply.
Operators can set the environment defaults above. Existing launch commands
that explicitly set NAVARCH_SESSION_TIMEOUT_MS (including older generated
Docker setup commands) retain that cap; remove it or set it to 0 to adopt
unlimited total runtime. Task creators/editors can set
max_runtime_ms and idle_timeout_ms through task creation, PATCH, or the task
MCP tools. A nonnegative integer overrides the machine default, 0 disables
that limit, and null inherits it. Values are read at the next claim and apply
to machine-runtime sessions; the separate managed hosted executor retains its
own budgets. An absolute limit covers all guidance/remediation turns within the
claim, rather than resetting with each turn. These are runtime limits, not
provider spending caps.
After a watchdog stop or lease loss during a turn, before worktree cleanup, the runtime attempts a private
local recovery checkpoint at
<workspaceRoot>/checkpoints/<project>/<task>/<session>/. It contains a Git
bundle of committed history, a binary patch for dirty tracked files, untracked
non-ignored files, and recovery instructions. The bundle excludes repository
configuration and broker credentials. Checkpoints are not pushed or uploaded;
timeout reports and runtime logs name their location. They survive session cleanup and need
manual recovery and removal on that machine. Ignored files, agent conversation
state, and checkpoints across machine/disk loss are not provided.
If checkpoint creation fails, the original working copy is retained instead of being force-deleted. The runtime removes session metadata and broker credentials, detaches the working copy without discarding dirty files, and writes a recovery marker. The janitor skips marked directories; a retry refuses to delete a marked working copy if detachment failed. Recover/remove it manually. Container teardown has a bounded timeout, and cancellation also stops the local Docker client, so an unresponsive daemon cannot keep the session waiting indefinitely.
Automatic and manual runtime upgrades
Run long-lived agents with navarch-runtime supervise. The worker advertises
its package version and updater protocol on each machine heartbeat. When the
control plane assigns an eligible release, it:
- Downloads the exact
@sagentlab/navarch-runtime@<version>tarball with npm lifecycle scripts disabled and verifies the control-plane-recorded SHA-512 integrity. - Stages it under
$NAVARCH_CONFIG_DIR/versions/<version>while existing sessions continue. - Stops claiming, reports zero available capacity, and waits for both an in-flight claim and every active session to finish.
- Atomically writes
pending-update.jsonand exits with the supervisor-only handoff code. The supervisor starts the staged binary with the same config directory and machine identity. - Commits the activation after the replacement completes its first successful
heartbeat, persisting
active-runtime.jsonso machine restarts keep using the verified release. A crash or two-minute health timeout before then restores the previous binary.
The control plane never supplies a package name, URL, path, or shell command:
only an exact semver and npm SHA-512 integrity for the fixed package name are
accepted. Rollouts are assigned by the machine's server-managed stable or
canary channel and deterministic rollout percentage. Set
NAVARCH_AUTO_UPDATE=off to keep telemetry but disable automatic activation.
Project owners can also request a same-version restart from the project Agents
page. Only a worker running under navarch-runtime supervise advertises this
capability. The restart command arrives on the normal machine heartbeat; the
worker stops claiming, reports zero capacity, drains active sessions, and exits
with a restart-only handoff code. The supervisor respawns the current verified
binary without staging an update. The control plane acknowledges the one-shot
command only after a heartbeat reports a different boot ID, preventing a stale
command from causing a restart loop.
Plain navarch-runtime start continues to work and reports available releases,
but deliberately does not activate them because no parent process would exist
to perform a safe handoff or rollback.
After publishing a runtime, an operator records the immutable npm metadata in
runtime_releases. Obtain the integrity with
npm view @sagentlab/navarch-runtime@<version> dist.integrity, then insert the
exact version, returned integrity, release channel, and desired rollout
percentage. Start with channel='canary' or a small rollout_percent; raising
the percentage keeps existing machines in the same deterministic cohort.
Manual fallback
SIGINT or SIGTERM now stops new claims and drains active sessions before
exiting. A second signal forces an immediate exit and can interrupt work, so use
it only when abandoning the active leases is intentional.
Use this sequence for an upgrade:
- Install or build the new runtime without stopping the existing process. For
a source checkout, update the checkout and run
npm ci && npm run buildinsideruntime/. For an npm deployment, select the exact version in the service command, for examplenpx --yes @sagentlab/navarch-runtime@<version> start. - In Navarch's Fleet view, wait until this machine shows
Sessions: 0/N. Check the daemon log once more for a newerclaimed taskmessage before proceeding. If it claimed another task, let that session finish too. - Stop the old process through its service manager, or send
SIGTERM/press Ctrl-C. Do not useSIGKILL(kill -9). Ensure the old process has exited before starting its replacement so two claim loops never run for one machine identity. - Run
doctorusing the same service environment andNAVARCH_CONFIG_DIR, then start the new version with that same environment. Do not runregisterorconnectagain: the existingmachine.jsoncontains the machine identity and token needed after the upgrade. Never print, log, or copy the contents ofmachine.jsonor its token into upgrade commands or diagnostics. - Confirm the startup log reports the expected machine, agent, and API base, then verify that Fleet shows the machine online with a fresh heartbeat.
For a supervised service, make the stop timeout long enough for step 3 to observe a normal exit, and keep the service's environment file/config directory unchanged across the deployment.
Configuration (NAVARCH_* env vars)
| Var | Default | Meaning |
|---|---|---|
| NAVARCH_API_BASE | http://localhost:3000 | Control-plane base URL. |
| NAVARCH_MACHINE_TOKEN / NAVARCH_MACHINE_ID | — | Skip register/the config file; twelve-factor auth. |
| NAVARCH_MACHINE_NAME | — | Used by register. |
| NAVARCH_ENROLLMENT_TOKEN | — | Alternative to register --token / connect --token. |
| NAVARCH_PROJECT_ID | — | Alternative to connect --project. |
| NAVARCH_CONFIG_DIR | ~/.navarch | Per-instance state root. Equivalent to --config-dir; use a different directory for every agent on the same host. |
| NAVARCH_WORKSPACE_ROOT | <config dir>/sandboxes | Persistent bare repo caches plus isolated per-session worktrees. |
| NAVARCH_MAX_SESSIONS | 5 | Local concurrent-session capacity cap — see src/capacity.cts. |
| NAVARCH_CAPABILITIES | shell,browser-use (docker-sandbox,shell in Docker mode) | Comma list reported at heartbeat/claim time. Docker mode does not advertise browser use until the configured image provides it. |
| NAVARCH_OWNER_ZONE | sagentlab | sagentlab or customer-<slug>-premises (project-plan.md §3.11). |
| NAVARCH_POLL_INTERVAL_MS | 30000 | Claim-loop poll interval. |
| NAVARCH_HEARTBEAT_INTERVAL_MS | 60000 | Machine-level heartbeat interval. |
| NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS | 300000 | Per-lease heartbeat interval; must stay well under the 60-minute lease TTL (schema-design.md §4). |
| NAVARCH_SESSION_TIMEOUT_MS | 0 (disabled) | Optional absolute runtime budget across agent turns in a claimed session. |
| NAVARCH_SESSION_IDLE_TIMEOUT_MS | 2700000 (45 min) | Stop after a continuously observed window with no stdout/stderr or process-tree CPU activity. 0 disables idle detection. |
| NAVARCH_OPENCODE_STARTUP_TIMEOUT_MS | 120000 (2 min) | Stop OpenCode when it produces no stdout/stderr during startup. This catches provider auth/quota failures that leave its CLI process alive. 0 disables the startup deadline. |
| NAVARCH_WORKTREE_CLEANUP_INTERVAL_MS | 3600000 (1 hour) | How often the runtime scans for session worktrees abandoned by a hard exit or host restart. |
| NAVARCH_WORKTREE_STALE_AFTER_MS | 86400000 (24 hours) | Minimum inactivity age before an abandoned session worktree is removed. Active sessions are always protected. |
| NAVARCH_GIT_AUTHOR_NAME / NAVARCH_GIT_AUTHOR_EMAIL | sagentlab / [email protected] | Git identity forced into session commits so host-level personal config is not inherited; override both for a project-authorized bot. |
| NAVARCH_SANDBOX_MODE | host | host uses the resources already available to the agent process. Set docker explicitly for container isolation. |
| NAVARCH_SANDBOX_PROFILE | trusted-development | Named security profile for Docker sessions (src/sandbox-profile.cts): trusted-development (image-default user, uncapped, open egress), untrusted-code (non-root, 2 CPU / 4g / 512 PIDs, deny-by-default egress, read-only shared git), elevated-verification (non-root, 4 CPU / 8g / 2048 PIDs, egress limited to GitHub plus package registries). The default is the exact pre-profile flag set. |
| NAVARCH_SANDBOX_EGRESS_NETWORK | (unset) | Docker network that enforces a profile's egress allowlist. Docker cannot filter by domain itself, so an allowlist profile without this fails closed to --network=none and records the denial. |
| NAVARCH_DOCKER_IMAGE | ghcr.io/sagentlab/navarch-sandbox-agent:0.1.0 | Version-pinned per-session image with Node 20, git, GitHub CLI, ripgrep, jq, SSH, and Claude Code 2.1.218. Override with an image tag or digest you control. |
| NAVARCH_AGENT | saved choice, then claude-code | Local choice of agent adapter: claude-code, codex, gemini, opencode, acp, or qoder. Overrides the choice saved by connect/register; start --agent has highest priority. |
| NAVARCH_RUNTIMES | resolved local agent choice | Comma list of installed/authenticated adapters advertised to dispatch. The control plane chooses among these per project/task. |
| NAVARCH_UPDATE_CHANNEL | stable | Release channel advertised by the worker (stable or canary); the server-managed machine channel remains authoritative. |
| NAVARCH_AUTO_UPDATE | on under supervise | Set off, false, or 0 to report releases without staging or activating them. Automatic activation is always off under plain start. |
| NAVARCH_CLAUDE_BIN | claude | Path/name of the Claude Code CLI binary. |
| NAVARCH_CLAUDE_EXTRA_ARGS | — | Comma list of extra CLI args appended after --mcp-config (Claude Code). --allowedTools/--disallowedTools layer rules onto auto mode; an explicit --permission-mode, --permission-prompt-tool, or bypass flag replaces the unattended default --permission-mode auto. Runtime sessions default to an empty --setting-sources list so machine/user/project hooks cannot leak into temporary checkouts. The generated host-mode settings retain only the user-level apiKeyHelper needed for authentication; supply --setting-sources=<sources> here to opt into other settings deliberately. |
| NAVARCH_CODEX_BIN | codex | Path/name of the Codex CLI binary. |
| NAVARCH_CODEX_EXTRA_ARGS | — | Comma list of extra CLI args appended after the generated MCP -c overrides and --json (Codex). |
| NAVARCH_GEMINI_BIN | gemini | Path/name of the Google Gemini CLI binary. |
| NAVARCH_GEMINI_EXTRA_ARGS | — | Comma list of extra CLI args appended after the generated MCP settings, stream-json, and unattended defaults (Gemini). |
| NAVARCH_OPENCODE_BIN | opencode | Path/name of the OpenCode CLI binary. |
| NAVARCH_OPENCODE_EXTRA_ARGS | — | Comma list of extra CLI args appended after run, the prompt, and the generated --format json argument. Explicit --format, --model, or --variant values replace the corresponding per-session default. |
| NAVARCH_ACP_BIN | dsh | Path/name of an Agent Client Protocol v1 stdio server. DeepSeek Harness is the default implementation. |
| NAVARCH_ACP_EXTRA_ARGS | --profile,acp | Comma list of arguments used to start the ACP server. Override this together with NAVARCH_ACP_BIN for another ACP-compatible coding agent. |
| NAVARCH_QODER_BIN | qoder | Path/name of the Qoder CLI binary. |
| NAVARCH_QODER_EXTRA_ARGS | — | Comma list of extra CLI args appended after the unattended defaults (--setting-sources, --permission-mode auto, --output-format json). An explicit --output-format, --permission-mode, --settings, --model, or --reasoning-effort here replaces the per-session default. Qoder selects its own account models; pin one via --model here or connect --model to override. |
| NAVARCH_MCP_CONFIG_PATH | — | Path to the platform MCP config passed as --mcp-config. |
| NAVARCH_WORKTREE_GUARD | on | Host-mode sessions get an adapter-native per-session worktree boundary guard (see below). Set off to disable it for every runtime on the machine — required to run OpenCode on the host. |
| NAVARCH_GUARD_EXTRA_ROOTS | — | path.delimiter-separated (: on POSIX) extra directories the worktree guard allows beyond the session worktree, shared bare repo, and temp dirs. |
Worktree boundary guard (host mode)
A machine typically runs several sessions concurrently (NAVARCH_MAX_SESSIONS),
each in its own git worktree. The runtime enforces the same boundary for all
supported coding agents, using each CLI's native enforcement point:
Every adapter receives an empty, session-owned GH_CONFIG_DIR and, when the
project supplies GitHub credentials, the lease-scoped GITHUB_TOKEN. This
keeps gh independent of the operator's selected account and prevents tasks
from reading unrelated GitHub credentials. Docker already mounts the session
metadata directory; Gemini's nested host sandbox receives the gh directory
as an explicit read-only mount.
- Claude Code: the runtime uses
--permission-mode auto, which sends approval decisions through Claude Code's background safety classifier. It does not blanket-preapprove the lease-scoped Navarch MCP tools. A generated settings file (src/worktree-guard.cts, passed as--settings) also installsbin/worktree-guard-hook.cjsas a fail-closedPreToolUseboundary hook. User, project, and local settings sources are disabled by default, preventing host-only hooks and plugins from leaking into unattended sessions; the explicit generated settings file remains active and carries forward only a user-levelapiKeyHelperwhen the machine uses one for authentication. - Codex: the runtime passes a one-off native permission profile with
approval_policy="on-request"andapprovals_reviewer="auto_review". Codex's OS sandbox grants read/write access only to the allowed roots and denies the surrounding multi-session workspace. Its network policy allows GitHub and GitHub-hosted content so authenticatedghand git operations required by the task stay inside the sandbox. Other destinations remain blocked and eligible escalations are decided by the automatic reviewer rather than waiting for human input.--ignore-user-configand an untrusted project-config override prevent a user or checked-in legacysandbox_modefrom silently disabling the generated profile; Codex authentication still comes fromCODEX_HOME, and repository instructions such asAGENTS.mdstill load. On macOS the profile also grants read access to/Library/Developerand/Applications(Xcode and CoreSimulator, whichxcrun simctlandxcodebuildload) and write access to~/Library/Developer(simulator devices and DerivedData) and~/Library/Caches/org.swift.swiftpm(SwiftPM repository, artifact, metadata, and manifest caches); the Claude hook gets the same write roots. Without these, iOS builds and simulator tests fail under the sandbox withaccess to /Library/Developer/PrivateFrameworks/CoreSimulator.framework is blocked. - Gemini: the runtime uses Gemini CLI's native sandbox with only the
current worktree, shared gitdir, session
ghconfig, and approved extra roots mounted. Theghconfig and lease MCP settings are read-only; header values remain environment-variable references rather than literals. Docker-mode sessions disable Gemini's implicit YOLO sandbox to avoid nesting it inside Navarch's already isolated session container. - OpenCode: the CLI cannot currently express a host-side boundary for all
shell side effects, so with the guard enabled it is resolved out of the
machine's advertised runtimes at startup — and a machine that offers only
OpenCode there refuses to start rather than claim tasks it can only fail.
Run OpenCode in Docker with an operator-owned image that contains an
authenticated
opencodebinary, or setNAVARCH_WORKTREE_GUARD=offonly when the whole machine is already isolated (the opt-out is machine-wide: every runtime on it loses the boundary). OpenCode receives a private, per-session config, project config discovery is disabled, and lease MCP headers are referenced through child-only environment variables instead of being copied into its config file or argv. - ACP agents: the protocol client rejects every unattended permission escalation. The agent remains responsible for enforcing its declared file sandbox; use Navarch Docker mode when the ACP server itself does not provide a trustworthy workspace boundary.
The resulting boundary is:
- File tools (
Read/Write/Edit/Glob/Grep/...) may only touch the session worktree, the project's shared bare repo, temp dirs, and anyNAVARCH_GUARD_EXTRA_ROOTS. Read-only tools may additionally read standard system prefixes (/usr,/etc, ...). Symlinks are resolved before the containment check. - Bash commands are screened lexically: absolute,
~/$HOME, and..-traversal path references must land inside the allowed roots or the system prefixes. - The rest of the workspace root — sibling sessions' worktrees, other
projects' bare repos, and the session's own metadata dir (lease-scoped MCP
config, the guard files themselves) — is denied outright. The only metadata
exception is the empty, read-only
ghconfig directory described above, so an agent can neither read another agent's checkout nor rewrite its own guard policy.
The Claude hook is a strong guardrail rather than a hard security boundary
because shell paths are screened lexically. Codex's permission profile is
enforced by its OS sandbox. For container-grade whole-process isolation use
NAVARCH_SANDBOX_MODE=docker; neither host guard is installed in Docker mode.
An operator-supplied --settings in NAVARCH_CLAUDE_EXTRA_ARGS, or an
explicit Codex permission/sandbox option in NAVARCH_CODEX_EXTRA_ARGS, takes
precedence over the generated policy.
Claude auto mode requires Claude Code 2.1.83 or later and an eligible account, model, and first-party Anthropic API provider. If those requirements are not met, Claude Code rejects auto mode instead of silently bypassing checks.
The auto-review launch paths were verified live on 2026-07-20 with Claude Code
2.1.214 (--permission-mode auto) and Codex CLI 0.144.1 (the generated
permission profile plus approvals_reviewer="auto_review"). Both completed an
unattended smoke task successfully.
Choosing an agent
Each worker advertises the agent CLIs it can actually execute. Existing
single-runtime installs keep using --agent/NAVARCH_AGENT; multi-runtime
workers set NAVARCH_RUNTIMES to the installed and authenticated adapters:
# Claude Code (default) — requires the `claude` CLI installed and
# authenticated on this machine (or NAVARCH_CLAUDE_BIN pointing at it).
export NAVARCH_AGENT=claude-code
# OpenAI Codex — requires the `codex` CLI installed and authenticated on
# this machine (or NAVARCH_CODEX_BIN pointing at it), analogous to the
# Claude Code prerequisite above.
export NAVARCH_AGENT=codex
# Google Gemini CLI — requires an authenticated `gemini` CLI (or
# NAVARCH_GEMINI_BIN pointing at it).
export NAVARCH_AGENT=gemini
# OpenCode — requires an authenticated `opencode` CLI (or
# NAVARCH_OPENCODE_BIN pointing at it). A guarded host refuses to start with
# only this runtime; use a Docker image containing OpenCode for the normal
# isolated path, or NAVARCH_WORKTREE_GUARD=off on an isolated machine.
export NAVARCH_AGENT=opencode
# Agent Client Protocol — defaults to DeepSeek Harness `dsh --profile acp`.
# Override NAVARCH_ACP_BIN / NAVARCH_ACP_EXTRA_ARGS for another ACP v1 server.
export NAVARCH_AGENT=acp
# Qoder — requires an authenticated `qoder` CLI (or NAVARCH_QODER_BIN pointing
# at it). Model selection is account-owned; NAVARCH_QODER_EXTRA_ARGS or
# `connect --model` can pin one explicitly.
export NAVARCH_AGENT=qoder
# Advanced compatibility mode: advertise every installed adapter. On a guarded
# host, `opencode` is dropped from this list (see the boundary notes above);
# the rest are advertised as written.
export NAVARCH_RUNTIMES=claude-code,codex,gemini,opencode,acp,qoderFor the legacy single-runtime setting, priority is start --agent →
NAVARCH_AGENT → the locally saved choice → claude-code.
NAVARCH_RUNTIMES expands what the worker advertises. For normal projects,
the locally selected NAVARCH_AGENT handles every claimed task. Sandbox
projects remain constrained to Claude Code.
All five BYO adapters implement the same AgentAdapter interface
(src/adapters/types.cts) and run either directly on the host or via
docker exec in the session's sandbox container, exactly like the Claude
adapter always has — session.cts picks one (src/adapters/index.cts's
selectAdapter) at the start of each session and passes agent_type
through to complete() unchanged by whatever happened during the run.
The control plane also resolves the project's model and the task's execution
profile on every claim. The runtime passes those values as per-session CLI
overrides (codex exec --model ... -c model_reasoning_effort=...,
claude -p --model ... --effort ..., gemini --model ..., or opencode run
--model ... --variant ...) and records the effective model, profile, and
effort on completion. Gemini currently uses its auto model default and does
not expose a reasoning-effort flag. OpenCode accepts any provider/model
reference (the platform default is opencode/x-preview-f-free, Ox Alpha Free
(Unlimited) on OpenCode Zen), or default to preserve the authenticated
account's selection. ACP sessions use the agent's advertised default model and
set the standard reasoning_effort configuration option when it is available.
Machine-wide extra arguments still configure other CLI behavior; dispatched
model policy wins.
Codex defaults to gpt-6-astra; Claude Code defaults to claude-opus-5-5.
Explicit project and steering/navarch.yaml model choices still win. A task's
explicit execution profile wins over the repository's per-task-type profile.
Otherwise, claim-time classification uses the task summary and metadata:
- Clearly scoped typo, spelling, link, wording, or label fixes use
fast/low. - Planning, retries, video work, tasks with at least three dependencies,
production operations, and descriptions indicating architectural or sensitive
changes use
complex/high. - Ambiguous work uses
standard/ the project effort baseline (mediumby default).
The classifier is deterministic and conservative; it never chooses critical
or deep automatically. Set an explicit profile to override its assessment.
Existing explicit task profiles remain explicit.
Explicitly selected Fable sessions pass --fallback-model claude-opus-5-5 for Claude Code's native
availability fallback.
Because native fallback excludes usage limits, a structured 429 usage-limit
rejection gets one additional Opus 5.5 attempt within the original timeout. It
resumes the saved conversation, or starts afresh only for a confirmed zero-turn
rejection. Missing transcripts after possible work, authentication/policy
errors, cancellation, and explicit fallback/session arguments do not trigger
that retry. If Opus 5.5 is also limited, the usual machine cooldown applies.
The runtime records the fallback in the transcript and reports Opus for the
retry and subsequent turns. Native multi-model turns still report the requested
primary model; their aggregate provider-reported usage includes both models.
Selecting Fable requires a Claude Code version supporting Fable 5.1 (v2.1.255 or later).
Astra token cost reporting uses a standard short-context API-equivalent estimate from OpenAI pricing, not the account's subscription charge or long-context/fast-mode uplifts.
The Codex CLI invocation was verified against codex-cli 0.144.1 on
2026-07-18. The runtime uses codex exec "<prompt>" --json and translates
the existing per-session MCP JSON into one-off -c mcp_servers.* overrides.
Machine and lease credentials are referenced through environment variables,
not placed in argv. The JSONL parser accepts the verified top-level
item.completed / turn.completed shape and retains the older msg
envelope as a compatibility fallback.
The Gemini adapter uses official headless --prompt and --output-format
stream-json flags. It resets pre-tool assistant text, retains the final
post-tool answer as the report, and reads aggregate input_tokens and
output_tokens from the terminal result event. Gemini does not report USD
cost, so an unknown value remains absent rather than becoming zero.
The OpenCode adapter uses opencode run "<prompt>" --format json, closes
stdin immediately, and normalizes text and step_finish JSONL events into
the shared report/token/cost boundary. It translates the lease MCP document
to OpenCode's remote-server config with OAuth disabled and environment-backed
headers. Missing accounting fields stay absent rather than becoming a
fabricated zero.
OpenCode also runs with --print-logs --log-level ERROR by default (see its
CLI flags), so provider errors
that are missing from assistant messages still reach the runtime. A rate or
usage limit in a provider error event or stream-error log stops the host
process or Docker container immediately. The runtime checkpoints local work,
records the error, and releases the lease with a failed session outcome.
Pending guidance does not restart that session. New claims on this machine
pause until the reported reset time (including relative resets such as
“in 23 days”), or for 15 minutes when no reset time is available.
The ACP adapter speaks the standard Agent Client Protocol
v1 JSON-RPC transport over stdio. It creates one protocol session in the task
worktree, converts Navarch's HTTP/stdio MCP entries, consumes semantic message,
thought, and usage updates, and fails permission requests closed because no
human is attached to a worker turn. DeepSeek Harness
ships that server as dsh --profile acp. This is intentionally not the
REST-based Agent Communication Protocol,
which is a different protocol now maintained as part of A2A.
Architecture
cli.cts
├─ register → api.registerMachine() → machine-store.cts (writes machine.json once)
├─ connect → api.connectMachine() → machine-store.cts (writes machine.json once)
├─ start
├─ MachineHeartbeatLoop (heartbeat-loop.cts) → api.machineHeartbeat() [every NAVARCH_HEARTBEAT_INTERVAL_MS]
└─ ClaimLoop (claim-loop.cts) → api.claim() [every NAVARCH_POLL_INTERVAL_MS, gated by CapacityTracker]
└─ runSession (session.cts), one per claimed lease, run concurrently up to NAVARCH_MAX_SESSIONS:
1. write prompt.md (prompt.cts renders the 4-layer context bundle)
2. api.issueSecrets() → held in memory only
3. fetch the project's bare repository cache and create a
unique git worktree for this session; optionally mount it
into Docker when NAVARCH_SANDBOX_MODE=docker
4. selectAdapter(config.agentType) (adapters/index.cts) picks one AgentAdapter
(adapters/types.cts) by NAVARCH_AGENT, then .run(...):
- claudeCodeAdapter (adapters/claude.cts) — `claude -p <prompt> --mcp-config <path>`
- codexAdapter (adapters/codex.cts) — `codex exec <prompt> --json -c mcp_servers.*=...`
- geminiAdapter (adapters/gemini.cts) — `gemini --prompt <prompt> --output-format stream-json`
- openCodeAdapter (adapters/opencode.cts) — `opencode run <prompt> --format json`
- acpAdapter (adapters/acp.cts) — ACP v1 JSON-RPC/stdio; defaults to `dsh --profile acp`
- qoderAdapter (adapters/qoder.cts) — `qoder -p <prompt> --output-format json`
heartbeating the lease every NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS throughout either;
a failed heartbeat aborts the run (kills the process) and marks the outcome as lease-lost
5. mapExitCondition (exit-conditions.cts) → redact.cts scrubs the transcript → upload.cts PUTs it
6. api.completeLease(), reporting agent_type: config.agentType
7. sandbox.wipe() when present; remove the session workspace
unconditionally (finally block)
└─ supervise → supervisor.cts starts the worker with IPC
└─ update directive → update-installer.cts stages exact npm release
└─ drain + exit 75 → supervisor activates candidate, waits for
healthy heartbeat, or rolls backadapter.cts (top-level) is now a backward-compat re-export of
adapters/claude.cts — new code should import adapters/index.cts (for
selectAdapter) or an adapter module directly.
api.cts is the single choke point for every HTTP call; nothing else in the
package talks to fetch directly for control-plane traffic.
Secret handling
- Task secrets come from
POST /api/broker/issueonce, at session start, and are held only in memory (session.cts) — never written to the host filesystem. - Inside the sandbox,
DockerSandbox.injectEnv()writesKEY=valuelines to a tmpfs-backed file (/tmp/session.env) overdocker exec -istdin — never adocker run -eflag (visible viadocker inspect) and never a host-side file. The container's/tmpand/runare tmpfs, so nothing persists pastwipe(). cloneRepo()wires a git credential helper that echoes$GITHUB_TOKEN— the literal token value never appears as a literal string in any argv the host'spscan see; only the variable reference does.- Before upload,
redact.ctsscrubs the transcript against every value the session'sSecretRegistryactually saw, plus pattern-based fallbacks (GitHub PAT shapes, PEM private keys, genericsk-...tokens) as defense-in-depth for values the registry didn't see directly.
Control-plane contracts
schema-design.md §7 and the matching control-plane routes define every HTTP
contract used by api.cts, including:
POST /api/machines/register— global machine registration via the operator-configured enrollment secret.POST /api/machines/:id/heartbeat— a machine-level heartbeat distinct from the per-lease heartbeat, needed becausemachines.last_heartbeat_at/statusmust update even when no task is claimed.POST /api/dispatch/:leaseId/transcript-upload-url— a signed R2 upload URL for the session's redacted transcript. The bucket is private; the legacypublic_urlresponse field is only an attested locator returned at completion, not an anonymous read URL.
POST /api/machines/connect is the project-scoped alternative: it redeems a
single-use token minted by an owner through the onboarding or Fleet UI.
Machine-authenticated routes answer a rejected identity in three distinct ways, and the runtime treats them differently:
- 503 — the control plane could not complete the token lookup. Transient; retried like any other failure. (Before this split a failed lookup came back as 401, so one database blip looked exactly like a revoked token.)
- 401 — the token is not recognized.
- 410 — this agent was removed from the fleet.
Three consecutive 401/410 heartbeats end the run: the runtime stops claiming, lets active sessions finish, prints how to re-enroll, and exits 78. The supervisor does not respawn on that code — a new process cannot fix a credential the control plane no longer honors.
What needs live verification
Most runtime behavior is covered offline. The Codex host adapter was also
probed against a real authenticated codex-cli 0.144.1; Docker and the full
production control-plane lifecycle still require live verification. Unit
tests cover:
api.cts— request/response shapes, error mapping, auth header handling (tests/api.test.cts).exit-conditions.cts— every exit-condition → complete/fail mapping, priority order, plus Claude's and Codex's usage/report parsing (tests/exit-conditions.test.cts).redact.cts— exact-value and pattern-based redaction (tests/redact.test.cts).capacity.cts— capacity math and acquire/release bookkeeping (tests/capacity.test.cts).config.cts— env var parsing and defaults for every selectable adapter (tests/config.test.cts).sandbox.cts— command construction (flags, env-via-stdin, credential-helper argv hygiene) against an injected fakeCommandRunner, plusisDockerAvailable()degrading tofalseinstead of throwing when Docker is absent (tests/sandbox.test.cts).adapters/index.cts#selectAdapter— picks the rightAgentAdapterfor everyNAVARCH_AGENTvalue, including the claude-code fallback for an unrecognized one (tests/adapters/index.test.cts).adapters/codex.cts— arg construction on both the host path (mockedspawn) and the docker-exec path (fakeCommandRunner), and usage/report-text attachment from fixed JSONL fixtures (tests/adapters/codex.test.cts).adapters/gemini.cts— host/Docker invocation, cancellation, sandbox/MCP credential hygiene, parser drift, and normalized report/usage extraction (tests/adapters/gemini.test.cts).adapters/opencode.cts— real fake-binary invocation covering the prompt, isolated config and MCP header references, JSONL normalization, timeout, lease cancellation, and fail-closed host boundary (tests/adapters/opencode.test.cts).adapters/types.cts— the shared process-result boundary is exercised for Claude, Codex, Gemini, and OpenCode (tests/adapters/conformance.test.cts).worktree-guard.cts+bin/worktree-guard-hook.cjs— generated settings/ config shape, native Codex permission-profile construction, and the hook's containment verdicts (in-worktree vs. sibling session vs. home dir, symlink escapes, Bash path screening, the fail-closed exit-2 protocol run as a real subprocess) (tests/worktree-guard.test.cts,tests/worktree-guard-hook.test.cts).
Not exercised by unit tests, and needing a real machine per the WP-07 DoD ("on a real machine: register → claim a seeded docs task → session runs Claude Code headless → PR opens on GitHub → complete lands with evidence; secrets absent from disk after exit"):
- Actually running
claude -pheadless and confirming its real--output-format jsonshape (WP-13).adapters/claude.ctsnow appends--output-format jsonby default and best-effort parses stdout as a single JSON result object shaped like{ type, subtype, is_error, result, session_id, total_cost_usd, usage: { input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens } }(seeexit-conditions.cts#parseClaudeJsonResultfor the exact assumption and fallback behavior). Unit tests (tests/adapter.test.cts,tests/exit-conditions.test.cts) cover the parsing and arg-construction logic against fixed JSON strings; what's untested offline is whether a realclaude -p --output-format jsoninvocation actually produces this shape. If it doesn't, parsing degrades tonullandtokensIn/tokensOut/costUsdfall back to 0 (session.cts's existing default) — never a thrown error — but cost/spend data silently goes missing until the shape is reconciled here. - A full Codex task that initializes the production MCP server, edits a worktree, pushes a branch, opens a PR, and completes its lease. The CLI flags, per-run MCP override keys, stdin behavior, and JSONL event/usage shape are now verified locally; the next dogfood run covers their production composition.
- A full Gemini task that uses an authenticated Gemini CLI to claim, edit, open a PR, report usage/evidence, and clean up its session sandbox.
- A full OpenCode task in an operator-owned Docker image that contains an authenticated OpenCode CLI, including lease MCP access, cancellation, PR creation, attribution, and cleanup.
- Whether Docker-mode Codex should opt into
--dangerously-bypass-approvals-and-sandbox. It is intentionally not a default: host mode is not an external sandbox, and silently disabling Codex's protections there would be unsafe. Operators can still add the flag explicitly throughNAVARCH_CODEX_EXTRA_ARGSon an isolated machine. - The full Docker sandbox lifecycle against a real Docker daemon (rootless
behavior, tmpfs env injection, git clone with a real PAT,
docker exectimeout/kill semantics underAbortSignal). - Lease-loss handling against a real dispatcher (heartbeat 404/409 on an
already-reassigned lease) —
session.ctsaborts the running adapter process and still attempts a best-effortcomplete()call, which the real server may reject; that path is untested against real semantics. - Transcript upload against a real signed R2 URL.
- The end-to-end "Connect an agent to a project" flow against a real
Supabase instance: an owner minting a token from
ConnectAgentPanel(or the onboarding wizard's Agent step),navarch-runtime connectredeeming it, and the resulting machine actually claiming a task scoped to that one project. Unit-tested here:connectMachine()'s request shape (tests/api.test.cts) and theconnectcommand's flag/env parsing (tests/cli.test.cts); not tested here: the real Postgres round trip (lib/navarch/__tests__/enrollment.test.tsand theapp/api/machines/connect/app/api/projects/[id]/enrollment-tokensroute tests cover that with a mocked admin client, not a live database). @sagentlab/navarch-runtimeis published to npm — everynpx @sagentlab/navarch-runtime ...command shown above (and inConnectAgentPanel) resolves the published package directly; the from-source flow (git clone+./install.sh+node bin/navarch.cjs connect ...) is an equivalent local-development alternative.
A note on .cts instead of .ts
Every source and test file in this package uses the .cts extension rather
than .ts. This isn't a style preference: the repo root's tsconfig.json
globs **/*.ts / **/*.mts into the Next.js app's own next build
type-check, and that check runs across the whole matched file set, not just
files reachable from a page — confirmed empirically while building this
package (a single stray .ts file under runtime/ failed the root
next build). WP-07's file ownership is scoped to runtime/ only, so
editing the root tsconfig.json's include/exclude was out of bounds.
.cts is a first-class TypeScript/Node extension (forces CommonJS output,
still allows normal import/export source syntax) that the root glob does
not match, making it a clean, zero-touch way to keep this package fully
isolated. Vite/Vitest's default esbuild transform filter also excludes
.cts by default — vitest.config.cts overrides it (esbuild.include) so
tests are actually type-stripped and run.
Inspect code dependencies
navarch-runtime code-graph --query saveInvoice --mode impact --commit HEAD
returns JSON with the indexed commit, checkout HEAD, dirty-worktree warning,
source paths and line numbers, and bounded dependency edges. Modes are search,
callers, callees, and impact; impact accepts --depth 1 through 5.
Use an exact node ID to disambiguate duplicate symbol names. A file query includes
its functions and file import dependencies. --repo defaults to the current
working directory. The CLI works without a Navarch login or network access.
This first implementation analyzes committed JavaScript and TypeScript with the TypeScript compiler. It resolves static imports and named functions/methods, including imported aliases. It does not execute repository code, follow symlinks, load tsconfig plugins, or upload source. Root tsconfig path aliases are supported; extended configs, dynamic dispatch, external dependencies and other languages are not fully resolved. Warnings report omissions. No graph is an assurance that a change has no other effects: inspect the actual diff and run relevant tests.
Each invocation rebuilds from Git blobs at the requested commit, avoiding stale persistent indexes. Bounds are 2,000 files, 10 MB total source, 500 KB per file, 100 returned nodes and 300 edges. A later persistent index can optimize large repositories without changing the query format.
Pinning a connection model
connect --agent codex --model gpt-6-astra saves the model alongside the local
machine identity. start and supervise reuse that model, including after worker
restarts. Their optional --model flag overrides the saved choice. The pin wins
over the project's session model for the selected adapter only; switching agent
types does not reuse another adapter's pin. Reconnect without --model to return
to project-controlled model selection. Older identities without a model keep
using the control-plane execution policy.
