# 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./{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: ```nginx 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 `-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](dotfiles.md).