Features
June 15, 2026 · View on GitHub
Part of the AgentBox docs. Start at CLAUDE.md. For cloud (
--provider daytona) parity + the few cloud-only knobs, seecloud-providers.md§5 and the running status indaytona-backlog.md.
What works today
Full local-Docker lifecycle (plus parity-tested for cloud via --provider daytona — see cloud-providers.md):
agentbox create— builds the image on first run (or resolves a checkpoint image when--snapshot <ref>is given), detects git repos (root + 1st-level subdirs), collects host-side carry-over (git stash create+ untrackedls-files), spins up the container, then seeds/workspacevia eitherseedWorkspace(in-containergit worktree addagainst the bind-mounted.git/+ stash/untracked replay) orseedWorkspaceFromDir(tar-pipe from host workspace / APFS clone for the no-git case). Checkpoint restore skips both — the image already has/workspace. Mounts theagentbox-claude-confignamed volume at/home/vscode/.claudeand rsyncs host's~/.claudeinto it (additive, host-authoritative). Bind-mounts each main repo's.git/at its identical absolute host path inside the container so worktree pointer files resolve symmetrically on both sides.--with-env(also onagentbox claude; config keybox.withEnv) copies the host'sDEFAULT_ENV_PATTERNSfiles (.env*,.envrc,.dev.vars,secrets.toml,local.settings.json,appsettings.*.json,agentbox.yaml) into/workspaceafter seeding — the host→box reverse ofagentbox download env(gitignored files are otherwise excluded by the worktree carry-over'sgit ls-files --others --exclude-standard). One-shot at create time, lands in the container's writable layer (persists across stop/start), best-effort (warn-not-throw), recorded asBoxRecord.withEnvand surfaced inagentbox status --inspect. Implemented bycopyHostEnvFilesToBox/buildHostEnvFindArgsinpackages/sandbox-docker/src/host-export.ts(hostfind . -print0 | tar→docker exec -i --user 1000:1000 tar -x).carry:inagentbox.yaml— declarative host→box file copy that bypasses.gitignore. Each entry maps a host path (/abs,~/..., or./relative-to-project-root) to an explicit in-box destination (/absor~/...—~/expands to/home/vscode); accepts amode:(octal),user:(uid),exclude:(tar globs / bare dir names), andoptional: true. When copying a directory, heavy regenerable dirs (.git,node_modules,bin,obj,packages,dist,.next,target—DEFAULT_CP_EXCLUDESinapps/cli/src/lib/dir-breakdown.ts) are dropped by default andexclude:is additive. The resolver enforces no-..-traversal, denies/proc|/sys|/dev|/etc/passwd|/etc/shadow, caps per-entry size after excludes atbox.cpMaxBytes(default 100 MiB — the same limitagentbox cpuses; carry callers pass the effective value intoresolveCarry), and flags symlinks whose target leaves$HOMEand the project root. Onagentbox create/claude/codex/opencode, the host CLI prompts ONCE (@clack/prompts.select—yes/skip just for this box/cancel create) listing every src→dest with size + mode + symlink warnings, then threads the approved set intoprovider.createasreq.carry. Auto-approve with--carry-yes(orAGENTBOX_CARRY_YES=1for CI); skip with--carry skip(orAGENTBOX_CARRY=skip).agentbox forkis the exception: it sends the carry: block by default (it forwards--carry-yes), because the host is trusted and the box is the untrusted side, so a host→box copy is safe — opt out withagentbox fork --carry skip.-y/--yesdoes NOT auto-approve carry — non-TTY use of-ywith non-empty entries fails loud, asking for the explicit env var (auditable in CI). The-i(queued background) path runs the same gate on the host at submit time (runQueuedCarryGate), serializes the approvedResolvedCarryEntry[]onto the queue job (QueueJobCreateOpts.carry), and the host-side worker applies them at box-create time — so--carry-yes/--carry skipwork identically for-i. Docker injects viacopyCarryPathsToBox(docker cpfor files, host-tar +docker exec tar -xfor dirs); cloud (Hetzner + Daytona) injects viauploadCarryPaths(host-tar +backend.uploadFile+backend.exec(tar -x)), per-entry isolated. Files land owned byvscode:vscode(uid 1000) when under/home/vscode; an audit summary ({count, entries: [{src, dest, bytes}]}) is recorded onBoxRecord.carry. Use case: develop AgentBox itself inside an AgentBox — carry~/.agentbox/secrets.env+~/.agentbox/claude-credentials.jsonso the in-boxagentboxCLI is fully authenticated. Schema:packages/ctl/src/carry.ts. Resolver / prompt / gate:apps/cli/src/lib/carry-resolve.ts,apps/cli/src/carry-prompt.ts,apps/cli/src/lib/carry-gate.ts. Copiers:packages/sandbox-docker/src/host-export.ts:copyCarryPathsToBox,packages/sandbox-cloud/src/carry.ts:uploadCarryPaths. A file carry entry may also setreplaceEnvs: true(substitute{{AGENTBOX_*}}whitelist placeholders),replace:(inline{from,to,regex?}rules), and/orrules:(named refs into the top-levelreplacements:block) — the file is rendered host-side to a temp byrenderCarryEntries(@agentbox/sandbox-core/src/carry-render.ts) before the copy (the host source is never modified; the box name is known by then). Named refs are expanded inresolveCarry; replace options are file-only (a dir entry errors).run_oncetasks + the replacement engine — a task may declarerun_once: true(the supervisor skips it while a SHA-256 of the resolved command matches a marker at<stateDir>/tasks/<name>, defaultstateDir=/var/lib/agentbox— box rootfs, captured by checkpoints, off/workspace) orrun_once: { check: <cmd> }(run the probe first; exit 0 = skip, no marker — for state outside the checkpoint like a containerized DB).run-task --forcebypasses both. Handled inTaskRunner.launch(packages/ctl/src/supervisor.ts). The shared, pure replacement engine lives in@agentbox/core(replace.ts:applyReplacements={{AGENTBOX_*}}whitelist substitution + ordered rules; re-exported by@agentbox/ctlwhich adds the yaml/fs loaders — kept in core to avoid thesandbox-core → ctl → relay → sandbox-corebuild cycle). Surfaced three ways: the top-levelreplacements:block (named rule-sets, parsed inconfig.ts),agentbox-ctl render <src> [--out|--in-place] [--env] [--rules|--rule|--rule-regex](in-box declarativesed,packages/ctl/src/commands/render.ts), and the carryreplaceEnvs/replace/rulesabove.renderalso expands{{AGENTBOX_AUTO_SECRET}}(fresh 32-byte base64url per render) /{{AGENTBOX_AUTO_SECRET:<name>}}(generated once, persisted at<stateDir>/secrets/<name>, reused) —packages/ctl/src/secret.ts, replacingopenssl randin env tasks.- Declarative docker
image:services — a service may setimage:(a bare ref string, or a mapping{ name, ports, env, args, container_name }) instead ofcommand:;parseService(packages/ctl/src/config.ts) synthesizes thedocker start-or-runshell (the provenexamples/express-ready/ optima pattern), reused by name across restarts (env baked into-e, no auto-rm).command/imageare mutually exclusive; the runner/ready_when/restart/exposemachinery is unchanged. The shared writable-state-dir resolver (packages/ctl/src/state-dir.ts) backs both run_once markers and persisted secrets. agentbox attach [box]— agent-agnostic reattach. Probes the box for live tmux sessions (claude / codex / opencode) via onetmux list-sessions -F '#{session_name} #{session_created}'round-trip, picks the running session, and dispatches to the same wrapped-pty path the per-agent attaches use. When 2+ are live: TTY prompts via Clackselect; non-TTY falls back to the most recently started. When 0 are live it printsno agent session running in <name>and exits 1 — never auto-starts (useagentbox claude/codex/opencodefor that). Flags:--session-name <name>,--attach-in <mode>,-i, --inline. Works for docker and every cloud provider — the cloud branch reusescloudAgentAttach; the pre-probe is what keeps cloud from auto-creating the tmux session viaprovider.buildAttach.apps/cli/src/commands/attach.ts.agentbox claude [-- <claude-args>...]— does everythingcreatedoes, then starts Claude Code in a detached tmux session inside the box and attaches the user's terminal to it.Ctrl+a ddetaches; the claude process keeps running. Reattach withagentbox attach <box>(oragentbox claude attach <box>for the per-agent variant, which also auto-starts a fresh session if none is running). ForwardsANTHROPIC_API_KEY/CLAUDE_CODE_OAUTH_TOKEN/CLAUDE_EFFORT/ANTHROPIC_MODELfrom host env when set.--isolate-claude-configopts into a per-boxagentbox-claude-config-<id>volume.agentbox claude start [box] [-- <claude-args>...]— start a Claude session in an existing box (vsagentbox claudewhich creates one). Resolves[box]via the usual auto-pick / index / name / id-prefix chain. Auto-unpauses/starts the container if needed (mirrorsshell/code). Re-syncs~/.claudeinto the box volume by default (skip with--no-sync-configfor speed). Re-runsrebuildPluginNativeDeps(idempotent — gated by per-plugin marker). If a tmux session with the configured name already exists, just attaches; otherwise starts a fresh one. Post---args are forwarded to claude only when starting a fresh session.agentbox codex [-- <codex-args>...]— the Codex parity ofagentbox claude: does everythingcreatedoes, then launches OpenAI Codex in a detachable tmux session (codexsession name;--session-name/ configcodex.sessionNameoverride). ForwardsOPENAI_API_KEYfrom host env.--isolate-codex-configopts into a per-boxagentbox-codex-config-<id>volume. Subcommands mirror claude:agentbox codex start [box] [-- <codex-args>...](start a session in an existing box, auto-unpause/start,--no-sync-configto skip the~/.codexresync),agentbox codex attach [box](attach/start without resyncing),agentbox codex login [-- <args>](sign in via a throwaway container — defaults tocodex login --device-auth, the headless device-code flow; pass-- --api-keyfor the API-key path). Skips the claude-only steps (setup wizard, plugin rebuild).apps/cli/src/commands/codex.ts. Codex is baked into the base image, but a box built from a checkpoint captured before Codex support (or an older base image) won't have the binary —ensureCodexInstalled(codex.ts) detects that andnpm install -g @openai/codexs it into the box's writable layer at create/start time (mirrors--with-playwright; fastcommand -vno-op when codex is already present).agentbox opencode [-- <opencode-args>...]— the OpenCode parity ofagentbox codex: creates a box and launches OpenCode (sst/opencode, the multi-provider terminal agent) in a detachable tmux session (opencodesession;--session-name/ configopencode.sessionName).--isolate-opencode-configopts into a per-box volume. Subcommands mirror codex:agentbox opencode start [box](auto-unpause/start,--no-sync-configto skip the config resync),agentbox opencode attach [box],agentbox opencode login [-- <args>](runs the interactiveopencode auth loginprovider picker in a throwaway container;-- --provider anthropicto skip selection).ensureOpencodeInstalledhandles stale-checkpoint boxes (mirrorsensureCodexInstalled).apps/cli/src/commands/opencode.ts.agentbox list/inspect— read from~/.agentbox/state.jsonand cross-referencedocker inspectfor live state (running/paused/stopped/missing).inspectsurfaces the claude tmux session status (running / not running) when the container is up.agentbox pause/unpause—docker pause/docker unpause.agentbox stop/start—docker stop/docker start./workspacelives in the container's writable layer so it survives stop/start without any mount work;startonly re-launchesagentbox-ctl+dockerd+ Xvnc (they die with the container). It first revalidates that each registered worktree's main.git/still exists on the host (the bind-mount is baked in at create time; if the host dir was deleted while the box was stopped, restart would just produce an opaque mount error).agentbox status/logs— proxy into the in-boxagentbox-ctlviadocker exec(seein-box-supervisor.md).statusrendersTASKS+SERVICESsections (the service row has aBLOCKED ONcolumn forwaitingservices) and reports the claude tmux session state (via theclaude-sessionwire op).agentbox wait <box>— blocks until all autostart tasks + services are ready. Thin wrapper over the daemon'swait-readyop; useful for scripted readiness gates.agentbox code <box>— opens VS Code or Cursor Desktop against the box via the Dev Containers extension. Both IDEs are supported transparently: by default the CLI preferscodeand falls back tocursorifcodeisn't in PATH; pass--ide vscode/--ide cursorto force a flavor. Auto-unpauses paused boxes and starts stopped ones (re-launching the ctl/dockerd/Xvnc daemons). Waits forwait-ready(default 120s) unless--no-wait, then writes/workspace/.vscode/tasks.json(sentinel-protected;--regen-tasksto overwrite a user-owned file) so the IDE auto-opens terminal panels tailing each service's log. The launcher uses<cli> --folder-uri "vscode-remote://attached-container+<hex>/workspace"(Cursor inherits thevscode-remote://scheme as a VS Code fork);--printreturns the URI instead. Each box mounts both server volume sets so either IDE can attach to any box without recreating: per-boxagentbox-vscode-server-<id>+agentbox-cursor-server-<id>(server binary + TS cache, ~70MB downloaded on first attach), plus the sharedagentbox-vscode-extensions+agentbox-cursor-extensionsvolumes for downloaded extensions across all boxes.agentbox shell <box> [-- <cmd>...]— interactive shell convenience: drops you intobash -lasvscodein/workspace. Auto-unpauses paused boxes and starts stopped ones (same recovery asagentbox code). By default the interactive shell runs inside a detachable tmux session —Ctrl+a ddetaches without killing it (the same chordagentbox claudeuses, via the sharedbuildTmuxSessionArgsinpackages/sandbox-docker/src/claude.ts), andagentbox shell attach <box>reattaches.--no-tmux(config keyshell.tmux: false) runs a plaindocker execshell with no session — closing the terminal kills it. One-shot-- <cmd>and any non-interactive/piped use are never tmux-wrapped — they stay on a plaindocker exec(bash -l -c '<args joined>') so stdout stays machine-readable.--user <name>overrides the in-container user (the tmux server is per-user);--no-logininvokesbashwithout-l. Forwards hostTERMso truecolor/hyperlinks survive. The wrapped-pty footer shows theControl+a d: detachhint only for the tmux-backed shell —runWrappedAttach'sdetachableflag (defaultmode === 'claude') drives the chord + footer, decoupled from themodelabel.- N shells per box — a box can hold multiple shell sessions, not just one (Claude stays exactly one agent per box). tmux is the single source of truth — there is no
BoxRecord/state.jsonshell registry; everything is derived live fromdocker exec <box> tmux list-sessions. Shell sessions are tmux sessions named with a reservedshellprefix:shell(the default,agentbox shell),shell-2/shell-3/… (auto-numbered,agentbox shell --new), andshell-<label>(named,agentbox shell -n <label>).isShellSessionName/shellSessionName/shellLabel/allocateShellSessionName/parseShellSessionList/listShellSessions/killShellSession(all inpackages/sandbox-docker/src/shell-session.ts) implement the prefix scheme — a pure string rule that never collides with theclaudeagent session or a*-dashgrouped sibling.agentbox shell ls <box>lists a box's shells (label / attached / created);agentbox shell kill <box> -n <label>(or--all) drops one.agentbox shell/agentbox shell attachtake-n <label>to target a specific shell; the detach notice gains a-n <label>suffix for non-default shells.agentbox listshows aSHELLScount column (listBoxes()runslistShellSessionsper running box —[]for paused/stopped, nodocker execreach);agentbox status/agentbox status --inspectadd a shells summary. The dashboard's[s]"open shell" action start-or-attaches the tracked tmuxshell(so it's the same session the CLI sees) — a full per-box shell picker in the TUI is not built yet. agentbox download [box]— box→host download of/workspace(gitignore-aware;--with-env/download envfor gitignored env files).agentbox download config [box]— box→host download of justagentbox.yaml(gitignore-bypassing, fixed pattern['agentbox.yaml']; thin specialization of thedownload envflow viapullToHostwithrespectGitignore: false; for syncing back an in-box-edited/regenerated config —apps/cli/src/commands/download-config.ts).agentbox download claude [box]— additive box→host download of Claude extensions (skills/,plugins/,agents/,commands/under~/.claude). Reads the claude-config volume via a throwaway helper container (the exact reverse ofensureClaudeVolume's forward sync), so the box need not be running. Never overwrites an item already on the host; excludesagentbox-*skills (the box-onlyagentbox-setup, image-seeded into the claude-config volume byseedSetupSkillIntoVolume— never written to the host's~/.claude); the two plugin registry JSONs are merged host-side (only box-only keys added, the forward/home/vscode/.claude/plugins/rewrite reversed). With the sharedagentbox-claude-configvolume the download aggregates extensions installed in any box (warned).packages/sandbox-docker/src/claude.tspullClaudeExtras+ pure helpers inclaude-pull.ts(library symbols keep thepullname; the CLI verb is the only thing that renamed).agentbox download codex [box]— additive box→host download of Codex config (config.toml,auth.json,prompts/) from the codex-config volume into~/.codex; never overwrites an existing host file (pullCodexConfigincodex.ts).agentbox download opencode [box]— additive box→host download of OpenCodeauth.json(→~/.local/share/opencode) +opencode.json/agents/commands/themes(→~/.config/opencode) from the opencode-config volume (pullOpencodeConfiginopencode.ts).agentbox cp <src> [dst]— file/dir copy between host and box, modeled ondocker cp. Direction is picked by which arg carries a<name>:prefix (a:not preceded by/); both-sides or neither-side → usage error. Docker provider streams a tar pipe (hosttar -cf -⇄docker exec -i tar -xf -,packages/sandbox-docker/src/box-cp.ts) rather than buffering — execa's default 100 MBmaxBufferotherwise silently fails large copies with "tar: write error". On upload, a follow-updocker exec --user root chown -R 1000:1000 <dst>re-owns the landed files tovscode(best-effort). Heavy regenerable dirs (DEFAULT_CP_EXCLUDES:.git,node_modules,bin,obj,packages,dist,.next,target) are dropped by default →tar --exclude; keep them with--no-default-excludes, add more with repeatable--exclude=<glob|name>. Uploads whose post-exclude size exceedsbox.cpMaxBytes(default 100 MiB) are blocked with adu-style tree of the biggest remaining folders/subfolders + a strategy (split per-folder /--exclude/--yes);--yesoverrides (apps/cli/src/lib/dir-breakdown.ts:measureCopy). Auto-unpauses/starts the box if needed. Host path is optional on download (defaults to cwd); required on upload. Box ref accepts the usual id/prefix/name/container/project-index. Cloud providers (packages/sandbox-cloud/src/cloud-cp.ts) thread the same excludes through theirtar -czfstage. The in-boxagentbox-ctl cp toHost|fromHostforwards--exclude/--no-default-excludes/--yesto the host CLI through the relay (cp.*RPC). Implementation:apps/cli/src/commands/cp.ts,packages/sandbox-docker/src/box-cp.ts,packages/sandbox-cloud/src/cloud-cp.ts.agentbox destroy— force-removes container + volumes + snapshot dir + per-box run dir (~/.agentbox/boxes/<id>/) + state record (prompts unless-y). Per-box claude-config,agentbox-vscode-server-<id>, andagentbox-cursor-server-<id>volumes are removed too; the sharedagentbox-claude-config+agentbox-vscode-extensions+agentbox-cursor-extensionsvolumes are preserved. Each registered git worktree is removed from the host viagit worktree remove --forcebefore the box dir is wiped.agentbox prune— dropsmissingstate records;--allalso reaps orphanagentbox-*containers / volumes / snapshot dirs (allowlists all three shared volumes —agentbox-claude-config,agentbox-vscode-extensions,agentbox-cursor-extensions— and per-box variants of either kind that belong to a surviving box).--allalso sweeps the legacyagentbox-relaycontainer +agentbox/relay:devimage +agentbox-netnetwork left over from the old in-docker relay design.agentbox relay status|stop|start|restart— manage the host relay process.statusreads the pidfile + GETs/healthzand renders running / not-responding (zombie) / not-running;--jsondumps theRelayStatusshape.stop/start/restartwrapstopRelay()/ensureRelay()(both idempotent — the same helpersself-updateuses). Backed bygetRelayStatus()inpackages/sandbox-docker/src/relay.ts(re-exported from@agentbox/sandbox-docker); CLI inapps/cli/src/commands/relay.ts.agentbox prepare— one-stop "set up the base image / show what's prepared" command.agentbox prepare(no args) prints a status table across all providers: docker'sagentbox/box:devimage + the three shared docker volumes (agentbox-claude-config,agentbox-codex-config,agentbox-opencode-config), plus all daytonaagentbox*snapshots (state / size / age /(pinned in project)marker) andagentbox*volumes — including the legacy per-agent ones that the daytona path no longer uses (visible reminder to clean them up via the Daytona dashboard).agentbox prepare --provider dockerpre-builds the local Dockerfile.box image (idempotent).agentbox prepare --provider daytona [--name X] [-y]builds a layeredImage.fromDockerfile().addLocalFile().runCommands()for the three host agent static tarballs and registers it as a named org-scoped snapshot via the documenteddaytona.snapshot.create({ name, image })API (daytona.io/docs/en/snapshots), then pinsbox.image: <name>into the project config — subsequentagentbox create --provider daytonaboots in seconds with the agent static config (plugins/skills/marketplaces/settings) already in place. Replaces the oldagentbox daytona publish-snapshot(which used_experimental_createSnapshot, broken upstream).agentbox self-update— self-updates the CLI then refreshes the local runtime. Detects how it was launched (apps/cli/src/exec-method.ts'sdetectExecutionMethod):npm→npm install -g @madarco/agentbox@latest,pnpm→pnpm add -g @madarco/agentbox@latest,npx/direct(dev clone) → skip the package update with a note. Then best-effortdocker image rm -f agentbox/box:dev(rebuilds lazily on nextcreate/claudeviaensureImage()) and reloads the relay viastopRelay(). The relay is only respawned in-process (ensureRelay()) when no self-update ran — after a real self-update this process is the stale build, so it just stops the relay and the next box command brings up the new one.-yskips the prompt,--dry-runpreviews,--skip-selfdoes only the image+relay refresh.stopRelaylives inpackages/sandbox-docker/src/relay.ts(reuses the existing pidfile helpers);removeImageindocker.ts.- Notion integration (relay-gated, host CLI) —
agentbox-ctl integration notion <op>and the in-boxntn/notionshims proxy a small allowlist of ops (whoami, read-onlyapipassthrough,page.create,page.update) through the host relay to the host's authenticatedntnCLI. Reads pass through; writes prompt the host for approval (sameaskPromptgate asgit push/gh pr create).refuseUnsafeApiCallkeepsapiread-only: GET to any endpoint, plus the read-by-POST endpointsv1/search/v1/databases/{id}/query/v1/data_sources/{id}/query(JSON body via-d); every other method/endpoint — writes (v1/pages,v1/comments),-X PATCH/DELETE, and the host-file--input/--filebody sources — is refused with exit 65. (Method is inferred from the body source, matching realntn—-d/inline, notgh-style-f/-F— so a body can't slip a write past the gate.) The box never holds a Notion token —printenv | grep -i notioninside a box returns nothing. Off by default — enable per project withagentbox config set --project integrations.notion.enabled true(typed config keyintegrations.notion.enabledinpackages/config/src/types.ts); the relay re-reads the layered config on every call so a flag flip takes effect with no bounce, and a disabled integration is refused before any host process is touched.agentbox doctorreports each integration in a dedicatedintegrations:group —infofor disabled,warnfor not-installed / not-logged-in (with install/login hints from the connector descriptor),okwhen authed. Connector descriptor lives inpackages/integrations/src/connectors/notion.ts; the relay spine inpackages/relay/src/integrations.ts(parseIntegrationMethod,assertIntegrationReady,refuseIfIntegrationDisabled,runHostIntegration) is dispatched identically by docker (server.ts) and cloud (host-actions.ts) per the "fix across all providers" rule. Adding a service (Linear / Trello / ClickUp) is one new descriptor file + a one-line registry add — no relay change. Seeintegrations.md(design) andnotion_backlog.md(per-task status). - Linear integration (relay-gated, host CLI) —
agentbox-ctl integration linear <op>and the in-boxlinearshim proxy a strict allowlist of ops (whoami,issue.list/issue.mine/issue.view/issue.query,team.list, query-onlyapiGraphQL passthrough,issue.create/issue.update/issue.comment) through the host relay to the host's authenticatedlinearCLI (@schpet/linear-cli). Same gate model as Notion: reads pass through, writes prompt. Theapiop'srefuseGraphqlNonQueryconsumes value-bearing flag values (--variable,--variables-json) so a benign JSON payload isn't misread as a positional, rejects any GraphQLmutation/subscriptionoperation with exit 65 (the GraphQL analogue ofrefuseApiNonGet), AND refuses--variable key=@<path>host-file loads (the@<path>syntax would let the box exfiltrate host files via GraphQL variables).linear auth token(which would print the raw API token to stdout) andauth login/logout/migrate/defaultare hard-rejected by the shim and absent from the connector allowlist — three defenses in series.issue delete/team delete/team createare off-list (destructive).issue.commentmaps tolinear issue comment add—@schpet/linear-cliv2 usesadd, notcreate. Connector descriptor atpackages/integrations/src/connectors/linear.ts; shim atpackages/sandbox-docker/scripts/linear-shim; typed flagintegrations.linear.enabled(default false); doctor row is driven offALL_CONNECTORSso the linear entry lights up automatically. Seeintegrations.md(design) andlinear_backlog.md(per-task status). - In-box
agentbox-ctl git pull|push [-- <args>](and any tool the agent runs that shells out via this command) — POSTs to the host relay's/rpc, which executes git on the host with the user's SSH agent + gitconfig. Commits made inside the box land in the host's main.git/immediately (the.git/is bind-mounted RW at its identical absolute path);git pushis the only operation that needs host credentials, hence the RPC. - Browser support — Vercel's
agent-browseris baked into the box image (npm install -g agent-browser). The Chromium binary that drives it is not Chrome for Testing (no Linux ARM64 build, and Noble'schromium-browserapt package is a snap stub that doesn't run in containers) — it's Playwright's Chromium, which has working linux/arm64 + linux/amd64 builds. It is not baked:ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/local/bin/chromiumpoints at thechromium-resolverscript (packages/sandbox-docker/scripts/chromium-resolver, installed at/usr/local/bin/chromium), which on first launch reuses the newest installed Playwright Chromium and otherwise runsplaywright install chromium— preferring the project's pinned Playwright (/workspace/node_modules/.bin/playwright, so the build matches the project's own tests and they share one binary), else the box's globalplaywrightas a fallback downloader. This avoids baking a version-pinned Chromium that goes stale the instant a project pins a different Playwright (the old bug: a baked build masqueraded in~/.cache/ms-playwright, the project'splaywright installfetched a different one, and agents waiting on the baked path hung). Chrome runtime libs (libnss3, libxkbcommon0, libcups2t64, etc. — Noble names with thet64suffix where applicable) are installed once at image build. Agents inside the box invokeagent-browserdirectly; sessions/auth/cookies persist under~/.agent-browser/in the container's writable layer, so they survivepause/unpauseandstop/startand are wiped ondestroy. The flag--with-playwrighton bothagentbox createandagentbox claudeadditionally runsnpm install -g @playwright/cli@latestinside the container at create time (recorded asBoxRecord.withPlaywrightand surfaced inagentbox status --inspect) — a separate package from theplaywrightruntime baked into the image. - Web service port — every box reserves container
:80at create with an unconditionaldocker run -p 127.0.0.1:0:80(immutable afterdocker run, so it's reserved up front even though theexpose:-flagged service is usually only known after the in-box wizard writesagentbox.yaml). The ephemeral host port is resolved viadocker portand persisted toBoxRecord.webHostPort(re-resolved on everystartBox, likevncHostPort, since Docker reallocates it).getBoxEndpointsemits akind: 'web'endpoint whose URL is the published loopback port (http://127.0.0.1:<webHostPort>) — uniform across engines, not OrbStack-dependent; it's the primary clickable link inagentbox list/status. Until a service declaresexpose:it renders asweb reserved (...). The in-box:80 → expose.portforward is the supervisor-ownedWebProxy(seein-box-supervisor.md). Pre-feature boxes (noBoxRecord.webContainerPort) have no reservation and are skipped bystartBox— recreate to enable. - Portless web URLs (Docker Desktop) — Docker Desktop has no per-container DNS like OrbStack's
.orb.local. The first-run prompt (Docker Desktop only, persisted to global configportless.enabled;--portless/--no-portlessoverride) offers to set Portless up:maybePromptPortless(apps/cli/src/portless-prompt.ts) runsnpm install -g portlessif the CLI is missing, then starts the proxy withportless proxy start --no-tls -p 1355— a high port + no TLS so it needs no root password and no CA-trust prompt. Consequence: box web apps are served athttp://<box-name>.localhost:1355(http, with a port). An already-running proxy (e.g. the user's own:443one →https://<box-name>.localhost) is left alone and used as-is. Whenportless.enabled,createBoxregistersportless alias <box-name> <webHostPort>and resolves the real URL viaportless get, storing it asBoxRecord.portlessUrl(alongside the route nameBoxRecord.portlessAlias).startBoxre-points the alias + refreshes the URL (Docker reallocates the host port each start);destroyBoxremoves the route.getBoxEndpoints+agentbox urlsurfaceportlessUrlon non-OrbStack engines (OrbStack keeps.orb.local;--loopbackforces the127.0.0.1URL). All Portless interaction is best-effort — an install/start/alias failure degrades to a printed hint and never blocks create (packages/sandbox-docker/src/portless.ts). The box image ships theportlessCLI (Node bumped 22→24 for it) and, whenportless.enabled, bind-mounts the host Portless state dir (~/.portless, or theportless.stateDirconfig override) at/home/vscode/.portlesswithPORTLESS_STATE_DIRset, so the in-boxportless list/getshare the host route registry (discovery). In-boxportless aliasfor arbitrary container ports does not work — those ports aren't host-published, so the host proxy can't reach them; only the box's published:80web port (handled host-side above) is routable. - In-box browser on the Portless URL —
agentbox screen(and the dashboard'sCtrl-a s) opens a headed Chromium inside the box (shown via VNC) on the box web app. Whenportless.enabled, that in-box browser loads the same<box-name>.localhostURL the host uses (BoxRecord.portlessUrl) — so the app is one origin from both sides (simplifies Next.jsallowedDevOrigins, OAuth, CORS). Chromium hard-codes*.localhost→ loopback and ignores/etc/hosts, so the box env (set atdocker runbyportlessBrowserEnvinportless.ts, whenportless.enabled+ non-OrbStack) carriesAGENT_BROWSER_ARGS=--host-resolver-rules=MAP <box-name>.localhost host.docker.internal— remapping the box's hostname to the host gateway — plusAGENT_BROWSER_IGNORE_HTTPS_ERRORS=1for a TLS host proxy's self-signed CA. The in-box request thus routes container → host Portless proxy → back into the box, hitting the exact host URL.screen.ts/ dashboardopenScreenpassrecord.portlessUrlas theensureBoxBrowsertarget only when a web service is exposed (elseabout:blank/ the loopback URL).agentbox url+Ctrl-a ualready openportlessUrlhost-side (the wrapped-pty shortcuts shell out toagentbox url/screen, so they inherit this). - Cloud signed preview URLs — for
agentbox create --provider daytonaboxes,agentbox urlandagentbox screenopen a signed preview URL with the auth token embedded in the host (https://{port}-{token}.proxy.daytona.work). The cloud provider'sresolveUrlcallsCloudBackend.signedPreviewUrl(port, ttl)(Daytona:sb.getSignedPreviewUrl(port, expiresInSeconds)) — distinct from the standardgetPreviewLinkURL used by the host poller for/bridge/*calls, which keeps the token in thex-daytona-preview-tokenheader. Default TTL is 3600s; override with--ttl <seconds>(1–86400).--loopbackis ignored for cloud boxes; the URL always goes through Daytona's proxy. - Cloud checkpoints —
agentbox checkpoint create -n setup <cloud-box>captures the live Daytona sandbox's filesystem state into a named provider snapshot viasb._experimental_createSnapshot(), and persists a thin manifest under~/.agentbox/cloud-checkpoints/<backend>/<projectHash-mnemonic>/<name>/manifest.json. Subsequentagentbox create --provider daytona --checkpoint setup(orbox.defaultCheckpoint) provisions a new sandbox viaclient.create({ snapshot: <prefixed-name> }), skipping the workspace-seed step because/workspaceis already in the snapshot. Snapshot names are project-prefixed (agentbox-ckpt-<hash>_<mnemonic>-<name>) so multiple projects in the same Daytona org don't collide.agentbox checkpoint lsmerges Docker + cloud checkpoints in a single view. The wizard's "starting from checkpoint" announcement is provider-aware: a Dockersetupcheckpoint won't trigger a misleading skip when creating a cloud box. - Cloud editor attach —
agentbox code <cloud-box>opens VS Code or Cursor with Remote-SSH attached to the Daytona sandbox's/workspace.--ide vscode(default) /--ide cursorselects the flavor — Claude Code users install the extension inside whichever IDE they pick and it Just Works once Remote-SSH is up. The CLI mints a fresh 60-min SSH token viaprovider.buildAttach(box, 'shell', { noTmux: true })(which calls Daytona'screateSshAccess(60)) and writes a managed block to~/.ssh/config(# BEGIN agentbox cloud box <alias>…# END …) so the folder URI is the stablevscode-remote://ssh-remote+agentbox-cloud-<name>/workspace.UserKnownHostsFile /dev/null+LogLevel ERRORare pinned in the block (Vagrant-style — many sandboxes share one DNS name, so persistent known_hosts entries would generate false-positiveHostKeyVerificationFailed). Token expiry is handled by re-invocation: re-runagentbox codeand the block is rewritten with a fresh token;agentbox destroyremoves the block. Auto-terminals (/workspace/.vscode/tasks.json) is docker-only for now — cloud users author their own. - Cloud noVNC —
agentbox screen <cloud-box>works end-to-end against Daytona. The same VNC stack used by Docker (Xvnc + websockify + noVNC, all baked intoDockerfile.box) is launched inside the sandbox bylaunchCloudVncDaemonat create time and re-launched on everystart(mirrors Docker'slaunchVncDaemon). A per-boxvncPasswordis generated host-side, persisted on the cloudBoxRecord, and threaded into the launcher via inlineAGENTBOX_VNC_PASSWORDonbackend.exec(so stop/start doesn't rely on Daytona preserving sandbox env). The signed preview URL is appended with/vnc.html?autoconnect=1&password=…so the browser jumps straight to the desktop.--no-vncat create skips the daemon launch;agentbox screenrefuses with the same "VNC is disabled" message Docker uses. - VNC web client — every box launches Xvnc (TigerVNC) on display
:1plus websockify serving noVNC at container port6080, by default.ENV DISPLAY=:1is baked into the image, so any GUI process started inside the box (Chromium via agent-browser,chromiumfrom a shell) renders to that display. The image declaresEXPOSE 6080, which is what makes OrbStack's<container-name>.orb.localauto-DNS route correctly. On OrbStack the URL ishttp://agentbox-<name>.orb.local:6080/vnc.html?autoconnect=1&password=<pw>; Docker Desktop hosts use the auto-allocated host port fromdocker run -p 127.0.0.1:0:6080(resolved viadocker portafterrunBox, persisted toBoxRecord.vncHostPort). The password is an 8-char[A-Za-z0-9]per-box random (BoxRecord.vncPassword) embedded in the auto-connect URL. The supervisor script ispackages/sandbox-docker/scripts/agentbox-vnc-start— baked at/usr/local/bin/agentbox-vnc-startin the image, launched viadocker exec -d --user vscodefromlaunchVncDaemon()(mirrorslaunchCtlDaemon— best-effort, idempotent, relaunched bystartBox()after stop/start because Xvnc dies with the container). Opt out withagentbox create --no-vnc/agentbox claude --no-vnc—BoxRecord.vncEnableddecides whetherstartBoxre-runs the launch.agentbox screenopens this noVNC URL in the host browser (--loopbackforces the 127.0.0.1 URL,--printprints it). Distinct fromagentbox url, which opens the web app on container:80. - Box self-awareness — every box is stamped at
docker runwithAGENTBOX=1,AGENTBOX_BOX_ID,AGENTBOX_BOX_NAME,AGENTBOX_HOST_WORKSPACE(absolute host path of the workspace cwd — informational, not a mount), and (when the workspace has anagentbox.yamlancestor)AGENTBOX_PROJECT_ROOT+AGENTBOX_PROJECT_INDEX. The same key=value pairs are written to/etc/agentbox/box.envafterrunBox(viawriteBoxEnvFileinpackages/sandbox-docker/src/box-env.ts);/etc/profile.d/agentbox.shsources it soagentbox shell <box>and anybash -lsee the vars even when launched outside docker-run's env. A short prose hint about sandbox constraints (DinD available, no SSH creds → useagentbox-ctl git pull|push) is baked at/etc/claude-code/CLAUDE.md— note that path is not currently a documented Claude Code load location, so today the hint is inspectable-only; in-box agents discover constraints via the env vars. - Docker-in-Docker — every box ships with
docker.io+iptablesand runs an in-boxdockerdlaunched after the ctl daemon is up vialaunchDockerdDaemon()(mirrorslaunchVncDaemon—docker exec -d --user root /usr/local/bin/agentbox-dockerd-start, best-effort, idempotent, relaunched bystartBox()because dockerd dies with the container — gated onBoxRecord.dockerVolumeso pre-DinD records don't try to start a daemon that isn't installed). The storage driver is selected at runtime byagentbox-dockerd-start, not pinned in the image: the script reuses the driver an already-populated/var/lib/dockerwas initialized with (dockerd can't switch a populated data root); on a fresh data root it probes the kernel-nativeoverlay2(mount a test overlay on the data-root filesystem + execve a binary from the merged dir — the exactfuse-overlayfsfailure mode) and uses it if it works, else falls back tofuse-overlayfs. It writes the resolvedstorage-driverinto/etc/docker/daemon.jsonbefore launching dockerd (the baked daemon.json carries onlyiptables: true).overlay2is preferred becausefuse-overlayfsis broken on recent kernels — on Docker Desktop's 6.x linuxkit kernel every innerdocker runfails at execve() withexec ...: invalid argument./dev/fuse, SYS_ADMIN, and apparmor:unconfined stay load-bearing: SYS_ADMIN for theoverlay2mount, the full set for thefuse-overlayfsfallback. The vscode user is added to thedockergroup at image build, so the agent invokesdockerwithout sudo. The data root/var/lib/dockeris the per-box named volumeagentbox-docker-<id>, removed ondestroy. Pass--shared-docker-cache(or setbox.dockerCacheShared: truein any config layer) to swap to the sharedagentbox-docker-cachevolume — preserved ondestroyand allowlisted inprune --all, but mutually exclusive at runtime (only one box can hold dockerd's lock on/var/lib/dockerat a time). The outer container always gets--cap-add=NET_ADMIN,--security-opt=seccomp=unconfined, and--cgroupns=private(in addition to the existingSYS_ADMIN//dev/fuse/apparmor:unconfined);--privilegedis not used, so the same container runs cloud-portably (E2B/Modal/etc. accept the cap_add path). Three non-obvious bits inagentbox-dockerd-startmake this work on OrbStack and Docker Desktop: (1)mount -o remount,rw /sys/fs/cgroupbecause the outer engine bind-mounts cgroup v2 RO and dockerd has tomkdir /sys/fs/cgroup/dockerfor its own slice; (2)mount -o remount,rw /proc/sysbecause dockerd writes to/proc/sys/net/ipv6/conf/<veth>/disable_ipv6during default-bridge setup, and/proc/sysis RO under the same hardening; (3)rm -f /var/run/docker.{pid,sock}before relaunch —/var/runis in the container's writable layer (not a volume), so a stale pidfile from beforedocker stopsurvives acrossstartand dockerd refuses to launch ("PID still running" — that PID got reassigned tosleep infinity). Both remounts are SYS_ADMIN-gated and only affect the box's own namespaces, never the host. Cloud parity: Daytona boxes run the sameagentbox-dockerd-startscript vialaunchCloudDockerdDaemon(auto-invoked bycloudProvider.create()/start()), sodockerworks out of the box on the cloud path too — no opt-in command.
What's not built yet (don't claim it works)
- Auto-refresh of the merged host export (inotify-driven
agentbox openkeeps~/.agentbox/boxes/<id>/workspacein sync without manual refresh). Today refresh is on-demand only. - Codex in the dashboard (
agentbox dashboardcompositor) — the dashboard's footer/pane wiring is still claude-only;agentbox codexworks standalone. (Claude Code + Codex + tmux + agent-browser are all baked into the image; VS Code Server is downloaded on first attach.) - Pre-warming the VS Code Server in the image (server version is keyed to host's VS Code Desktop version, so first attach to a fresh box still pays the ~70MB download; subsequent attaches to the same box are instant).
- Auto-pause-on-idle / auto-stop policy.
- Auto-refresh of the merged host export (inotify-driven
agentbox openkeeps~/.agentbox/boxes/<id>/workspacein sync without manual refresh). Today refresh is on-demand only. - Exporting the container writable layer on destroy (
--export <path>flag). The live merged export under~/.agentbox/boxes/<id>/workspaceis wiped with the box (useagentbox checkpoint createfirst if you want to keep the state). - Additional
/rpcmethods beyondgit.pull/git.push/gh.pr.*/integration.<svc>.<op>. The dispatch is a single switch inpackages/relay/src/server.ts— easy to extend (target ideas:git.fetch,npm.publish, anything else that needs host creds). - A user-facing
agentbox events/agentbox notifyCLI on top of the relay's ring buffer. Today you canagentbox-relay tail(against the host process at 127.0.0.1:8787) ortail -f ~/.agentbox/relay.log. - Event-buffer persistence (events are lost on relay restart; the token registry is rehydrated from
state.jsonon nextagentbox create, but historical events aren't). - Remote providers (E2B / Modal / Daytona / Vercel Sandbox).
- Non-macOS host support for the snapshot path (
cp -cis APFS-only; Linux fallback torsync --excludeis TODO).