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
This commit is contained in:
@@ -15,6 +15,7 @@ ARG NEXT_PUBLIC_SENTRY_DSN
|
|||||||
ARG NEXT_PUBLIC_SENTRY_URL
|
ARG NEXT_PUBLIC_SENTRY_URL
|
||||||
ARG NEXT_PUBLIC_SENTRY_ORG
|
ARG NEXT_PUBLIC_SENTRY_ORG
|
||||||
ARG NEXT_PUBLIC_SENTRY_PROJECT_NAME
|
ARG NEXT_PUBLIC_SENTRY_PROJECT_NAME
|
||||||
|
ARG NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL
|
||||||
|
|
||||||
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
|
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
|
||||||
ENV SENTRY_DISABLE_AUTO_UPLOAD=$SENTRY_DISABLE_AUTO_UPLOAD
|
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_URL=$NEXT_PUBLIC_SENTRY_URL
|
||||||
ENV NEXT_PUBLIC_SENTRY_ORG=$NEXT_PUBLIC_SENTRY_ORG
|
ENV NEXT_PUBLIC_SENTRY_ORG=$NEXT_PUBLIC_SENTRY_ORG
|
||||||
ENV NEXT_PUBLIC_SENTRY_PROJECT_NAME=$NEXT_PUBLIC_SENTRY_PROJECT_NAME
|
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 source code (node_modules excluded via .dockerignore)
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -11,9 +11,15 @@ RUN apt-get update \
|
|||||||
curl \
|
curl \
|
||||||
git \
|
git \
|
||||||
jq \
|
jq \
|
||||||
|
less \
|
||||||
|
locales \
|
||||||
|
neovim \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
python3 \
|
python3 \
|
||||||
ripgrep \
|
ripgrep \
|
||||||
|
tmux \
|
||||||
|
unzip \
|
||||||
|
wget \
|
||||||
&& corepack enable \
|
&& corepack enable \
|
||||||
&& corepack prepare pnpm@latest --activate \
|
&& corepack prepare pnpm@latest --activate \
|
||||||
&& corepack prepare yarn@stable --activate \
|
&& corepack prepare yarn@stable --activate \
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ services:
|
|||||||
NEXT_PUBLIC_SENTRY_URL: ${NEXT_PUBLIC_SENTRY_URL}
|
NEXT_PUBLIC_SENTRY_URL: ${NEXT_PUBLIC_SENTRY_URL}
|
||||||
NEXT_PUBLIC_SENTRY_ORG: ${NEXT_PUBLIC_SENTRY_ORG}
|
NEXT_PUBLIC_SENTRY_ORG: ${NEXT_PUBLIC_SENTRY_ORG}
|
||||||
NEXT_PUBLIC_SENTRY_PROJECT_NAME: ${NEXT_PUBLIC_SENTRY_PROJECT_NAME}
|
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: spoon-next:latest
|
||||||
#image: git.gbrown.org/gib/spoon-next:latest
|
#image: git.gbrown.org/gib/spoon-next:latest
|
||||||
container_name: ${NEXT_CONTAINER_NAME}
|
container_name: ${NEXT_CONTAINER_NAME}
|
||||||
|
|||||||
@@ -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.<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:
|
||||||
|
|
||||||
|
```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 <dotfiles> ~/.config/...`); persistent
|
||||||
|
auto-cloning of a dotfiles repo is a planned follow-up.
|
||||||
@@ -46,6 +46,11 @@ services:
|
|||||||
- SPOON_AGENT_WORKER_URL=${SPOON_AGENT_WORKER_URL:-http://spoon-agent-worker:3921}
|
- SPOON_AGENT_WORKER_URL=${SPOON_AGENT_WORKER_URL:-http://spoon-agent-worker:3921}
|
||||||
- SPOON_AGENT_WORKER_INTERNAL_TOKEN=${SPOON_AGENT_WORKER_INTERNAL_TOKEN}
|
- SPOON_AGENT_WORKER_INTERNAL_TOKEN=${SPOON_AGENT_WORKER_INTERNAL_TOKEN}
|
||||||
- SPOON_WORKER_TOKEN=${SPOON_WORKER_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.<domain> → spoon-agent-worker:3921 with WS
|
||||||
|
# upgrade. See docs/agent-terminal.md.
|
||||||
depends_on: ['spoon-backend', 'spoon-postgres']
|
depends_on: ['spoon-backend', 'spoon-postgres']
|
||||||
labels: ['com.centurylinklabs.watchtower.enable=true']
|
labels: ['com.centurylinklabs.watchtower.enable=true']
|
||||||
tty: true
|
tty: true
|
||||||
|
|||||||
Reference in New Issue
Block a user