Host relay (@agentbox/relay)

July 10, 2026 · View on GitHub

Part of the AgentBox docs. Start at CLAUDE.md. Cloud boxes drive the same relay through a different transport — the in-sandbox relay parks host-only RPCs on a HostActionQueue and the host's CloudBoxPoller long-polls /bridge/poll, then runs executeCloudAction to dispatch (git.push, cp.*, download.workspace, checkpoint.create, browser.open.mirror). See cloud-providers.md §2.3 for the full bridge model.

  • Host node process, not a container (it used to be — that was a mistake; admin endpoints can't be reached easily from the host, and the relay needs the user's SSH agent + git config for the push/pull RPCs). ensureRelay() in sandbox-docker/src/relay.ts spawns node packages/relay/dist/bin.cjs serve --port 8787 --host 0.0.0.0 as a detached, unref'd child, with PID at ~/.agentbox/relay.pid and stdout/stderr at ~/.agentbox/relay.log. Lazily started on first agentbox create / agentbox claude; idempotent — subsequent calls ping /healthz and short-circuit if the existing process responds. Migration: a stale agentbox-relay docker container is removed up front so it doesn't shadow the new process.

  • In-box relay endpoint: boxes don't talk to the host relay directly. The ctl daemon binds an in-box relay on 127.0.0.1:8788 (env override: AGENTBOX_BOX_RELAY_PORT, constant DEFAULT_BOX_RELAY_PORT in @agentbox/relay) and the in-box client points at it via AGENTBOX_RELAY_URL=http://127.0.0.1:8788. For docker boxes that in-box endpoint is a thin reverse proxy (packages/ctl/src/box-relay-forwarder.ts) that forwards /rpc and /events to AGENTBOX_HOST_RELAY_URL (defaults to http://host.docker.internal:8787, with the matching --add-host=host.docker.internal:host-gateway set by runBox). For cloud boxes the same port hosts a full mode: 'box' relay that the host's CloudBoxPoller long-polls. The split keeps :8787 free inside the box, so a nested agentbox run (developing agentbox-from-inside-agentbox) can claim its own host relay there without colliding. agentbox-net is gone.

  • Auth is a per-box bearer token: generateRelayToken() (32 random bytes hex) at create time, persisted in BoxRecord.relayToken, injected into the box as AGENTBOX_RELAY_TOKEN, registered with the relay over HTTP to POST /admin/register-box (loopback only). The box also gets AGENTBOX_RELAY_URL (above). destroyBox calls POST /admin/forget-box.

  • How the token reaches in-box agentbox-ctl (and why it differs by provider): agentbox-ctl is the only in-box consumer of the relay token (the agent's git push, the ntn/linear shims, cp/download/checkpoint, and the supervisor's event posts all route through it). On docker the token is global container env (docker run -e), inherited by every shell. On cloud there is no global env — the interactive agent runs under a tmux login shell that doesn't inherit the daemon's process env. So the cloud ctl daemon, once it validates its own token, writes a 0600 /run/agentbox/relay.env (tmpfs, never snapshotted) holding only AGENTBOX_RELAY_URL + AGENTBOX_RELAY_TOKEN; resolveRelayEnv in packages/ctl/src/relay-env.ts (used by relay-rpc.ts postRpc and relay-client.ts) prefers process env and falls back to that file. This is what makes the in-box agent's git push and the host-driven agentbox git push work without spraying the token into every login shell's env, and guards against the regression in b9e4ebf55 (the daemon's box.env overwrite dropped the token). The bridgeToken (host-poller→box /bridge/* auth) is consumed only by the daemon and stays in its process env — never written to a file.

  • Endpoints:

    • POST /events (bearer; append to 1000-entry in-memory ring buffer → 202). Two event types are special-cased and never enter the ring: box-status (persisted to the BoxStatusStore) and credentials-updated (the ctl credential watcher's refreshed-agent-login payload — a secret; handled by CredentialsFanout in credentials-fanout.ts: newest-wins write of ~/.agentbox/<agent>-credentials.json + debounced spawn of agentbox credentials propagate). Cloud boxes' events drain through the bridge → CloudBoxPoller.onEvents, where the same two types get the same routing.
    • POST /rpc (bearer): handles git.pull / git.push in-process by spawning git -C <hostWorktreeDir> {pull|push} <remote> [...args] with process.env (so it inherits SSH_AUTH_SOCK, ~/.gitconfig, etc.) and returning { exitCode, stdout, stderr }. Container path → worktree dir is resolved from the registered box's worktrees[], with longest-prefix wins for nested paths. Unknown methods still return 501. 120s timeout per call.
    • POST /admin/register-box / POST /admin/forget-box / GET /admin/events / GET /admin/registry — admin. Source-IP guarded: loopback only. Anything else gets a 403; this is how we keep boxes from poking admin even though the listener is on 0.0.0.0.
    • GET /admin/prompts?boxId=<id> (one-shot list of pending host-action approvals) / GET /admin/prompts/stream?boxId=<id> (SSE) / POST /admin/prompts/answer ({ id, answer: 'y'|'n', cancelled? }) — admin, loopback only. The SSE stream is what an attached agentbox claude footer subscribes to; the one-shot list + answer are what an unattended orchestrator uses (surfaced as agentbox agent approvals / agentbox agent approve — see below).
    • GET /healthz — no auth.
  • Registration carries the worktree map: RegisterBoxBody.worktrees: BoxWorktree[] (containerPath, hostWorktreeDir, branch). The relay needs this to resolve which dir to run git in; on box restart or relay restart, rehydrateRelayRegistry() replays it from BoxRecord.gitWorktrees so the relay's in-memory state catches back up.

  • Rehydration after restart: every createBox reads ~/.agentbox/state.json and re-pushes every known (relayToken, gitWorktrees) via rehydrateRelayRegistry(). Idempotent and cheap, so we do it unconditionally instead of trying to detect a restart. startBox also re-registers its own box.

  • agentbox recover: the explicit "reconnect this host to an already-running box" entrypoint. It calls ensureRelay() + the same rehydrateFromState() the relay start/restart path uses, then provider.reconnect(box) — the no-power-cycle sibling of start: for cloud it re-runs reEnsureCloudBox (refresh preview URLs, re-open the Hetzner tunnel, re-register portless + the relay poller, relaunch in-box daemons) without backend.start; for docker it re-runs startBox (idempotent on a live container). Then it relaunches the box's lastAgent (resuming, or fresh) and attaches. With --provider <cloud> --adopt it first rebuilds a BoxRecord (fresh relay + bridge tokens, re-injected when reconnect relaunches the ctl daemon → /run/agentbox/relay.env) for a sandbox missing from local state.

  • The supervisor pushes outbound: packages/ctl/src/relay-client.ts is a fire-and-forget POST to /events (node:http, 2s timeout, silent failure). onServiceState / onTaskState in supervisor.ts forward terminal states (ready / crashed / backoff / unhealthy / stopped / done / failed). Disabled at construction when AGENTBOX_RELAY_URL / AGENTBOX_RELAY_TOKEN are unset — keeps existing tests and pre-relay boxes a no-op.

  • In-box CLI: agentbox-ctl git pull|push [-- <git-args>...] (in packages/ctl/src/commands/git.ts) POSTs to /rpc with { method: 'git.pull'|'git.push', params: { path: <cwd>, args: [...] } }, streams stdout/stderr back to the agent's terminal, and exits with the host's git exit code. This is what the agent invokes to ask the host to push the box's commits — no SSH keys leak into the box.

  • The in-box git shim + its flag allowlist: packages/sandbox-docker/scripts/git-shim is installed at /usr/local/bin/git (ahead of /usr/bin on PATH) in every provider's box image, so a plain git push|pull|fetch|clone transparently becomes agentbox-ctl git …. Everything else (commit, status, log, …) execs the real /usr/bin/git. The shim refuses a positional remote/branch — the relay rebuilds git -C <hostMainRepo> <op> <remote> <branch> from the registered worktree, and re-passing them makes git read them as refspecs (refs/remotes/origin/HEAD cannot be resolved to branch). It also enforces a per-op flag allowlist:

    • push--force-with-lease, --tags, --dry-run
    • pull--ff-only, --prune/-p, --tags/--no-tags
    • fetch--prune/-p, --prune-tags, --tags/--no-tags, --force/-f, --dry-run
    • all three also take --quiet/-q, --verbose/-v, --progress/--no-progress
    • clone--branch <n>, --depth <n> (the one op that parses flag values; a clone whose source is unmistakably local — file://, an absolute or relative path — falls through to real git before the gate, since it needs no host credentials)

    Every push/pull/fetch flag must be value-less, and the allowlist stays that way on purpose: the glued form (--upload-pack=/bin/sh) fails the exact-match check and the split form (--depth 1) has its value caught as a positional, so command-executing flags (--upload-pack=, --receive-pack=, --exec=, -o) are excluded for free. Refused on top of that, deliberately: --delete/--mirror, push --prune (destructive on the remote), push --force (use --force-with-lease), and --all (git rejects it alongside the relay's positional remote+branch). Note the shim is a footgun guard, not the security boundary — agentbox-ctl git accepts unknown options, so a determined in-box process bypasses it; the host approval gate below is what actually holds. A shim edit is baked into the image/snapshot, so cloud boxes need a re-agentbox prepare --provider <name> to pick it up.

  • Host-only landing (git push --host-only): agentbox-ctl git push --host-only [--as <branch>] [--force] (and host-side agentbox git push <box> --host-only) sends the same git.push method but with params.hostOnly (+ optional as/force). It makes the box's branch available in the host's local repo without pushing to any remote — nothing is published online. Because nothing leaves the host, it bypasses the push-confirm gate / host-initiated-token path entirely. Docker (handleGitSaveToHost in server.ts): the box commits already live in the bind-mounted .git/, so it copies the box branch to refs/heads/<as> via a self-fetch git -C <hostMainRepo> fetch . <src>:refs/heads/<dest> (fast-forward-only; + with --force); when dest === src it's a no-op success. Cloud (runGitRpc short-circuit in host-actions.ts): the push flow's bundle pull-back steps (bundle the branch in the sandbox → download → git -C <workspacePath> fetch <bundle> <src>:refs/heads/<dest>) without the final remote push or origin/upstream sync. Destination defaults to the box's current branch name. --host-only is incompatible with --remote (rejected with exit 64 on both the ctl and host-CLI sides).

  • Service integrations via host CLIs: agentbox-ctl integration <svc> <op> [-- args...] (and the in-box ntn / notion / linear shims) POST { method: 'integration.<svc>.<op>', params: { path, args } } for any connector registered in @agentbox/integrations. Currently shipped: notion (host bin ntn) with ops whoami, api (GET-only passthrough, refuses -X/--method/-f/-F / --input), page.create (gated), page.update (gated); and linear (host bin linear, @schpet/linear-cli) with ops whoami (auth whoami), issue.list / issue.mine / issue.view / issue.query, team.list, api (GraphQL query-only passthrough — refuseGraphqlNonQuery consumes --variable / --variables-json values, rejects mutation / subscription, and rejects --variable key=@<path> host-file loads), issue.create / issue.update / issue.comment (gated; issue.comment maps to linear issue comment add — v2 uses add, not create). The linear shim hard-rejects linear auth token (would print the raw API key) and auth login/logout/migrate/default; destructive ops (issue delete, team delete, team create) are off the allowlist. The relay parses the method, looks up the connector + op, refuses with exit 65 if the per-project integrations.<svc>.enabled flag is off, runs the op's refuseCall pre-flight, probes the host binary (<hostBin> --version, cached 60s), then either passes through (read) or gates the call via askPrompt (write) before shelling out to the host CLI via runHostIntegration. A connector may declare a <SERVICE>_* env override merged onto the host spawn env (none do today — each host CLI uses its own default auth); a descriptor that tries to set anything outside its namespace yields exit 78 instead of silently rewriting PATH. Same {exitCode, stdout, stderr} envelope as git.* / gh.pr.*; wired into both server.ts (docker) and host-actions.ts (cloud — daytona/hetzner/vercel/e2b) per the "fix across all providers" rule. The reusable spine lives in packages/relay/src/integrations.ts (parseIntegrationMethod, getConnector, assertIntegrationReady, refuseIntegrationCall, refuseIfIntegrationDisabled, runHostIntegration); the in-box ctl surface is built from the same descriptors in packages/ctl/src/commands/integration.ts. Adding a service is one new descriptor file + a one-line registry add — no relay change. See integrations.md for the design + the connector descriptor shape.

  • PR ops via host gh: agentbox-ctl git pr <op> [args...] POSTs { method: 'gh.pr.<op>', params: { path, args } } for op ∈ {create, view, list, comment, review, merge, checkout, close, reopen}. The relay shells gh pr <op> <args> with cwd = worktree.hostMainRepo so gh infers the repo from the host repo's git remote -v. Read-only ops (view, list) bypass the prompt; create and comment auto-approve by default under box.autoApproveSafeHostActions (see below); review/close/reopen/merge/checkout still trigger an askPrompt. Extra guards: merge refuses the AGENTBOX_PROMPT=off auto-y unless AGENTBOX_GH_FORCE=1; checkout is disabled by default (opt-in via AGENTBOX_GH_PR_CHECKOUT=allow) and refused on a dirty host tree or a host HEAD that matches any registered box branch (would corrupt the bind-mounted box .git/HEAD). Cloud path mirrors the same matrix in executeCloudAction → runGhPrRpc, with the no-attached-wrapper behavior gated by AGENTBOX_GH_NO_SUB (deny default, allow, or prompt). Requires gh installed and gh auth login on the host; for HTTPS push/pull/fetch we additionally recommend gh auth setup-git so plain git push uses gh's OAuth token (handled invisibly by git's credential helpers — no relay change needed).

  • agentbox-ctl open <url> (packages/ctl/src/commands/open.ts) opens the URL in the box's own Chromium via agent-browser open --headed (visible in the VNC view / agentbox screen), then best-effort POSTs { method: 'browser.open', params: { url } } to the relay. The relay records a browser-open event, answers immediately (never blocks the box), and raises a non-blocking, auto-expiring confirm prompt (askPrompt(..., { ttlMs }), ~25s) in the footer/dashboard — "open link on the host?" — and only opens it on the host on a y. URL scheme is validated http/https both in the ctl command and via isOpenableUrl in server.ts. The box image symlinks /usr/local/bin/xdg-open to the agentbox-open wrapper and sets BROWSER=/usr/local/bin/agentbox-open, so xdg-open and any $BROWSER-aware tool (Claude Code OAuth, gh, …) route here. The Ctrl+a u footer/dashboard leader action is unrelated — it opens the box's web app on the host (agentbox url).

  • Host-action approvals (orchestrator path): the confirm prompts that still gate the non-safe host actions (non-sanctioned-branch git.push, uncontained/secret cp.*, gh.pr.* merge/checkout/review, git.lease-token, browser.open; the safe subset auto-approves — see box.autoApproveSafeHostActions below) are raised by askPrompt and answered over /admin/prompts/answer. Because that endpoint is loopback-only, only a host process can answer — a box can't. A host-side orchestrator (e.g. a Claude driving boxes with agentbox claude -i) inspects and answers them deliberately via two CLI commands (apps/cli/src/commands/agent.ts):

    • agentbox agent approvals [box] [--json] [--wait <ms>] — list everything the box is blocked on: relay host-action prompts (their full contextcommand, argv, cwd) and the agent's in-TUI prompts (plan/question/permission, read from the box's status.json). Each row carries an id + kind. --wait long-polls until one appears.
    • agentbox agent approve <id> [--option <n|label>] [--deny] — answer one by id. A bare UUID → the relay path (POST /admin/prompts/answer). A tui:<boxId>:<kind>:<digest> id → the in-TUI path: it recomputes the digest from the box's current status.json and refuses if it differs (the prompt changed since you listed it), else sends the mapped keystrokes to the agent's tmux session via the same helpers agentbox drive uses (apps/cli/src/lib/agent-answer.ts maps decision→keys; best-effort, TUI-version-sensitive).

    This is the supported replacement for hand-curling the loopback endpoint (and for hand-crafting drive keypress for plan/question/permission dialogs). The orchestrator answering grants nothing it lacks — it already holds the host's git/file credentials; the point of the command is that it sees the exact action (by id) before approving, avoiding a confused-deputy that launders a prompt-injected box's request and avoiding answering the wrong prompt when a new one arrives mid-inspection.

  • Safe-subset auto-approve (box.autoApproveSafeHostActions, default true): the safe subset of host actions — ones scoped to the box's own project folder and host-sanctioned branch that can't touch host-global state or exfiltrate secrets — auto-resolves without a prompt by default, each emitting an audited host-action-auto-approved event (with a reason). Set the key false to restore the pre-relax always-prompt behavior. The subset:

    • open PR (gh.pr.create, relay forces --head <box-branch>) and PR/review comments (gh.pr.comment, gh.api review comments).
    • gh run rerun (re-triggers the project's own CI) and integration writes (Notion/Linear/… — hit the connected external service, not the host).
    • push to a sanctioned branch: the box's agentbox/* scratch branch (always) or the branch the host put it on. Two values are tracked per box — the create-time branch (immutable scratch identity) and sanctionedBranch, updated when the host runs agentbox git checkout/branch/pull <branch> (persisted to the record + re-registered). An in-box agent that self-switches HEAD to another branch (e.g. main) is not sanctioned, so its push still prompts. isSanctionedPushBranch in @agentbox/core is the shared decision; the docker relay pushes sanctionedBranch (falling back to branch). git.lease-token is deliberately excluded — it grants a repo-scoped token the box can push any branch with, so it keeps the scratch-only gate.
    • checkpoint (checkpoint.create; the vercel stop+reboot confirm is skipped).
    • contained file transfer: cp.toHost / download.workspace whose host destination stays inside the box project folder, and cp.fromHost whose host sources stay inside it. Containment is symlink-aware (isContainedInWorkspace realpaths the deepest existing prefix, so an in-project symlink pointing outside fails). A secret-looking host->box source (.env*, *.pem/*.key, id_rsa/id_ed25519, credentials, .npmrc/.netrc, .ssh/.aws/.gnupg/.config/gh) still prompts unless it was already approved via the box's carry: block. Still gated: gh pr merge/checkout, non-sanctioned-branch push, and any uncontained or secret transfer. The shared decision lives in packages/relay/src/safe-transfer.ts; both docker (server.ts) and cloud (host-actions.ts) handlers consume it.
  • Auto-approve policy (box.autoApproveHostActions): an opt-in per-box config key (default false) for fully-unattended runs — the superset of the safe subset above (approves everything, including merge/checkout and uncontained transfers). Resolved at create from loadEffectiveConfig (workspace > project > global), persisted on BoxRecord.autoApproveHostActions, and carried on BoxRegistration (set at register-box, replayed by rehydrateRelayRegistry). When set, askPrompt short-circuits the box's confirms to y without a prompt — but every bypass emits a host-action-auto-approved relay event (visible via /admin/events, agentbox agent, the dashboard), so the bypass is auditable, never silent. The single short-circuit lives in PendingPrompts.consumeAutoApprove (in askPrompt), so it covers docker and cloud (same host relay, same handlers) uniformly. Takes effect for boxes created after the key is set; AGENTBOX_PROMPT=off remains the process-wide (all-boxes) bypass for headless/CI.

  • The bin is still built CJS with deps bundled (packages/relay/dist/bin.cjs). Library entry (dist/index.js) stays ESM and is consumed by @agentbox/sandbox-docker for the wire types (BoxWorktree, GitRpcParams, etc.) and constants. The bin's register / forget / tail subcommands still work (they POST to 127.0.0.1:8787) but you can also just curl the admin endpoints directly.

Hosted control plane (laptop-off for cloud boxes)

The same @agentbox/relay core also ships as a hosted control plane — a Next.js + Postgres app (apps/control-plane) deployable to Vercel or self-host — so cloud boxes keep pushing / opening PRs with the laptop off. The full design + build-out status lives in control-plane-backlog.md (the plan of record); the short version of how it relates to this relay:

  • Shared core. Routing is extracted into packages/relay/src/core/handler.ts (handleRelayRequest(GenericRequest) -> RelayResponse). The laptop relay (server.ts, node:http, loopback-gated admin) and the hosted plane (the Next.js catch-all route, fail-closed admin-bearer) are both thin adapters over it.
  • Store seam. Every handler talks to an async Store (packages/relay/src/store/). The laptop relay defaults to MemoryStore (wraps the in-memory registry.ts / status-store.ts / host-initiated.ts verbatim → zero regression); the plane uses PostgresStore (pg, lazy + bundler-external so the laptop carries no pg); RemoteStore lets a federated laptop relay push its state up to the plane.
  • Git creds by leasing, not reach-back. Instead of the cloud git.push bundle-pull-back path, the plane mints a 1-hour, single-repo GitHub-App installation token (github-app.ts, git.lease-token, gated by the identical policy as git.push, repo re-derived from the box's registered origin) and leases it to the box, which pushes directly. No host on the plane (host-local RPCs return 501).
  • Approvals poll, not block. On the plane, an approval-needed RPC parks a prompt row and returns 202 {promptId}; the box polls GET /rpc/status/:id for the verdict (vs the laptop relay's in-process blocking askPrompt). The SSE stream stays for the human dashboard/wrapper.
  • Box creation is a durable queue: POST /remote/boxes enqueues a create_jobs row (202 {jobId}); a long-running agentbox control-plane worker claims it (atomic FOR UPDATE SKIP LOCKED), leases a token, clones the repo locally, and hands the checkout to the normal provider.create(). Cloud providers only (docker bind-mounts the host .git, so it can't be plane-provisioned).