From 24a516c2b5896ab2c720249973ab4e54c32c907a Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Wed, 24 Jun 2026 08:27:10 -0400 Subject: [PATCH] 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 --- docker/Dockerfile | 2 + docker/agent-job.Dockerfile | 6 +++ docker/compose.yml | 1 + docs/agent-terminal.md | 104 ++++++++++++++++++++++++++++++++++++ docs/compose.prod.yml | 5 ++ 5 files changed, 118 insertions(+) create mode 100644 docs/agent-terminal.md diff --git a/docker/Dockerfile b/docker/Dockerfile index cf75d44..daee78f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -15,6 +15,7 @@ ARG NEXT_PUBLIC_SENTRY_DSN ARG NEXT_PUBLIC_SENTRY_URL ARG NEXT_PUBLIC_SENTRY_ORG ARG NEXT_PUBLIC_SENTRY_PROJECT_NAME +ARG NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN ENV SENTRY_DISABLE_AUTO_UPLOAD=$SENTRY_DISABLE_AUTO_UPLOAD @@ -25,6 +26,7 @@ ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN ENV NEXT_PUBLIC_SENTRY_URL=$NEXT_PUBLIC_SENTRY_URL ENV NEXT_PUBLIC_SENTRY_ORG=$NEXT_PUBLIC_SENTRY_ORG ENV NEXT_PUBLIC_SENTRY_PROJECT_NAME=$NEXT_PUBLIC_SENTRY_PROJECT_NAME +ENV NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL=$NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL # Copy source code (node_modules excluded via .dockerignore) COPY . . diff --git a/docker/agent-job.Dockerfile b/docker/agent-job.Dockerfile index a5f2deb..975a5d0 100644 --- a/docker/agent-job.Dockerfile +++ b/docker/agent-job.Dockerfile @@ -11,9 +11,15 @@ RUN apt-get update \ curl \ git \ jq \ + less \ + locales \ + neovim \ openssh-client \ python3 \ ripgrep \ + tmux \ + unzip \ + wget \ && corepack enable \ && corepack prepare pnpm@latest --activate \ && corepack prepare yarn@stable --activate \ diff --git a/docker/compose.yml b/docker/compose.yml index b781c18..d1e4157 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -17,6 +17,7 @@ services: NEXT_PUBLIC_SENTRY_URL: ${NEXT_PUBLIC_SENTRY_URL} NEXT_PUBLIC_SENTRY_ORG: ${NEXT_PUBLIC_SENTRY_ORG} NEXT_PUBLIC_SENTRY_PROJECT_NAME: ${NEXT_PUBLIC_SENTRY_PROJECT_NAME} + NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL: ${NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL:-} image: spoon-next:latest #image: git.gbrown.org/gib/spoon-next:latest container_name: ${NEXT_CONTAINER_NAME} diff --git a/docs/agent-terminal.md b/docs/agent-terminal.md new file mode 100644 index 0000000..e474969 --- /dev/null +++ b/docs/agent-terminal.md @@ -0,0 +1,104 @@ +# 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./jobs/:id/terminal?token=… + ▼ +nginx ── upgrade ──► spoon-agent-worker :3921 + │ verifyTerminalToken(token, jobId, secret) + │ dockerode exec -t → bash/tmux PTY + ▼ + spoon-agent-term- (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: + +```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` 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 ~/.config/...`); persistent +auto-cloning of a dotfiles repo is a planned follow-up. diff --git a/docs/compose.prod.yml b/docs/compose.prod.yml index 5c75e47..26b0072 100644 --- a/docs/compose.prod.yml +++ b/docs/compose.prod.yml @@ -46,6 +46,11 @@ services: - SPOON_AGENT_WORKER_URL=${SPOON_AGENT_WORKER_URL:-http://spoon-agent-worker:3921} - SPOON_AGENT_WORKER_INTERNAL_TOKEN=${SPOON_AGENT_WORKER_INTERNAL_TOKEN} - SPOON_WORKER_TOKEN=${SPOON_WORKER_TOKEN} + # NOTE: the Terminal tab needs NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL, which is + # a NEXT_PUBLIC (build-time) var — it must be baked into the spoon-next image + # at build (via the build env file / CI), NOT set as a runtime env here. Also + # requires nginx to proxy worker. → spoon-agent-worker:3921 with WS + # upgrade. See docs/agent-terminal.md. depends_on: ['spoon-backend', 'spoon-postgres'] labels: ['com.centurylinklabs.watchtower.enable=true'] tty: true