Files
spoon/docs/agent-terminal.md
T
Gabriel Brown 4505471149 fix(worker): keep-id compat, zombie tmux clients, nvm in box image
- Box init + chownInBox exec explicitly as root: under podman keep-id the
  container's default user is the mapped uid, which cannot useradd/chown.
- Init renames an existing uid-1000 passwd entry (podman keep-id injects one
  named after the HOST user) instead of adding a duplicate that would win the
  getpwuid lookup and make whoami report the wrong name.
- Terminal connections tag their shell with a per-connection marker and reap
  the process group on WebSocket close: closing the attach socket never
  killed the exec'd shell, so every disconnect leaked an attached tmux client
  (session stuck at the smallest zombie's size; enough dead clients starve
  tmux rendering for live ones).
- Job image: nvm 0.39.5 at /usr/local/nvm (user-writable) with
  /etc/profile.d/nvm.sh, matching what user dotfiles expect; docs for the box
  user + ownership model.
2026-07-13 10:36:44 -04:00

8.6 KiB

Interactive terminal

A real shell inside the user's persistent box (spoon-box-{username}). There are two entry points, both bridging to the same box:

  • The workspace Terminal tab — a shell opened at the thread's checkout (~/Code/{spoon}/{branch}).
  • The My Machine page (/machine) — a ~-rooted shell into the same box.

Both are an xterm.js front end bridged to a bash/tmux PTY via docker exec into the box. There is no per-job spoon-agent-term-* container anymore — the box is long-running and shared by the agent, the editor, and both terminals.

Architecture

browser (xterm.js)
   │  1a. GET /api/agent-jobs/:id/terminal-token   (Convex-auth'd, owner only)
   │      → { url: "wss://worker…/jobs/:id/terminal?token=…", expiresAt }
   │  1b. GET /api/box/terminal-token              (Convex-auth'd; username
   │      → { url: "wss://worker…/box/terminal?token=…", expiresAt }   derived
   │                                                             server-side)
   │  2. WebSocket  wss://worker.<domain>/{jobs/:id|box}/terminal?token=…
   ▼
nginx ── upgrade ──► spoon-agent-worker :3921
                        │  verify HMAC token (job-scoped or username-scoped)
                        │  docker exec -it  →  bash/tmux PTY
                        ▼
                     spoon-box-{username}  (Fedora box; persistent home mounted)
  • The browser never holds the worker secret. The Next app mints a short-lived HMAC token; the worker verifies it. The job terminal token is minted only after a Convex ownership check; the box terminal token carries the username derived server-side from the authed user (never from the request).
  • 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 prefers tmux new-session -A -s spoon (falls back to bash -l), so reconnecting reattaches the same session. The box itself is idle-reaped after SPOON_AGENT_BOX_IDLE_MS (default 30m) once no job or terminal holds it.

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_BOX_IDLE_MS No Idle-box reap delay, shared with agent jobs (default 1800000). The terminal holds the box open while connected.

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, /box/*, etc.) require the internal bearer token, so exposing the worker host only usefully exposes the token-gated /jobs/:id/terminal and /box/terminal upgrades. 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 job token route uses Convex auth + ownership assertion; the box token derives the username server-side from the authed user.
  • Tokens are short-lived (2m connect window), scoped (job-scoped or username-scoped), and HMAC-signed.
  • A shell in the box 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.

The box user

Terminals and agent turns run as a named non-root user (uid 1000), not root — Claude Code refuses some operations as root, and a real machine has a named account. The Linux username mirrors your Spoon username (lowercased/sanitized; the passwd entry may differ from the home folder name), the hostname is <user>-box, and the account is in wheel.

sudo & password: with no password set, sudo works without prompting (a NOPASSWD drop-in at /etc/sudoers.d/spoon-box). Set a password from the /machine → Box user card: it is stored encrypted in Convex, applied live to a running box (chpasswd over stdin via POST /box/set-password), and re-applied at every box creation — at which point the NOPASSWD drop-in is removed and wheel membership makes sudo prompt. Clearing the password reverts to passwordless sudo. Passwords are 8-128 chars and write-only (the UI only ever learns whether one is set).

File ownership: the worker writes into homes from the host (clones, dotfiles, the file editor). Under dev rootless podman the box runs with --userns=keep-id:uid=1000,gid=1000 so the host user IS the box user and ownership just works (SPOON_AGENT_BOX_USERNS overrides; empty disables). Under prod rootful docker the worker repairs ownership with targeted chown -R 1000:1000 execs after each host-side write, plus a one-time marker-guarded (~/.spoon/chown-v1) home chown at first box creation.

Tools in the shell

The box image ships bash, tmux, neovim, git, ripgrep, jq, python3, node, bun, pnpm, yarn, curl/wget, unzip, plus QoL tooling (fzf, fd, bat, eza, zoxide, gh, gum, oh-my-posh) and the agent CLIs (codex, opencode, claude). Personalize the shell in Settings → Dotfiles (overlay files + an optional dotfiles repo), applied to the persistent home; see dotfiles.md.