7.2 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 tobash -l), so reconnecting reattaches the same session. The box itself is idle-reaped afterSPOON_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/terminaland/box/terminalupgrades. 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.
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.