Wire the box helpers into the worker HTTP server as /box/* routes,
authed by the internal bearer token like every other worker route.
Adds a boxRoute matcher (/^\/box\/(status|lifecycle|tree|file)$/) and
dispatches before jobRoute; missing user -> 400, unknown lifecycle
action -> 400, containment throws -> 400 (server keeps serving).
Manual verification (harness on :3922, internal token, user gabriel):
GET /box/status?user=gabriel -> 200 {"status":{...running:false...}}
GET /box/status (no user) -> 400 {"error":"Missing user"}
(no Authorization header) -> 401
POST /box/lifecycle {"action":"bogus"} -> 400 {"error":"Unknown action"}
GET /box/tree?user=gabriel -> 200 {"tree":{"name":"~",...}}
PUT /box/file {"path":"spoon-test.txt","content":"hi from task4"} -> 200 {"success":true}
GET /box/file?path=spoon-test.txt -> 200 {"path":...,"content":"hi from task4"}
GET /box/file?path=../../../../../etc/passwd -> 400 {"error":"Refusing to access path outside home: ..."}
GET /box/status after escape -> 200 (no crash)
GET /box/other?user=gabriel -> 404 {"error":"Not found"}
Centralize per-user box home-path derivation, status inspection, and
lifecycle (start/stop/restart) in a new box.ts so the HTTP routes and the
terminal WS bridge share one source of truth. Add inspectUserBoxStatus to
docker.ts (single `inspect --format` call, all-null/false on non-zero exit)
and resetBox to user-container.ts (clears the idle timer and drops the
registry entry so a stopped box isn't treated as held).
Manual verification (docker required, after Task 4 wires the routes): with
the worker running and a box up,
`curl -H "Authorization: Bearer $TOKEN" localhost:3921/box/status?user=<you>`
shows running:true + image + startedAt; `POST /box/lifecycle {"action":"stop"}`
flips status to running:false; `start` brings it back.
Add @anthropic-ai/claude-code@2.1.207 to the job image global npm install,
extend the smoke script with claude --version plus a documented per-runtime
turn block, and reconcile OpenCode/Claude adapter flags against the real CLIs
(all assumed flags confirmed). Document the anthropic_oauth_json credential
snapshot pattern in the README.
Every thread (agent turns + terminal + project commands) now execs into one
persistent per-user container (spoon-box-{username}) instead of ephemeral
docker run --rm — so the agent and terminal share the exact same running
environment, filesystem, and in-session installs.
- docker.ts: ensureUserContainer (persistent box) + streamExecInContainer/
runExecInContainer (docker exec, streaming) sharing a factored streamSubprocess
- user-container.ts: reference-counted box lifecycle (held while any thread
workspace is active or a terminal is connected; idle-reaped after
SPOON_AGENT_BOX_IDLE_MS, default 30m)
- worker.ts: runClaim acquires the box; codex turn + runProjectCommand exec into
it; release on stop/PR/failure
- terminal.ts: execs into the shared box (dockerode TTY) instead of a per-job
container; materializeUserHome runs the dotfiles setup in the box
- Verified: agent + terminal run in the same box, share fs, dotfiles + tmux load
- Each job/terminal now mounts a persistent per-user home (${workdir}/homes/
{username}) at /home/{username}; the thread checkout lives at
~/Code/{spoon}/{branch} so every thread shows up as a folder in one home and
dotfiles/tools/nvim plugins persist across sessions
- docker.ts helpers + git.ts cloneRepository take container home/cwd + dir name
(backward-compatible defaults); codex/opencode/terminal use the per-user paths
- new user-environment.ts: fetchUserEnvironment (worker-token Convex action) +
materializeUserHome — ensures ~/.bash_profile, applies the editable overlay
files, and (hashed/idempotent) clones the public dotfiles repo + runs the
setup command inside the job image
- stopWorkspace no longer deletes the home; only the container stops
- Verified: codex runs a real turn under the new /home/{user} + ~/Code layout;
overlay .bashrc loads in the interactive shell
- attachTerminalServer() upgrades /jobs/:id/terminal WS connections, verifying a
short-lived job-scoped HMAC token (verifyTerminalToken) so the browser never
holds the worker secret
- Bridges the socket to a bash PTY via dockerode exec (Tty) in a persistent
per-job shell container (spoon-agent-term-<id>) mounting the workspace; binary
frames = stdin, JSON text frames = resize; idle containers reaped after 30m
- New env: SPOON_AGENT_TERMINAL_IMAGE/SECRET/IDLE_MS (secret falls back to the
shared worker internal token)
Root cause of the prod empty-response: the spoon-agent-worker image shipped
without a docker CLI binary, so it could never launch the codex job container.
On Debian trixie (the bun base) 'docker.io' + --no-install-recommends installs
the daemon package but omits the client (split into 'docker-cli'), leaving no
'docker' on PATH. execa('docker', ...) hit ENOENT, and with reject:false that
resolves with exitCode undefined -> coerced to 0 -> looked like a successful
empty run -> 'Codex completed without producing an assistant response'.
- agent-worker.Dockerfile: drop docker.io, install the official static docker
CLI client pinned to 29.5.3 (matches the host daemon) to /usr/local/bin/docker
- runtime/docker.ts: normalizeRunResult() so a spawn failure (exitCode null) is
always a non-zero exit carrying the real reason, never a silent empty success
- tests: cover the spawn-failure and normal-result paths
- Pin codex@0.142.0 + opencode-ai@1.17.9 in the job image (was @latest,
causing dev/prod drift)
- Worker now s the job image once per process so prod stops
running a stale Codex
- Surface Codex error/turn.failed events instead of swallowing them, so the
real failure reason is reported rather than 'no assistant response'
- Harden the Codex JSON parser to also handle the legacy msg-wrapped shape
- Fix the docker-in-docker workdir: bind-mount identical host:container path
and set SPOON_AGENT_HOST_WORKDIR (named volume can't be mounted by sibling
job containers)
- Add docs/compose.prod.yml as a documented reference deployment