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
HostActionQueueand the host'sCloudBoxPollerlong-polls/bridge/poll, then runsexecuteCloudActionto dispatch (git.push,cp.*,download.workspace,checkpoint.create,browser.open.mirror). Seecloud-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()insandbox-docker/src/relay.tsspawnsnode packages/relay/dist/bin.cjs serve --port 8787 --host 0.0.0.0as a detached, unref'd child, with PID at~/.agentbox/relay.pidand stdout/stderr at~/.agentbox/relay.log. Lazily started on firstagentbox create/agentbox claude; idempotent — subsequent calls ping/healthzand short-circuit if the existing process responds. Migration: a staleagentbox-relaydocker 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, constantDEFAULT_BOX_RELAY_PORTin@agentbox/relay) and the in-box client points at it viaAGENTBOX_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/rpcand/eventstoAGENTBOX_HOST_RELAY_URL(defaults tohttp://host.docker.internal:8787, with the matching--add-host=host.docker.internal:host-gatewayset byrunBox). For cloud boxes the same port hosts a fullmode: 'box'relay that the host'sCloudBoxPollerlong-polls. The split keeps :8787 free inside the box, so a nestedagentboxrun (developing agentbox-from-inside-agentbox) can claim its own host relay there without colliding.agentbox-netis gone. -
Auth is a per-box bearer token:
generateRelayToken()(32 random bytes hex) at create time, persisted inBoxRecord.relayToken, injected into the box asAGENTBOX_RELAY_TOKEN, registered with the relay over HTTP toPOST /admin/register-box(loopback only). The box also getsAGENTBOX_RELAY_URL(above).destroyBoxcallsPOST /admin/forget-box. -
How the token reaches in-box
agentbox-ctl(and why it differs by provider):agentbox-ctlis the only in-box consumer of the relay token (the agent'sgit push, thentn/linearshims,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 a0600 /run/agentbox/relay.env(tmpfs, never snapshotted) holding onlyAGENTBOX_RELAY_URL+AGENTBOX_RELAY_TOKEN;resolveRelayEnvinpackages/ctl/src/relay-env.ts(used byrelay-rpc.tspostRpcandrelay-client.ts) prefers process env and falls back to that file. This is what makes the in-box agent'sgit pushand the host-drivenagentbox git pushwork without spraying the token into every login shell's env, and guards against the regression inb9e4ebf55(the daemon'sbox.envoverwrite dropped the token). ThebridgeToken(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) andcredentials-updated(the ctl credential watcher's refreshed-agent-login payload — a secret; handled byCredentialsFanoutincredentials-fanout.ts: newest-wins write of~/.agentbox/<agent>-credentials.json+ debounced spawn ofagentbox credentials propagate). Cloud boxes' events drain through the bridge →CloudBoxPoller.onEvents, where the same two types get the same routing.POST /rpc(bearer): handlesgit.pull/git.pushin-process by spawninggit -C <hostWorktreeDir> {pull|push} <remote> [...args]withprocess.env(so it inheritsSSH_AUTH_SOCK,~/.gitconfig, etc.) and returning{ exitCode, stdout, stderr }. Container path → worktree dir is resolved from the registered box'sworktrees[], 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 attachedagentbox claudefooter subscribes to; the one-shot list + answer are what an unattended orchestrator uses (surfaced asagentbox 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 fromBoxRecord.gitWorktreesso the relay's in-memory state catches back up. -
Rehydration after restart: every
createBoxreads~/.agentbox/state.jsonand re-pushes every known(relayToken, gitWorktrees)viarehydrateRelayRegistry(). Idempotent and cheap, so we do it unconditionally instead of trying to detect a restart.startBoxalso re-registers its own box. -
agentbox recover: the explicit "reconnect this host to an already-running box" entrypoint. It callsensureRelay()+ the samerehydrateFromState()therelay start/restartpath uses, thenprovider.reconnect(box)— the no-power-cycle sibling ofstart: for cloud it re-runsreEnsureCloudBox(refresh preview URLs, re-open the Hetzner tunnel, re-register portless + the relay poller, relaunch in-box daemons) withoutbackend.start; for docker it re-runsstartBox(idempotent on a live container). Then it relaunches the box'slastAgent(resuming, or fresh) and attaches. With--provider <cloud> --adoptit first rebuilds aBoxRecord(fresh relay + bridge tokens, re-injected whenreconnectrelaunches the ctl daemon →/run/agentbox/relay.env) for a sandbox missing from local state. -
The supervisor pushes outbound:
packages/ctl/src/relay-client.tsis a fire-and-forget POST to/events(node:http, 2s timeout, silent failure).onServiceState/onTaskStateinsupervisor.tsforward terminal states (ready/crashed/backoff/unhealthy/stopped/done/failed). Disabled at construction whenAGENTBOX_RELAY_URL/AGENTBOX_RELAY_TOKENare unset — keeps existing tests and pre-relay boxes a no-op. -
In-box CLI:
agentbox-ctl git pull|push [-- <git-args>...](inpackages/ctl/src/commands/git.ts) POSTs to/rpcwith{ method: 'git.pull'|'git.push', params: { path: <cwd>, args: [...] } }, streamsstdout/stderrback 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
gitshim + its flag allowlist:packages/sandbox-docker/scripts/git-shimis installed at/usr/local/bin/git(ahead of/usr/binonPATH) in every provider's box image, so a plaingit push|pull|fetch|clonetransparently becomesagentbox-ctl git …. Everything else (commit,status,log, …) execs the real/usr/bin/git. The shim refuses a positional remote/branch — the relay rebuildsgit -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-runpull—--ff-only,--prune/-p,--tags/--no-tagsfetch—--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 gitaccepts 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-sideagentbox git push <box> --host-only) sends the samegit.pushmethod but withparams.hostOnly(+ optionalas/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 (handleGitSaveToHostinserver.ts): the box commits already live in the bind-mounted.git/, so it copies the box branch torefs/heads/<as>via a self-fetchgit -C <hostMainRepo> fetch . <src>:refs/heads/<dest>(fast-forward-only;+with--force); whendest === srcit's a no-op success. Cloud (runGitRpcshort-circuit inhost-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-onlyis 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-boxntn/notion/linearshims) POST{ method: 'integration.<svc>.<op>', params: { path, args } }for any connector registered in@agentbox/integrations. Currently shipped:notion(host binntn) with opswhoami,api(GET-only passthrough, refuses-X/--method/-f/-F/--input),page.create(gated),page.update(gated); andlinear(host binlinear,@schpet/linear-cli) with opswhoami(auth whoami),issue.list/issue.mine/issue.view/issue.query,team.list,api(GraphQL query-only passthrough —refuseGraphqlNonQueryconsumes--variable/--variables-jsonvalues, rejectsmutation/subscription, and rejects--variable key=@<path>host-file loads),issue.create/issue.update/issue.comment(gated;issue.commentmaps tolinear issue comment add— v2 usesadd, notcreate). The linear shim hard-rejectslinear auth token(would print the raw API key) andauth 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-projectintegrations.<svc>.enabledflag is off, runs the op'srefuseCallpre-flight, probes the host binary (<hostBin> --version, cached 60s), then either passes through (read) or gates the call viaaskPrompt(write) before shelling out to the host CLI viarunHostIntegration. 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 rewritingPATH. Same{exitCode, stdout, stderr}envelope asgit.*/gh.pr.*; wired into bothserver.ts(docker) andhost-actions.ts(cloud — daytona/hetzner/vercel/e2b) per the "fix across all providers" rule. The reusable spine lives inpackages/relay/src/integrations.ts(parseIntegrationMethod,getConnector,assertIntegrationReady,refuseIntegrationCall,refuseIfIntegrationDisabled,runHostIntegration); the in-box ctl surface is built from the same descriptors inpackages/ctl/src/commands/integration.ts. Adding a service is one new descriptor file + a one-line registry add — no relay change. Seeintegrations.mdfor 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 } }forop ∈ {create, view, list, comment, review, merge, checkout, close, reopen}. The relay shellsgh pr <op> <args>withcwd = worktree.hostMainReposo gh infers the repo from the host repo'sgit remote -v. Read-only ops (view,list) bypass the prompt;createandcommentauto-approve by default underbox.autoApproveSafeHostActions(see below);review/close/reopen/merge/checkoutstill trigger anaskPrompt. Extra guards:mergerefuses theAGENTBOX_PROMPT=offauto-yunlessAGENTBOX_GH_FORCE=1;checkoutis disabled by default (opt-in viaAGENTBOX_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 inexecuteCloudAction → runGhPrRpc, with the no-attached-wrapper behavior gated byAGENTBOX_GH_NO_SUB(denydefault,allow, orprompt). Requiresghinstalled andgh auth loginon the host; for HTTPS push/pull/fetch we additionally recommendgh auth setup-gitso plaingit pushuses 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 viaagent-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 abrowser-openevent, 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 onlyopens it on the host on ay. URL scheme is validated http/https both in the ctl command and viaisOpenableUrlinserver.ts. The box image symlinks/usr/local/bin/xdg-opento theagentbox-openwrapper and setsBROWSER=/usr/local/bin/agentbox-open, soxdg-openand any$BROWSER-aware tool (Claude Code OAuth,gh, …) route here. TheCtrl+a ufooter/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/secretcp.*,gh.pr.*merge/checkout/review,git.lease-token,browser.open; the safe subset auto-approves — seebox.autoApproveSafeHostActionsbelow) are raised byaskPromptand 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 withagentbox 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 fullcontext—command,argv,cwd) and the agent's in-TUI prompts (plan/question/permission, read from the box'sstatus.json). Each row carries anid+kind.--waitlong-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). Atui:<boxId>:<kind>:<digest>id → the in-TUI path: it recomputes the digest from the box's currentstatus.jsonand 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 helpersagentbox driveuses (apps/cli/src/lib/agent-answer.tsmaps decision→keys; best-effort, TUI-version-sensitive).
This is the supported replacement for hand-curling the loopback endpoint (and for hand-crafting
drive keypressfor 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 (byid) 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, defaulttrue): 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 auditedhost-action-auto-approvedevent (with areason). Set the keyfalseto 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.apireview 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-timebranch(immutable scratch identity) andsanctionedBranch, updated when the host runsagentbox 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.isSanctionedPushBranchin@agentbox/coreis the shared decision; the docker relay pushessanctionedBranch(falling back tobranch).git.lease-tokenis 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.workspacewhose host destination stays inside the box project folder, andcp.fromHostwhose host sources stay inside it. Containment is symlink-aware (isContainedInWorkspacerealpaths 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'scarry:block. Still gated:gh pr merge/checkout, non-sanctioned-branch push, and any uncontained or secret transfer. The shared decision lives inpackages/relay/src/safe-transfer.ts; both docker (server.ts) and cloud (host-actions.ts) handlers consume it.
- open PR (
-
Auto-approve policy (
box.autoApproveHostActions): an opt-in per-box config key (defaultfalse) for fully-unattended runs — the superset of the safe subset above (approves everything, including merge/checkout and uncontained transfers). Resolved at create fromloadEffectiveConfig(workspace > project > global), persisted onBoxRecord.autoApproveHostActions, and carried onBoxRegistration(set atregister-box, replayed byrehydrateRelayRegistry). When set,askPromptshort-circuits the box's confirms toywithout a prompt — but every bypass emits ahost-action-auto-approvedrelay event (visible via/admin/events,agentbox agent, the dashboard), so the bypass is auditable, never silent. The single short-circuit lives inPendingPrompts.consumeAutoApprove(inaskPrompt), so it covers docker and cloud (same host relay, same handlers) uniformly. Takes effect for boxes created after the key is set;AGENTBOX_PROMPT=offremains 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-dockerfor the wire types (BoxWorktree,GitRpcParams, etc.) and constants. The bin'sregister/forget/tailsubcommands still work (they POST to127.0.0.1:8787) but you can also justcurlthe 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 toMemoryStore(wraps the in-memoryregistry.ts/status-store.ts/host-initiated.tsverbatim → zero regression); the plane usesPostgresStore(pg, lazy + bundler-external so the laptop carries no pg);RemoteStorelets a federated laptop relay push its state up to the plane. - Git creds by leasing, not reach-back. Instead of the cloud
git.pushbundle-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 asgit.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 pollsGET /rpc/status/:idfor the verdict (vs the laptop relay's in-process blockingaskPrompt). The SSE stream stays for the human dashboard/wrapper. - Box creation is a durable queue:
POST /remote/boxesenqueues acreate_jobsrow (202 {jobId}); a long-runningagentbox control-plane workerclaims it (atomicFOR UPDATE SKIP LOCKED), leases a token, clones the repo locally, and hands the checkout to the normalprovider.create(). Cloud providers only (docker bind-mounts the host.git, so it can't be plane-provisioned).