Skip to content

Latest commit

 

History

History
28 lines (26 loc) · 13.5 KB

File metadata and controls

28 lines (26 loc) · 13.5 KB

In-box supervisor (@agentbox/ctl)

Part of the AgentBox docs. Start at CLAUDE.md.

  • Reads /workspace/agentbox.yaml; runs declared tasks (one-shot) and services (long-running) under a DAG scheduler. Tasks transition pending → waiting → running → done | failed | skipped; services transition pending → waiting → starting → running → ready | unhealthy | crashed | backoff | stopped. waiting is distinct from starting so blockedOn can surface in agentbox status. Restarts crashed services with exponential backoff and captures logs to /var/log/agentbox/<svc>.log.
  • Credential watcher (credentials-watcher.ts, started by daemon.ts unless AGENTBOX_CREDENTIAL_SYNC=0 — the wire form of box.credentialSync): polls the three agent credential files (~/.claude/.credentials.json, ~/.codex/auth.json, ~/.local/share/opencode/auth.json) every 15s (mtime, then sha256) and posts a credentials-updated relay event with the base64 blob when a shape-valid change appears (claude: non-empty claudeAiOauth.refreshToken; codex/opencode: non-empty JSON). The first scan posts too — self-heals a refresh that happened while the relay was down (the host's newest-wins gate makes it a no-op otherwise). Paths/shapes are drift-tested against AGENT_SYNC_SPECS (test/credentials-watcher.test.ts). The host side is the relay's CredentialsFanout — see host-relay.md.
  • needs: on any unit forms a DAG (cycles + unknown refs rejected at config load). Independent units launch in parallel.
  • ready_when: declares a readiness probe per service: port (TCP connect to 127.0.0.1:<port> by default), log_match (regex over stdout/stderr), or http (GET; expects 2xx by default). Probe lives in packages/ctl/src/probe.ts. on_timeout: kill (default) re-enters the restart policy; on_timeout: mark_unhealthy leaves the process running but flags the service — the escape hatch for legitimately slow cold starts.
  • expose: { port: <int>, as: 80 } on a service marks it as the web service (at most one; as must be 80 — the only container port AgentBox reserves; RESERVED_WEB_PORT in config.ts / WEB_CONTAINER_PORT in @agentbox/sandbox-docker). The supervisor owns an in-process Node TCP forwarder (WebProxy, packages/ctl/src/web-proxy.ts) that binds container :80127.0.0.1:<expose.port>, (re)pointed by applyWebProxy() on init/reload and torn down in stopAll — so the wizard writing agentbox.yaml post-create + agentbox-ctl reload activates it with no box restart. Binding :80 as non-root vscode works because the image grants the node binary cap_net_bind_service (setcap in Dockerfile.box). The expose mapping rides in the status snapshot (BoxStatusServiceEntry.expose) so the host knows the web service even when agentbox.yaml lives only in the box.
  • Wire ops: status returns { services, tasks }; task-status returns task list; wait-ready { timeoutMs?, units? } blocks daemon-side until all autostart units reach their satisfying state, then resolves { ready: true } or { ready: false, timedOut, failed }; run-task { name, force? } resets a task back to pending so the scheduler reruns it (force also bypasses the run_once skip).
  • run_once: on a task (handled in TaskRunner.launch, supervisor.ts) makes a re-run a no-op when already satisfied. run_once: true → marker keyed by a SHA-256 of the resolved command (+cwd+env) at <stateDir>/tasks/<name> (stateDir defaults to DEFAULT_STATE_DIR = /var/lib/agentbox, the box rootfs — captured by checkpoints, never under /workspace); editing the command invalidates it. run_once: { check: <cmd> } → run the probe first; exit 0 = skip, no marker written (right for state outside the checkpoint, e.g. a containerized DB). Marker writes happen in the child exit handler on code 0.
  • Replacement engine (@agentbox/core's replace.ts, re-exported by @agentbox/ctl's replace.ts which adds the yaml/fs loaders — kept in core so the host carry path can share it without the sandbox-core → ctl → relay → sandbox-core cycle): applyReplacements does {{AGENTBOX_*}} whitelist substitution (PLACEHOLDER_KEYS) + ordered {from,to,regex?} rules. Surfaced three ways: the top-level replacements: block (named rule-sets, parsed in config.ts), agentbox-ctl render (in-box CLI, commands/render.ts), and carry replaceEnvs/replace/rules (host-side, file-only, rendered to a temp by renderCarryEntries in @agentbox/sandbox-core before the per-provider copy — wired in sandbox-docker/create.ts and sandbox-cloud/cloud-provider.ts). {{AGENTBOX_BOX_HOST}} derives as <box-name>.localhost by default, but the public-URL cloud providers (vercel/daytona/e2b) set it explicitly at boot to the real preview host (e.g. <sub>.vercel.run) via launchCloudCtlDaemon — see cloud-providers.md §1.0.1.
  • {{AGENTBOX_AUTO_SECRET}} render generator (commands/render.tssecret.ts, not the pure engine — needs crypto + fs): a render-time pass before applyReplacements. Unnamed → fresh randomBytes(32).toString('base64url') per occurrence; :<name> → generated once and persisted at <stateDir>/secrets/<name> (0600), reused across renders. State dir resolved via the shared resolveWritableStateDir (state-dir.ts, extracted from the supervisor's marker-dir logic — try /var/lib/agentbox, fall back to <logDir>/state).
  • Declarative docker image: services (config.ts parseServiceparseImagesynthesizeImageCommand): a service sets image: instead of command: — either a bare ref string or a mapping { name, ports, env, args, container_name } (container config nested under image:); the parser synthesizes the start-or-run shell (docker container inspectdocker start + logs -f, else docker run with -p/-e/args), so the runner/DAG/ready_when/restart machinery is unchanged. Container reused by name across restarts (no auto-rm; env baked into -e, spec.env left unset). command and image are mutually exclusive (one required).
  • Listens on /run/agentbox/ctl.sock (UNIX socket, newline-delimited JSON). Both the in-box agentbox-ctl client and host commands talk to the same socket — but the host commands shell in via docker exec, not the bind-mounted socket: Docker Desktop / OrbStack's VM boundary breaks connect() from the mac side, even though the file is visible.
  • Host-facing service control (agentbox services, the hub box-detail panel + /api/v1/boxes/:id/services*): status is agentbox-ctl status --json; restart-one is the existing { op: 'restart', service } wire op (agentbox-ctl restart <name>). Restart-all is a host-side loop over the service list — deliberately not a new ctl wire op, so it works on already-baked boxes (docker + cloud snapshots) with no re-bake. The provider-agnostic argv/parse plumbing is single-sourced in packages/sandbox-core/src/box-git.ts (git ops live there too), shared by the CLI and the hub backend.
  • Bring-up — agentbox-ctl bootstrap (packages/ctl/src/commands/bootstrap.ts): the single, idempotent in-box self-configure step. Cloud providers run it via ONE host exec (kickCloudBootstrap, sandbox-cloud/src/bootstrap-launch.ts) on both create and resume, replacing the three former host-driven launches (launchCloud{Ctl,Dockerd,Vnc}Daemon, now removed). It reads injected env and: (1) optionally clones /workspace from a leased token-bearing AGENTBOX_CLONE_URL then scrubs origin to AGENTBOX_ORIGIN_URL (the plane / cloud-IDE path; the laptop path host-seeds and omits it); (2) launches dockerd → the ctl daemon → VNC, each only if not already live. Idempotency is load-bearing: the same kick serves create and resume, and Vercel's persistent snapshots keep daemons alive across resume — a blind relaunch would duplicate them. dockerd/VNC are best-effort; only a dead ctl daemon makes the kick exit non-zero (fatal). Flags: AGENTBOX_LAUNCH_DOCKERD=0 (vercel/e2b, no DinD), AGENTBOX_VNC_ENABLED/AGENTBOX_VNC_PASSWORD.
  • Ordering invariant: the in-box dockerd is launched and awaited ready before the ctl daemon — for cloud this happens inside agentbox-ctl bootstrap; for docker it's still the host-driven launchDockerdDaemon then launchCtlDaemon() in sandbox-docker/src/ctl.ts, repeated in startBox() (best-effort; missing/empty agentbox.yaml is fine and doesn't fail create). The supervisor starts services the moment it's up, so a docker-based service (docker run, docker compose up) would otherwise race a not-yet-ready /var/run/docker.sock. (Docker keeps its proven per-step launch; only the cloud providers were unified onto the bootstrap.)
  • In-box relay: the daemon also binds an in-box endpoint on 127.0.0.1:8788 (DEFAULT_BOX_RELAY_PORT; override AGENTBOX_BOX_RELAY_PORT) so the in-box ctl client has a symmetric AGENTBOX_RELAY_URL across providers. For cloud boxes that endpoint is a full mode: 'box' relay the host's CloudBoxPoller long-polls; for docker boxes it's a thin reverse proxy (packages/ctl/src/box-relay-forwarder.ts) that whitelists POST /rpc + POST /events and forwards to AGENTBOX_HOST_RELAY_URL (default http://host.docker.internal:8787). Keeping :8787 unbound inside the box lets a nested agentbox run (developing agentbox-from-inside-agentbox) claim its own host relay there. See host-relay.md.
  • Agent activity state (working | idle | waiting | end-plan | question | prompt | compacting | error) is aggregated by StatusReporter (status-reporter.ts) and pushed to the host relay (drives agentbox agent state/wait-for). The primary signal is hook-driven: Claude Code's managed hooks (packages/sandbox-docker/scripts/claude-managed-settings.json) call agentbox-ctl claude-state <state>; Codex/OpenCode report similarly. Two tmux-pane scrapers (tmux capture-pane -p per tick) back this up because hooks can miss:
    • codex-scraper.ts is codex's primary source (its JSON hooks are unreliable) — a full pattern table maps the pane to a state.
    • claude-scraper.ts is a promote-only safety net: Claude's hooks are reliable except for prompts they don't cover (MCP tool dialogs have no hook; the Notification:permission_prompt hook can drop), which strand the state on working. When the pane's bottom region shows a prompt (and no "esc to interrupt" working-hint), it calls reporter.markScreenWaiting(), which promotes workingwaiting only — never clobbering the richer hook-driven end-plan/question/idle. A real hook overwrites it back to working when the agent resumes, so there's no demote path. Both scrapers start in commands/daemon.ts.
  • Session-id capture for restore-on-restart (session-pointer.ts). The agent tmux session dies when a box stops/idle-pauses; on restart the host re-launches the agent resuming the same conversation, but the shared config volumes pool every box's sessions, so it can't tell which one was this box's. So the capture is lazy and in-box: the SessionStart/Stop Claude hooks pass --capture-session, and claude-state reads the hook payload's session_id and writes it to ~/.local/state/agentbox/claude-session (uuid-guarded). Codex exposes no resumable id (and its hooks are unreliable), so StatusReporter.setCodexState instead drops a presence marker ~/.local/state/agentbox/codex-active the first time codex shows activity. That dir is on the box's own writable layer (not a mounted volume), so the pointers survive stop/start + cloud pause and track /new / /branch. The host reads them on restart via provider.exec (apps/cli/src/agent-sessions.ts).
  • The bin is built as CJS (dist/bin.cjs) with all deps bundled — esbuild's ESM output poisons require() from CJS deps like commander. Library entry (dist/index.js) stays ESM.
  • Config validation has two sources of truth that must agree: the runtime parser in packages/ctl/src/config.ts (used by the daemon and the host pre-flight) and the JSON Schema at packages/ctl/schema/agentbox.schema.json (used by editors). packages/ctl/test/schema-drift.test.ts feeds the same fixtures to both and asserts they accept/reject identically. The schema can't express cross-field rules (max_ms >= initial_ms) — those cases are marked runtimeOnly in the fixtures.
  • Unknown keys warn, they don't throw — the one deliberate disagreement between the two. agentbox-ctl is baked into the box image, so a box created months ago parses an agentbox.yaml written against today's CLI: refusing to boot on a key it predates would make every schema addition brick every existing image. The runtime parser skips the key and records it in CtlConfig.warnings (the daemon logs them at startup; agentbox-ctl validate prints them and still exits 0). The JSON Schema stays strict — it drives editor autocomplete, where flagging a typo as you type is the point. Those fixtures are marked schemaOnly. Everything else (wrong types, bad DAG, unknown needs: target) still fails loud.
  • createBox pre-validates the host's agentbox.yaml via loadConfig before any docker work; a ConfigError aborts create with the formatted message. The in-container daemon re-validates on start (defence in depth, and necessary because the file lives in the overlay and can change after create).
  • Editors auto-wire via # yaml-language-server: $schema=… (Red Hat YAML extension reads it). The repo's .vscode/settings.json maps the schema for in-tree files.