Files
spoon/docs/agent-terminal.md
T
Gabriel Brown 24a516c2b5 Terminal: job image tools (neovim/tmux), build-arg wiring, docs
- agent-job image: add neovim, tmux, less, unzip, wget, locales for the
  interactive shell (tmux powers cross-reconnect session persistence)
- Wire NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL as a build arg (Dockerfile +
  compose.yml) since NEXT_PUBLIC vars are inlined at build time
- docs/agent-terminal.md: architecture, env, nginx WS exposure, dev testing,
  security; note the build-time var in docs/compose.prod.yml
2026-06-24 08:27:10 -04:00

6.6 KiB

Workspace interactive terminal

A real shell inside the agent workspace, shown as the Terminal tab in the workspace UI. It's an xterm.js front end bridged to a bash/tmux PTY running in a persistent per-job container (the agent job image), mounting the same workspace the editor and agent use.

Architecture

browser (xterm.js)
   │  1. GET /api/agent-jobs/:id/terminal-token   (Convex-auth'd, owner only)
   │     → { url: "wss://worker…/jobs/:id/terminal?token=…", expiresAt }
   │
   │  2. WebSocket  wss://worker.<domain>/jobs/:id/terminal?token=…
   ▼
nginx ── upgrade ──► spoon-agent-worker :3921
                        │  verifyTerminalToken(token, jobId, secret)
                        │  dockerode exec -t  →  bash/tmux PTY
                        ▼
                     spoon-agent-term-<jobId>  (job image, mounts the workspace)
  • The browser never holds the worker secret. The Next app (which has already verified job ownership) mints a short-lived HMAC token; the worker verifies it.
  • Frames: binary = stdin/stdout bytes; text JSON {type:"resize",cols,rows} = resize. The token's 2-minute expiry is a connect window; an established session persists.
  • The shell runs tmux new-session -A -s spoon (falls back to bash -l), so reconnecting reattaches the same session. Idle containers are removed after SPOON_AGENT_TERMINAL_IDLE_MS (default 30m).

Configuration

Where Variable Required? Notes
Next app NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL Yes Browser-facing worker WS base, e.g. wss://worker.spoon.gbrown.org (prod) or ws://localhost:3921 (dev). Build-time (NEXT_PUBLIC): for the Docker image it must be passed as a build arg (wired in docker/Dockerfile + docker/compose.yml, sourced from the build env file), not a runtime env. Unset → the Terminal tab shows "not configured".
Next app SPOON_AGENT_TERMINAL_SECRET No HMAC secret for signing tokens. Falls back to SPOON_AGENT_WORKER_INTERNAL_TOKEN.
Worker SPOON_AGENT_TERMINAL_SECRET No Must match the Next app's. Falls back to SPOON_AGENT_WORKER_INTERNAL_TOKEN (already shared), so by default no new secret is needed.
Worker SPOON_AGENT_TERMINAL_IMAGE No Shell container image. Defaults to SPOON_AGENT_JOB_IMAGE.
Worker SPOON_AGENT_TERMINAL_IDLE_MS No Idle-container reap delay (default 1800000).

Because the secret defaults to the already-shared worker token, the only required step is setting NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL and exposing the worker over nginx (prod).

Exposing the worker (prod, nginx)

The worker and nginx are on the same nginx-bridge network, so nginx can reach spoon-agent-worker:3921 directly — no published port needed. Add a server block that upgrades WebSockets:

server {
  listen 443 ssl;
  server_name worker.spoon.gbrown.org;
  # ssl_certificate ... ; ssl_certificate_key ... ;

  location / {
    proxy_pass http://spoon-agent-worker:3921;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_read_timeout 86400s;   # keep idle terminals open
    proxy_send_timeout 86400s;
  }
}

Then set on the Next app: NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL=wss://worker.spoon.gbrown.org.

The worker's HTTP routes (/jobs/:id/tree etc.) require the internal bearer token, so exposing the worker host only usefully exposes the token-gated /jobs/:id/terminal upgrade. Still, restrict the server block to TLS.

Dev testing (no nginx)

The dev worker runs on the host at localhost:3921 (bun dev:next:worker), so the browser can hit it directly:

NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL=ws://localhost:3921

Note: the terminal uses dockerode against the Docker socket. In dev with Podman, point it at the Podman socket (run podman system service and set DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock), or run the worker in Docker mode. Prod (Docker socket mounted) works as-is.

Security

  • Owner-only: the token route uses Convex auth + assertOwned.
  • Tokens are short-lived (2m connect window), job-scoped, HMAC-signed.
  • A shell in the workspace can reach the network and the repo's git credentials. This is intended for the single-user self-hosted deployment; do not expose the worker domain without TLS, and keep the deployment single-tenant.

Tools in the shell

The job image ships bash, tmux, neovim, git, ripgrep, jq, python3, node, bun, pnpm, yarn, curl/wget, unzip. Bring your own dotfiles by cloning them in-session (e.g. git clone <dotfiles> ~/.config/...); persistent auto-cloning of a dotfiles repo is a planned follow-up.