Execution boundary
Execution boundary
Only packages/runner may execute commands or mutate a target repository at runtime. The boundary is responsible for validating working directories, arguments, timeouts, output limits, and execution mode. A transport handler, source-control adapter, model provider, or state transition must request runner work through typed contracts rather than invoking a shell directly.
observe is the default mode. It can inspect and report but cannot write. Enabling fix requires both an explicit mode and repository policy permission.
RepositoryBoundary holds the filesystem and git behavior shared by every runner; subclasses decide only how a repository command is executed. LocalRunner runs it on the host, ContainerRunner runs it in an ephemeral sandbox. Both report a RunnerDescription that is recorded in evidence, so a claim of isolated verification is auditable rather than assumed.
Git inspection runs in the trusting process because its argv is fixed by the runner package. Repository-supplied commands are the untrusted ones, and those are what isolation moves into a sandbox.
createRunner and runnerOptionsFromPolicy are the only mapping from policy to a concrete boundary. runnerOptionsFromPolicy declares the policy fields it needs structurally, so the runner does not depend on the configuration package. Composition roots call both; nothing else constructs a runner.
Hosted sandboxes follow the same rule. RunnerPool lives in packages/runner, accepts credential-free SandboxRequest values, enforces global/repository quotas and lease ceilings before provisioning, and returns only a Runner. Provider credentials are constructor state of a vendor adapter and never enter a request, lease snapshot, agent state, or log. A composition root may schedule and release a lease, but it cannot execute a command itself.
Model transports follow the same adapter rule. packages/models owns the AI SDK integrations for OpenAI, Anthropic, Google, AI Gateway, and OpenAI-compatible endpoints behind one ModelProvider contract. Composition roots pass the validated provider policy; credentials come only from fixed provider-specific environment variables, and a custom endpoint can only come from the operator-owned AGENT_ZERO_MODEL_BASE_URL environment variable. The agent runtime sees neither SDK objects nor credentials, and all adapters share one structured-output, usage-accounting, timeout, and error-redaction path.
Two of those transports are subscription-based: claude-code and codex-cli drive a vendor CLI that is already logged in on the host, so modelProviderCredentialKind reports subscription and there is no credential for a composition root to supply, redact, or persist. They are the only transports whose SDK spawns a subprocess, which is why three things hold: each stays inert unless its operator flag is exactly true, the vendor SDK is imported lazily so an unused transport costs nothing, and the CLI is configured with its own tools disabled (Claude Code) or read-only with approvals off (Codex) so it cannot read outside the supplied context or edit a checkout behind the runner boundary.
packages/models still contains no child_process import of its own — it never spawns anything itself, matching every other package outside packages/runner. subscriptionProbeCommand returns the liveness command as a string for zero doctor to run through the runner like every other command, and the CLI process behind a live decide() call is spawned the same way: modelFromEnvironment takes an optional ClaudeCodeProcessSpawner, and packages/cli and packages/api supply one backed by packages/runner's spawnManagedProcess — the streaming counterpart to execFileProcessRunner, for a caller that needs a live duplex process instead of one buffered result. Wired that way, the claude-code transport's CLI process is spawned through the same boundary as every repository check, not through the vendor SDK's own default child_process.spawn. codex-cli cannot be closed the same way: ai-sdk-provider-codex-cli exposes no equivalent spawn hook, so that transport's process is spawned by the vendor SDK directly regardless of what a composition root supplies — a vendor limitation, not a choice this codebase makes. The same read-only, no-MCP, approvals-off configuration is still the containment for that one transport.
Both composition roots additionally read config.runner.isolation to decide how they build that spawner, in a local module (subscription-isolation.ts, duplicated in packages/cli and packages/api rather than pulled into a shared package — this decision is composition-root-specific, needing both AgentZeroConfig and an operator environment variable, and neither packages/models nor packages/runner should own it). On local isolation it wires spawnManagedProcess directly, spawning the CLI on the host, same as before. On container isolation it wires spawnManagedProcess's container option instead: packages/runner exports ManagedProcessContainerOptions and containerizedProcessArgv, a docker/podman run invocation deliberately distinct from ContainerRunner.engineArguments() — no repository-checkout volume (the CLI never touches one) and no --network tied to permissions.network (that policy contains an untrusted checkout's commands, not Agent Zero's own necessary calls to the vendor API). When container isolation is declared but no CLI container image is configured (AGENT_ZERO_CLAUDE_CODE_CONTAINER_IMAGE), the composition root refuses the transport rather than silently falling back to an unisolated host spawn. The refusal is reported to modelFromEnvironment as a subscriptionRefusalReason — a synchronous throw from Agent Zero's own code the moment the transport is asked to build a model, never touching the vendor SDK — rather than by turning the enable flag off: the flag also gates fallback selection, so disabling it would have reported the transport as never configured at all and skipped a configured AGENT_ZERO_MODEL_FALLBACK_PROVIDER entirely, turning a run that could have degraded into one that fails outright. environmentForModel (used only by zero doctor's diagnostics, which want a plain "not ready" signal rather than this nuance) is the one place that still disables the flag.
A RunnerPool lease is a separate isolation mechanism from config.runner.isolation, and SandboxProvider (vitehub/cloudflare/vercel/custom) returns only the ordinary Runner contract — bounded command execution, never a live process handle — so there is no claude-code spawner that could route the CLI's duplex stream through the same boundary a lease already gives repository commands. runTask (packages/api/src/operations.ts) refuses claude-code outright whenever options.runnerPool is configured, regardless of runner.isolation, the same way it refuses container isolation without an image and for the same reason: a subscriptionRefusalReason, not a disabled flag, so a configured fallback still gets its turn rather than the run failing outright. packages/cli has no RunnerPool concept at all, so this check lives only in packages/api.
Getting an authenticated session into that container took three fixes beyond the mount, each verified against a real docker run rather than assumed: the vendor SDK resolves the CLI to an absolute host path (its bundled native binary, or AGENT_ZERO_CLAUDE_CODE_PATH) that does not exist in the container, so the spawner substitutes a bare, image-relative executable name instead (AGENT_ZERO_CLAUDE_CODE_CONTAINER_EXECUTABLE, default claude) and passes the vendor's own args through unchanged (they carry no host paths). The CLI's session spans two host locations that are not nested — ~/.claude/ and a sibling file ~/.claude.json — and Docker refuses to bind-mount a file inside an already-:ro directory mount (an OCI runtime restriction, not a policy choice here), so both are mounted as siblings under one synthetic directory with the container's $HOME pointed at it, letting the CLI's own default resolution find both without a CLAUDE_CONFIG_DIR override. And the container runs as the host's UID:GID rather than root: the mounted credential file's 0600 mode plus --cap-drop ALL (which strips even root's permission-bypass capability inside the container) means only a matching UID can satisfy the CLI's own ownership check on it. containerizedProcessArgv also forwards env as -e KEY=VALUE flags now — the container engine's own process env configures its client, not the process it starts, so $HOME and the vendor SDK's other env entries would otherwise never reach the code running inside.
A subscription transport also owns the one failure that repairs itself. A spent usage window is not a permanent error, so ResumingSubscriptionProvider waits for the reset the transport reported and retries, resuming the interrupted session through a SubscriptionSession handle shared by the model factory that writes to it and the error translator that reads it. The wait is bounded by a cumulative budget and an attempt count, and only ever on a reset the transport actually stated, so no run blocks for a duration nobody chose. It sits inside the API-key fallback rather than outside it: the subscription is already paid for. Both wrappers degrade on SubscriptionProviderUnavailableError and nothing else, so a model that merely returned an unusable decision never causes a transport to be swapped or a run to sleep.