Five dependencies used "latest", so every upstream release changed the resolution and CI's frozen install refused the lockfile. They now use ranges around the versions already locked and deployed, so nothing changes at runtime.
- Box init + chownInBox exec explicitly as root: under podman keep-id the
container's default user is the mapped uid, which cannot useradd/chown.
- Init renames an existing uid-1000 passwd entry (podman keep-id injects one
named after the HOST user) instead of adding a duplicate that would win the
getpwuid lookup and make whoami report the wrong name.
- Terminal connections tag their shell with a per-connection marker and reap
the process group on WebSocket close: closing the attach socket never
killed the exec'd shell, so every disconnect leaked an attached tmux client
(session stuck at the smallest zombie's size; enough dead clients starve
tmux rendering for live ones).
- Job image: nvm 0.39.5 at /usr/local/nvm (user-writable) with
/etc/profile.d/nvm.sh, matching what user dotfiles expect; docs for the box
user + ownership model.
The workspace terminal died everywhere: bun's node:http client never
emits the client 'upgrade' event (it surfaces the 101 as a plain
response), so docker-modem's exec.start({hijack:true}) waited forever —
a connected WebSocket with no bytes flowing (prod), or an instant close
when the socket was unreachable (dev). The pre-Phase-1 bridge avoided
dockerode attach for exactly this reason; the real-TTY-resize rewrite
reintroduced it.
- runtime/exec-stream.ts: speak the exec-start HTTP upgrade over a raw
net socket (docker and podman both answer 101 UPGRADED). Works under
bun and node; honors the modem's socketPath/host:port. The stream is
returned paused so the first screen paint can't race the caller's
data handler. Exec create/resize stay on dockerode (plain POSTs).
- user-container.ts: acquireUserBox re-ensures the container on every
acquire instead of trusting a cached "initialized" flag, so a box
removed behind the worker's back (manual rm, another instance's
reaper) is recreated instead of bricking terminals and agent turns
until restart.
- terminal.ts: bridge failures are logged and close the WebSocket with
a clamped human-readable reason instead of a silent catch;
xterm-session.tsx displays that reason instead of a mute
"Session ended". Also roomier terminal line spacing (1.2 -> 1.35).
Add a USER-scoped `/box/terminal` WebSocket that acquires the user's box
and opens a login shell rooted at `~`. Extract the shared real-TTY PTY
bridging (buffered input, dockerode exec/start, resize, forward, cleanup)
out of the job `bridge()` into `runShellBridge`, parameterized by how the
box is acquired, the working directory, and the env; `bridge()` and the
new `bridgeBox()` both call it so the job path is unchanged.
The `/box/terminal` upgrade reads the username FROM the token
(`parseBoxTokenUsername`, part[2] of the 4-part token), then verifies the
whole token with `verifyBoxTerminalToken`; on success `bridgeBox` derives
`boxHomePaths(username)`, seeds a minimal `.bash_profile` via a shared
`ensureBashProfile` (extracted from `materializeUserHome`), acquires the
box, and opens the shell at `containerHome` with `TERM`+`HOME` only (no
per-job secrets). Cleanup releases the box handle.
Manual verification (dockerode PTY I/O is daemon-dependent):
- `bun run test:unit` 105 passed (incl. new `parseBoxTokenUsername` tests),
`bun run typecheck` clean.
- Raw upgrade against a live server: valid box token -> `101 Switching
Protocols`; bad token -> refused (no upgrade). Job `/jobs/:id/terminal`
branch unchanged.
- `bridgeBox` path exercised: `acquireUserBox` created `spoon-box-<user>`
and `ensureBashProfile` seeded `~/.bash_profile`. Interactive typing/
resize could not be observed on this host because dockerode cannot reach
the podman API socket (same mechanism the job terminal uses, so no
regression) — full browser flow is covered by later tasks.
Claude-Session: https://claude.ai/code/session_01ScyZ1NhfN84LAnHpwhfEQk
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/[email protected] 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 [email protected].0 + [email protected].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