Compare commits

...
105 Commits
Author SHA1 Message Date
Gabriel Brown 919d185a85 Merge feat/box-user: non-root box user + settable password
Build and Push Spoon Images / quality (push) Successful in 1m57s
Build and Push Spoon Images / build-images (push) Successful in 9m52s
2026-07-13 11:38:29 -04:00
Gabriel Brown 4505471149 fix(worker): keep-id compat, zombie tmux clients, nvm in box image
- 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.
2026-07-13 10:36:44 -04:00
Gabriel Brown 028fbebcd6 feat(next): box user card on /machine 2026-07-13 10:11:11 -04:00
Gabriel Brown 134bab93a5 feat(next): box password API 2026-07-13 10:08:48 -04:00
Gabriel Brown 78aae034b5 feat(worker): live box password apply route 2026-07-13 10:07:06 -04:00
Gabriel Brown 58bd101793 feat(worker): run terminals and agent turns as the box user 2026-07-13 10:04:14 -04:00
Gabriel Brown a956d63e16 feat(backend): boxSettings table + box password actions 2026-07-13 09:56:08 -04:00
Gabriel Brown 37a82098c2 feat(worker): exec-as-user, stdin input, chown + userns plumbing 2026-07-13 09:52:45 -04:00
Gabriel Brown 78cb3d90c2 feat(worker): box-user script builders + username sanitizer 2026-07-13 09:49:59 -04:00
Gabriel Brown a91c8551ac docs: box user implementation plan 2026-07-13 09:48:25 -04:00
Gabriel Brown aea2aa2c5c docs: box user design spec (non-root box + user-set password) 2026-07-12 22:42:32 -04:00
Gabriel Brown b038a31520 fix(box): bump job image base to Fedora 44 for Neovim 0.12
fedora:41 ships Neovim 0.10.4, below LazyVim's >= 0.11 requirement, so
dotfiles-managed editors failed inside the box. F44 ships 0.12.4.
2026-07-12 22:26:45 -04:00
Gabriel Brown a640b5cb94 fix(terminal): bun-safe exec attach, box self-heal, surfaced errors
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).
2026-07-12 22:14:37 -04:00
Gabriel Brown c0dd8759af fix(expo): support anthropic_oauth_json auth type in provider form
Build and Push Spoon Images / quality (push) Successful in 2m29s
Build and Push Spoon Images / build-images (push) Successful in 8m44s
The backend aiProviderProfiles authType union added 'anthropic_oauth_json'
but the Expo provider form's local AuthType type was stale, breaking
typecheck. Add the literal, a picker option, help text, and multiline
handling to match the Next app.

Claude-Session: https://claude.ai/code/session_01ScyZ1NhfN84LAnHpwhfEQk
2026-07-12 12:09:33 -04:00
Gabriel Brown ed88478ce6 Merge codex/spoon-stabilize-unify-build: 5-phase stabilize/unify/build (65 tasks)
Phases 1-5 complete: stabilization criticals, runtime unification (codex/opencode/claude
in per-user box), dev-box /machine surface, GitHub sync correctness + webhooks,
notifications + polish. Final whole-branch review passed; all packages green.
2026-07-12 02:53:40 -04:00
Gabriel Brown a255a06a35 docs: rewrite for box-first model + webhooks + notifications 2026-07-11 18:23:49 -04:00
Gabriel Brown 1fbb2dd0a1 chore: remove dead routes/components + fix redact/env cleanup 2026-07-11 18:11:22 -04:00
Gabriel Brown f89dfce3b8 fix(workspace): scroll/diff-race/recovery/editor/mobile polish 2026-07-11 17:59:25 -04:00
Gabriel Brown b00141130c fix(a11y): remove nested-interactive role='link' on spoon/thread lists 2026-07-11 17:42:33 -04:00
Gabriel Brown cc241c8bbf fix(ui): profile page directive + avatar dropdown links/fallbacks 2026-07-11 17:32:49 -04:00
Gabriel Brown d873c8eaf7 fix(auth): retain input on failure + copy fixes 2026-07-11 17:23:25 -04:00
Gabriel Brown a3ec04ad99 fix(ui): re-sync settings/thread forms on loaded record 2026-07-11 17:14:06 -04:00
Gabriel Brown bca86196fb fix(ui): error-handle fire-and-forget mutations in settings panels 2026-07-11 16:59:16 -04:00
Gabriel Brown 8c579d060a fix(ui): loading skeletons for dashboard/spoons/threads 2026-07-11 16:50:24 -04:00
Gabriel Brown 8d7486bc8c feat(notifications): settings notification preferences 2026-07-11 16:41:35 -04:00
Gabriel Brown c241941e41 feat(notifications): header notification bell 2026-07-11 16:25:08 -04:00
Gabriel Brown 5776841e2f feat(notifications): inbox query + mark-read mutations 2026-07-11 16:13:42 -04:00
Gabriel Brown 3766a29871 feat(notifications): emit at maintenance/turn/sync/reauth events 2026-07-11 16:08:22 -04:00
Gabriel Brown 4eb0c963ff feat(notifications): add emit helper + UseSend email dispatch 2026-07-11 15:52:26 -04:00
Gabriel Brown 05eabcb0fa feat(notifications): add notifications + notificationPreferences tables 2026-07-11 15:42:14 -04:00
Gabriel Brown 0347d58f71 chore: fix eslint errors introduced in phase 4 backend 2026-07-11 15:37:51 -04:00
Gabriel Brown 75d7592cf3 feat(settings): surface GitHub connection needs_reauth/revoked 2026-07-11 14:49:28 -04:00
Gabriel Brown bce6769277 fix(convex): order spoonCommits by committedAt via index so bounding keeps newest 2026-07-11 14:41:31 -04:00
Gabriel Brown 9067d16734 perf(convex): bound unbounded owner-wide collects on hot paths 2026-07-11 14:41:31 -04:00
Gabriel Brown e4a7f96633 fix(sync): don't misclassify plain 403 auth errors as rate limited 2026-07-11 14:28:02 -04:00
Gabriel Brown 4769a76274 feat(sync): add Octokit retry/throttle and rate-limited status 2026-07-11 14:28:02 -04:00
Gabriel Brown f1dbf8cd50 feat(webhooks): add signature-verified GitHub webhook route 2026-07-11 14:18:55 -04:00
Gabriel Brown 68dc7bb4ee feat(spoons): find owned spoons by pushed repo (indexed) 2026-07-11 14:12:30 -04:00
Gabriel Brown f7c3aa6482 feat(github): index connections by installation + status mutation 2026-07-11 14:07:57 -04:00
Gabriel Brown 18cb424b99 feat(webhooks): add GitHub webhook signature verifier 2026-07-11 14:04:32 -04:00
Gabriel Brown 675d6b2316 fix(sync): use stable dedup key and skip threads when head unresolved 2026-07-11 14:00:16 -04:00
Gabriel Brown 0e894f8e67 refactor(threads): dedup maintenance threads by stable key via index 2026-07-11 14:00:16 -04:00
Gabriel Brown c3f73ac656 feat(sync): add deterministic maintenance-thread dedup key 2026-07-11 13:48:49 -04:00
Gabriel Brown 4684617fed fix(sync): paginate compare and resolve real head SHA 2026-07-11 13:44:10 -04:00
Gabriel Brown c8200a6747 feat(sync): add resolveBranchHead to fetch branch tip SHA 2026-07-11 13:39:36 -04:00
Gabriel Brown 696f03e867 fix(web): /machine terminal visible prop, stop/restart confirmation, accurate waiting label 2026-07-11 13:36:54 -04:00
Gabriel Brown a5bc0434f3 chore: fix eslint errors introduced across phases 1-3 2026-07-11 13:27:23 -04:00
Gabriel Brown 12b1c98cfc feat(web): terminal copy-on-select toggle and web-links polish
Wire copy-on-select in XtermSession (shared by the workspace terminal
and the /machine terminal): a header checkbox toggles the behavior and
persists to localStorage under spoon.terminal.copyOnSelect. On mount the
stored value takes precedence over the copyOnSelect prop default. When
enabled, onSelectionChange copies the current selection via
navigator.clipboard.writeText — guarded (empty selections skipped;
clipboard-unavailable/denied errors swallowed). The handler reads the
toggle through a ref so live flips apply without reconnecting.

WebLinksAddon remains loaded so printed URLs are clickable.

Manual verification: in /machine and a workspace terminal, enabling
copy-on-select and selecting text places it on the clipboard; a printed
URL is clickable and opens in a new tab.
2026-07-11 13:17:46 -04:00
Gabriel Brown 148e060a9b feat(web): persistent workspace terminal across tab switches 2026-07-11 13:05:49 -04:00
Gabriel Brown 33e7732566 feat(web): /machine dev-box page with status, terminal, and home file browser 2026-07-11 12:57:13 -04:00
Gabriel Brown ea56b29453 refactor(web): extract reusable XtermSession from WorkspaceTerminal 2026-07-11 12:43:08 -04:00
Gabriel Brown 27019dc1d3 feat(web): /api/box status, lifecycle, tree, file, terminal-token routes 2026-07-11 12:36:56 -04:00
Gabriel Brown 11e93ade5f feat(web): box terminal token mint + ownership proxy helpers 2026-07-11 12:32:10 -04:00
Gabriel Brown 2c6a605646 feat(worker): user-scoped /box/terminal websocket bridge
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
2026-07-11 12:25:32 -04:00
Gabriel Brown 192f547be5 feat(worker): /box status, lifecycle, tree, and file HTTP routes
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"}
2026-07-11 12:13:50 -04:00
Gabriel Brown bf0276b1f3 feat(worker): home-scoped box file tree/read/write with realpath containment 2026-07-11 12:05:04 -04:00
Gabriel Brown 6439e5200b feat(worker): per-user box status and lifecycle helpers
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.
2026-07-11 11:57:57 -04:00
Gabriel Brown d4303e652e feat(worker): user-scoped box terminal token verification 2026-07-11 11:52:26 -04:00
Gabriel Brown 432e3b501e fix(worker): don't double-append streamed answer when completed event carries content 2026-07-11 11:47:11 -04:00
Gabriel Brown 2311679802 fix(worker): chmod materialized env file to 0600 even when it pre-exists 2026-07-11 11:36:03 -04:00
Gabriel Brown 58c815a7a7 fix(worker): materialize env files 0600, re-materialize per run, delete on stop 2026-07-11 11:36:03 -04:00
Gabriel Brown 424307c5af build(agent-job): pin Claude Code CLI; smoke one turn per runtime
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.
2026-07-11 11:25:44 -04:00
Gabriel Brown 702e53a4ba feat(web): per-thread/per-spoon runtime picker and Anthropic OAuth provider profile 2026-07-11 11:20:33 -04:00
Gabriel Brown 6ec2e51312 feat(backend): migration to retire openai_direct runtime 2026-07-11 11:05:12 -04:00
Gabriel Brown 0716a224ef fix(worker): codex prepareAuth skips auth.json for api_key profiles 2026-07-11 11:00:08 -04:00
Gabriel Brown c0f107c4cf feat(backend): queue-time runtime/profile validation; job.runtime authoritative in worker 2026-07-11 10:58:05 -04:00
Gabriel Brown 70396feccc feat(backend): add claude runtime, anthropic OAuth auth kind, and profile→runtime derivation 2026-07-11 10:42:48 -04:00
Gabriel Brown 76886621b5 feat(worker): ClaudeCodeAdapter runs claude -p stream-json in the per-user box 2026-07-11 10:35:56 -04:00
Gabriel Brown c9ee2e6cde refactor(worker): delete per-job OpenCode container path, host_port split, and dead docker helpers
Claude-Session: https://claude.ai/code/session_01ScyZ1NhfN84LAnHpwhfEQk
2026-07-11 10:26:58 -04:00
Gabriel Brown ff0425cf4b feat(worker): OpenCodeAdapter runs opencode in the per-user box (no side container) 2026-07-11 10:07:58 -04:00
Gabriel Brown 949a036d13 fix(worker): surface nonzero-exit agent turns as failed even when text streamed 2026-07-11 10:07:14 -04:00
Gabriel Brown e5bba720b4 refactor(worker): move Codex turn logic into CodexAdapter behind AgentRuntime 2026-07-11 02:53:26 -04:00
Gabriel Brown c1e816741d fix(worker): marked-command survives tail-exec; kill script excludes itself 2026-07-10 18:53:00 -04:00
Gabriel Brown bab87cc2d0 feat(worker): in-box process-group marker + kill helpers for agent turns 2026-07-10 18:53:00 -04:00
Gabriel Brown 613a6c8a2d fix(agent-jobs): narrow recovery-cron guard so abandoned stops still recover 2026-07-10 18:34:15 -04:00
Gabriel Brown 38431155b2 refactor(worker): extract AgentRuntime interface, shared provider helpers, and adapter registry 2026-07-10 18:28:41 -04:00
Gabriel Brown 697fb7286d fix(agent-jobs): recovery cron skips already-terminal jobs 2026-07-10 18:27:53 -04:00
Gabriel Brown 60dd5d3836 fix(agent-jobs): stop heartbeat from resurrecting terminated jobs 2026-07-10 18:13:28 -04:00
Gabriel Brown ac68484059 fix(worker): make cancellation teardown failure-safe 2026-07-10 18:04:40 -04:00
Gabriel Brown 470af043fa fix(agent-jobs): add heartbeat cancel and stale recovery 2026-07-10 18:03:45 -04:00
Gabriel Brown cbbb168e7c docs: add implementation handoff for Claude 2026-07-10 17:54:06 -04:00
Gabriel Brown 8af33fc9e0 fix(worker): always release stopped workspaces 2026-07-10 17:35:33 -04:00
Gabriel Brown 00f96a126e fix(agent-jobs): close maintenance jobs after decision 2026-07-10 17:35:33 -04:00
Gabriel Brown 1e1435e656 fix(agent-jobs): recover failed claim decryption 2026-07-10 17:26:17 -04:00
Gabriel Brown cf45404b7d fix(agent-jobs): validate claims before claiming 2026-07-10 17:26:17 -04:00
Gabriel Brown 926008fd3a fix(workspace): skip queries for missing jobs 2026-07-10 17:24:32 -04:00
Gabriel Brown 2beeeb1732 fix(app): handle nullable detail consumers 2026-07-10 17:24:32 -04:00
Gabriel Brown ed21126eea fix(app): add friendly not-found handling 2026-07-10 17:24:32 -04:00
Gabriel Brown c4d0f5219a fix(terminal): use real TTY exec and resize 2026-07-10 17:21:52 -04:00
Gabriel Brown 906f906ad3 fix(backend): use generated import extension 2026-07-10 17:15:34 -04:00
Gabriel Brown bdb2964e34 fix(sync): honor per-spoon auto-sync settings 2026-07-10 17:15:20 -04:00
Gabriel Brown 57c175aecd fix(worker): contain workspace paths against symlinks 2026-07-10 17:11:00 -04:00
Gabriel Brown 2bc090c378 chore: remove terminal task scratch report 2026-07-10 17:06:44 -04:00
Gabriel Brown d4a3856083 fix(workspace): key shell by job 2026-07-10 17:06:33 -04:00
Gabriel Brown 020ffbfb23 fix(worker-auth): unify token checks and bind job environment 2026-07-10 17:06:33 -04:00
Gabriel Brown feb3723753 fix(terminal): preserve animation-frame first fit 2026-07-10 17:06:33 -04:00
Gabriel Brown 75d06394ce fix(terminal): refit after fonts load 2026-07-10 17:06:33 -04:00
Gabriel Brown 0b5a2c0411 fix(worker): ensure reconciled boxes before acquire 2026-07-10 17:04:41 -04:00
Gabriel Brown 284f7ea52b fix(worker): serialize box failure and idle reaping 2026-07-10 17:00:22 -04:00
Gabriel Brown 490c05329b chore: remove implementation scratch report 2026-07-10 16:56:07 -04:00
Gabriel Brown 0a4461eaba fix(agent-jobs): prevent terminal status resurrection 2026-07-10 16:55:43 -04:00
Gabriel Brown 9e7a8722f3 fix(worker): make user-box lifecycle concurrency-safe 2026-07-10 16:54:55 -04:00
Gabriel Brown daec4d6dfa fix(worker): make workspace cleanup layout-aware 2026-07-10 15:55:11 -04:00
Gabriel Brown 8aa9140191 docs: multi-phase implementation plan (63 tasks across 5 phases)
Phase 1 Stabilize (13), Phase 2 Runtime unification (12),
Phase 3 Dev-box surface (11), Phase 4 Sync correctness (12),
Phase 5 Notifications & polish (15), plus index.
2026-07-10 15:18:51 -04:00
Gabriel Brown 6406e19b15 docs: stabilize/unify/build design spec from full audit 2026-07-10 14:10:58 -04:00
198 changed files with 19846 additions and 2163 deletions
+189 -59
View File
@@ -10,7 +10,8 @@
<p align="center">
Spoon is a self-hostable fork maintenance cockpit built around managed forks,
durable maintenance threads, and OpenCode-powered workspaces.
durable maintenance threads, and a persistent per-user dev box that agents,
terminals, and project commands all run inside.
</p>
<p align="center">
@@ -61,14 +62,38 @@ in the data model, but GitHub is the active automation surface today.
Spoon keeps raw GitHub drift visible while also tracking ignored upstream
changes so irrelevant commits do not keep a fork permanently actionable.
- **OpenCode workspaces**
Agent work happens in an isolated workspace with a file tree, browser editor,
diff viewer, command panel, logs, artifacts, and draft PR actions.
- **One persistent per-user box**
Every user owns a single long-running Fedora container `spoon-box-{username}`
with a persistent home. Every thread's agent turn, terminal session, and
project command `docker exec`s into that same box — there is no per-job
throwaway container.
- **Three agent runtimes**
Codex, OpenCode, and Claude Code all run inside the box behind one adapter
interface, selected per thread and validated at queue time against the AI
provider profile.
- **Workspaces**
Agent work happens in a workspace with a file tree, browser editor, diff
viewer, command panel, logs, artifacts, an interactive terminal, and draft PR
actions.
- **My Machine**
A `/machine` surface to start/stop/restart the box, open a `~`-rooted
terminal, and browse the persistent home.
- **GitHub webhooks**
A signature-verified webhook keeps drift fresh on `push` and flips connection
status on `installation` changes; the hourly cron is a fallback.
- **Notifications**
An in-app bell plus preference-gated transactional email for maintenance
threads, agent turns, needed input, sync failures, and connection re-auth.
- **User-owned providers and secrets**
AI provider profiles, Codex/OpenCode auth, and per-Spoon project secrets are
encrypted. Secrets are redacted from logs and refused from commits when
materialized into env files.
AI provider profiles, Codex/OpenCode/Anthropic auth, and per-Spoon project
secrets are encrypted. Secrets are written `0600`, redacted from logs, and
refused from commits when materialized into env files.
- **Draft PR handoff**
Code changes become branches and draft pull requests. Spoon does not
@@ -138,12 +163,23 @@ custom work exists.
</details>
<details>
<summary><strong>OpenCode workspaces</strong></summary>
<summary><strong>The box and workspaces</strong></summary>
Spoon's optional agent worker is designed to run outside Convex actions. The
worker claims queued jobs, clones the current GitHub fork, creates a branch,
starts an isolated workspace, and exposes workspace operations to the Next app
through server-only API proxies.
worker claims queued jobs, clones the current GitHub fork into the owner's
persistent home, creates a branch, and exposes workspace operations to the Next
app through server-only API proxies.
Everything runs inside **one long-running container per user**,
`spoon-box-{username}`. The box is a Fedora image (`docker/agent-job.Dockerfile`)
started with `sleep infinity` and a `--memory 4g` / `--cpus 2` cap. Each
thread's agent turn, terminal, and project commands `docker exec` into that same
box; the box's `/home/{username}` is a bind-mounted persistent home so dotfiles,
installed tools, shell history, and thread checkouts under `~/Code/{spoon}/{branch}`
survive across sessions. The box is reference-counted by the worker and reaped
after `SPOON_AGENT_BOX_IDLE_MS` idle (default 30m). There is no per-job
`docker run --rm` container and no separate `opencode serve` container — that
path was removed.
Workspace capabilities:
@@ -154,16 +190,18 @@ Workspace capabilities:
- inspect diffs
- send thread messages to the agent
- run configured commands
- use an interactive terminal (xterm.js → box PTY; see
[docs/agent-terminal.md](docs/agent-terminal.md))
- store logs and artifacts
- push a branch
- open a draft PR
The browser never receives worker tokens and never talks directly to the worker
or job container.
or the box.
Worker cleanup is available in `Settings -> Worker`. It can delete old terminal
workspace records and ask the active worker to remove orphaned job containers
and inactive work directories.
Worker cleanup is available in `Settings -> Worker`. It can delete stale
workspace records and ask the active worker to remove orphaned containers and
inactive work directories (persistent per-user homes are preserved).
Local worker development:
@@ -174,20 +212,53 @@ bun dev:next:worker
bun dev:next:worker:staging
```
Local host-run worker commands still load env through Infisical, then
`scripts/dev-agent-worker` selects Podman when available, falls back to Docker,
and publishes the OpenCode server on a localhost port so the host worker can
reach the job container. Override with:
Local host-run worker commands load env through Infisical, then
`scripts/dev-agent-worker` selects Podman when available and falls back to
Docker. Override the container CLI with:
```env
SPOON_AGENT_CONTAINER_RUNTIME=podman
SPOON_AGENT_CONTAINER_ACCESS=host_port
```
</details>
<details>
<summary><strong>Production agent runtime images</strong></summary>
<summary><strong>Agent runtimes</strong></summary>
Three agent CLIs run inside the box behind one `AgentRuntime` adapter interface
(`apps/agent-worker/src/runtime/*-adapter.ts`), selected by `job.runtime`:
| Runtime | CLI | Selected for |
| ---------- | ----------------------------------- | -------------------------------------------------- |
| `codex` | `@openai/codex` | OpenAI providers and Codex ChatGPT-login snapshots |
| `opencode` | `opencode-ai` | OpenAI-compatible API-key providers (default) |
| `claude` | `@anthropic-ai/claude-code@2.1.207` | Anthropic providers |
The runtime is validated at queue time (`runtimeSupport.ts` +
`agentJobs.insertJob`): a job is rejected unless the resolved runtime is one the
AI provider profile supports. `runtimesForProfile` maps profiles to runtimes —
ChatGPT-login snapshots → `codex` only; `openai` API keys → `opencode`/`codex`;
Anthropic API keys → `claude`/`opencode`; Anthropic `anthropic_oauth_json`
credential snapshots → `claude` only; every other OpenAI-compatible API key →
`opencode`.
Auth is materialized into the box before a turn:
- **Codex ChatGPT-login** profiles get the encrypted `auth.json` written to
`CODEX_HOME/.codex/auth.json`.
- **Anthropic API-key** profiles authenticate via `ANTHROPIC_API_KEY` in the
environment (no file needed).
- **Anthropic `anthropic_oauth_json`** credential-snapshot profiles get the
encrypted OAuth blob written to `~/.claude/.credentials.json`.
- **API-key** profiles run through OpenCode.
All materialized auth files are written `0600`. Treat those saved auth files
like a password and only use them on trusted workers.
</details>
<details>
<summary><strong>Production runtime images</strong></summary>
Gitea CI builds and pushes three production images:
@@ -197,31 +268,36 @@ git.gbrown.org/gib/spoon-agent-worker:latest
git.gbrown.org/gib/spoon-agent-job:latest
```
The worker image is the long-running service that polls Convex. The job image is
the isolated workbench that the worker launches for each agent job. For the MVP,
production should use the repo-provided JS/TS workbench image:
The worker image is the long-running service that polls Convex. The
`spoon-agent-job` image is the **box** image — the per-user Fedora dev box the
worker `exec`s into (it is not launched fresh per job). Point the worker at it
with:
```env
SPOON_AGENT_JOB_IMAGE="git.gbrown.org/gib/spoon-agent-job:latest"
```
The job image includes Node 22, Bun, pnpm and yarn through Corepack, npm, git,
ripgrep, Python, build tools, OpenCode, and the Codex CLI. It is not the forked
project's production runtime; it is the agent execution environment.
The box image is Fedora 41 with Node, Bun, pnpm and yarn, npm, git, ripgrep,
Python, build tools, plus interactive tooling (neovim, tmux, fzf, fd, bat, eza,
zoxide, gh, gum, oh-my-posh) and the pinned agent CLIs OpenCode, Codex, and
Claude Code. It is not the forked project's production runtime; it is the agent
execution environment.
Production worker runtime requirements:
- `spoon-agent-worker` must run as a separate service.
- The worker needs `/var/run/docker.sock` mounted so it can launch job
containers.
- Production should keep `SPOON_AGENT_CONTAINER_RUNTIME=docker` and
`SPOON_AGENT_CONTAINER_ACCESS=network`.
- The production Docker host must be logged into `git.gbrown.org` so worker jobs
can pull the private `spoon-agent-job` image.
- The worker needs `/var/run/docker.sock` mounted so it can create and `exec`
into per-user boxes on the host daemon.
- Production should keep `SPOON_AGENT_CONTAINER_RUNTIME=docker`.
- `SPOON_AGENT_HOST_WORKDIR` must equal the absolute host path backing
`SPOON_AGENT_WORKDIR` (identical inside and outside the worker container) so
the host daemon can bind-mount per-user homes under `${WORKDIR}/homes/{username}`.
- The production Docker host must be logged into `git.gbrown.org` so the worker
can pull the private `spoon-agent-job` box image.
- `SPOON_WORKER_TOKEN` must match the value stored in Convex production env.
- `spoon-next` needs `SPOON_AGENT_WORKER_URL=http://spoon-agent-worker:3921` and
`SPOON_AGENT_WORKER_INTERNAL_TOKEN` so Next API routes can proxy workspace
file, diff, message, command, and draft PR actions.
`SPOON_AGENT_WORKER_INTERNAL_TOKEN` so Next API routes can proxy workspace and
box file, diff, message, command, terminal, and draft PR actions.
- `spoon-agent-worker` also needs `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`.
If the private key is stored in a single-line dotenv value, encode newlines as
literal `\n` characters so the worker can restore the PEM before using it.
@@ -240,22 +316,57 @@ curl -H "Authorization: Bearer $SPOON_AGENT_WORKER_INTERNAL_TOKEN" \
Deployment readiness checklist:
1. Production Convex env has `SPOON_WORKER_TOKEN`, `SPOON_ENCRYPTION_KEY`,
GitHub App env, and Convex Auth signing keys.
GitHub App env (including `GITHUB_APP_WEBHOOK_SECRET`), and Convex Auth
signing keys.
2. Compose env has `SPOON_AGENT_WORKER_URL`,
`SPOON_AGENT_WORKER_INTERNAL_TOKEN`, `SPOON_AGENT_JOB_IMAGE`, and the GitHub
App private key.
App private key; the `spoon-next` image is built with
`NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL`.
3. The production Docker host can pull private images from `git.gbrown.org`.
4. `Settings -> Worker` reports the expected job image, runtime, network, and
active workspace count.
4. `Settings -> Worker` reports the expected box image, runtime, network, and
active box count.
5. The first test thread uses a configured API-key provider or a trusted Codex
login profile.
6. If a worker restart leaves stale workspace state, use the workspace recovery
panel or `Settings -> Worker` cleanup.
6. If a worker restart leaves stale state, use the workspace recovery panel or
`Settings -> Worker` cleanup.
API-key based AI provider profiles run through OpenCode. Codex ChatGPT login
profiles run through the Codex CLI: Spoon writes the encrypted `auth.json` into
the isolated job workspace as `CODEX_HOME/.codex/auth.json` before execution.
Treat that saved auth file like a password and only use it on trusted workers.
</details>
<details>
<summary><strong>GitHub webhooks</strong></summary>
Spoon exposes a signature-verified webhook as a Convex `httpAction` at
`POST /webhooks/github` (`packages/backend/convex/githubWebhooks.ts`,
`http.ts`). Configure the GitHub App webhook URL as
`<CONVEX_SITE_URL>/webhooks/github` and set `GITHUB_APP_WEBHOOK_SECRET`; the
endpoint fails closed (HTTP 503) when no secret is configured and rejects any
payload whose `x-hub-signature-256` does not verify.
- `push` → a targeted drift refresh for every Spoon tracking that repository.
- `installation` / `installation_repositories` → the matching connection's
status flips to `revoked` (deleted/suspended), `active` (created/unsuspended),
or otherwise `needs_reauth`, surfaced in `Settings -> Integrations`.
The hourly `refreshDueSpoons` cron remains a fallback, so drift still refreshes
even if a webhook is missed.
</details>
<details>
<summary><strong>Notifications</strong></summary>
Spoon delivers an in-app bell plus preference-gated transactional email
(`packages/backend/convex/notifications.ts`). Five kinds are emitted:
`maintenance_thread`, `agent_turn_finished`, `agent_needs_input`, `sync_failed`,
and `connection_needs_reauth`.
- Every event inserts an in-app notification (bell badge via `unreadCount`,
list via `listMine`, `markRead` / `markAllRead`).
- Email is sent through UseSend (`USESEND_*`) only when the user has an email
and the matching per-kind preference is not disabled. Unset preferences
default to enabled.
- Per-kind email toggles live in `Settings -> Notifications`. Web push is out of
scope.
</details>
@@ -285,7 +396,7 @@ Treat that saved auth file like a password and only use it on trusted workers.
<summary><strong>Core tables</strong></summary>
| Table | Purpose |
| ------------------------ | --------------------------------------------------------- |
| ------------------------- | ----------------------------------------------------------- |
| `spoons` | Managed fork records |
| `threads` | Durable maintenance and work conversations |
| `threadMessages` | Messages inside threads |
@@ -297,11 +408,15 @@ Treat that saved auth file like a password and only use it on trusted workers.
| `spoonPullRequests` | Cached fork/upstream pull requests |
| `spoonSecrets` | Encrypted per-Spoon environment variables |
| `spoonAgentSettings` | Per-Spoon runtime, branch, command, and env-file settings |
| `aiProviderProfiles` | Encrypted provider/auth profiles used by OpenCode |
| `aiProviderProfiles` | Encrypted provider/auth profiles (Codex/OpenCode/Anthropic) |
| `agentJobs` | Worker-executed workspace jobs and PR lifecycle |
| `agentJobEvents` | Append-only worker event log |
| `agentJobArtifacts` | Diffs, summaries, command output, PR body drafts |
| `agentWorkspaceChanges` | Recorded user, agent, and command file changes |
| `userDotfiles` | Encrypted per-user dotfiles overlay + repo/setup config |
| `userEnvironment` | Per-user box/home environment config |
| `notifications` | In-app notification rows (bell) |
| `notificationPreferences` | Per-user email notification toggles |
</details>
@@ -309,7 +424,7 @@ Treat that saved auth file like a password and only use it on trusted workers.
<summary><strong>Important routes</strong></summary>
| Route | Purpose |
| --------------------------------- | --------------------------------------- |
| --------------------------------- | ----------------------------------------------- |
| `/` | Public product landing page |
| `/dashboard` | Maintenance overview |
| `/spoons` | Managed fork list |
@@ -318,11 +433,16 @@ Treat that saved auth file like a password and only use it on trusted workers.
| `/spoons/[spoonId]/agent/[jobId]` | Interactive workspace |
| `/threads` | Global thread queue |
| `/threads/[threadId]` | Thread detail |
| `/machine` | My Machine: box status, terminal, home |
| `/settings/profile` | User profile settings |
| `/settings/integrations` | GitHub and service integration settings |
| `/settings/ai-providers` | AI/OpenCode provider profiles |
| `/settings/ai-providers` | AI provider profiles (Codex/OpenCode/Anthropic) |
| `/settings/dotfiles` | Per-user dotfiles overlay + repo |
| `/settings/notifications` | Email notification preferences |
Legacy `/updates` and `/agents` routes redirect into `/threads`.
The Convex `httpAction` `POST /webhooks/github` (mounted at
`<CONVEX_SITE_URL>/webhooks/github`) handles GitHub App webhooks. Legacy
`/updates` and `/agents` routes redirect into `/threads`.
</details>
@@ -424,9 +544,10 @@ not call Infisical.
<summary><strong>Public Next variables</strong></summary>
| Variable | Used for |
| --------------------------------- | ------------------------------------------- |
| --------------------------------------- | ------------------------------------------------------------------- |
| `NEXT_PUBLIC_SITE_URL` | Canonical Spoon web URL |
| `NEXT_PUBLIC_CONVEX_URL` | Convex client URL |
| `NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL` | Browser-facing worker WS base for the terminal (**build-time** var) |
| `NEXT_PUBLIC_DEPLOYMENT_URL` | Convex dashboard/deployment URL when needed |
| `NEXT_PUBLIC_PLAUSIBLE_URL` | Plausible analytics endpoint |
| `NEXT_PUBLIC_SENTRY_DSN` | Browser Sentry DSN |
@@ -487,15 +608,16 @@ not call Infisical.
| `SPOON_AGENT_WORKER_URL` | Internal worker HTTP URL used by Next |
| `SPOON_AGENT_WORKER_HTTP_PORT` | Worker HTTP port |
| `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | Server-only token for Next-to-worker proxy |
| `SPOON_AGENT_JOB_IMAGE` | Agent job container image |
| `SPOON_AGENT_TERMINAL_SECRET` | HMAC secret for terminal/box tokens (falls back to the internal/worker token) |
| `SPOON_AGENT_JOB_IMAGE` | Per-user box (Fedora) image the worker `exec`s into |
| `SPOON_AGENT_RUNTIME` | Runtime mode, currently Docker/Podman-oriented |
| `SPOON_AGENT_CONTAINER_RUNTIME` | Container CLI used by worker, `docker`/`podman` |
| `SPOON_AGENT_CONTAINER_ACCESS` | `network` in prod, `host_port` for host dev |
| `SPOON_AGENT_BOX_IDLE_MS` | Idle time before a per-user box is reaped (default `1800000`) |
| `SPOON_AGENT_MAX_CONCURRENT_JOBS` | Worker concurrency limit |
| `SPOON_AGENT_JOB_TIMEOUT_MS` | Job timeout |
| `SPOON_AGENT_WORKDIR` | Worker work directory |
| `SPOON_AGENT_JOB_TIMEOUT_MS` | Agent turn timeout |
| `SPOON_AGENT_WORKDIR` | Worker work directory; per-user homes live under `homes/{username}` |
| `SPOON_AGENT_HOST_WORKDIR` | Host path matching `SPOON_AGENT_WORKDIR` when the worker runs in Docker and controls the host Docker socket |
| `SPOON_AGENT_NETWORK` | Optional job container network |
| `SPOON_AGENT_NETWORK` | Optional box container network |
</details>
@@ -523,12 +645,20 @@ not call Infisical.
- GitHub drift refresh, commit cache, PR cache, and sync-run history
- Effective drift and ignored upstream change records
- Global Threads page and Spoon-scoped Threads tab
- OpenCode/Codex-oriented agent worker and browser workspace foundation
- Persistent per-user box the worker `exec`s into for agent turns, terminal, and
commands
- Three agent runtimes (Codex, OpenCode, Claude Code) behind one adapter, with
queue-time runtime validation
- My Machine surface (box status/start/stop/restart, terminal, home browser)
- Interactive workspace terminal (xterm.js → box PTY)
- Per-user dotfiles overlay + optional dotfiles repo
- Signature-verified GitHub webhook with hourly cron fallback
- In-app + email notifications with per-user preferences
- Monaco editor with optional Vim mode
- Diff viewer, command panel, worker logs, and artifacts
- Encrypted Spoon secrets and bulk `.env` import
- Encrypted AI provider profiles, including Codex auth JSON and API-key
provider support
- Encrypted AI provider profiles, including Codex/Anthropic auth JSON and
API-key provider support
- Authentik, GitHub, and password auth through Convex Auth
- Self-hosted Convex/Postgres deployment model
+161
View File
@@ -357,6 +357,114 @@ export const normalizeCodexJsonLine = (
return events;
};
// Claude Code `--output-format stream-json` emits one JSON object per line:
// `system:init` announces the session id, `assistant` messages carry text and
// tool_use blocks, `user` messages carry tool_result blocks, and `result`
// terminates the turn (success with final text, or an `error_*` subtype).
export const normalizeClaudeJsonLine = (
line: string,
): NormalizedAgentEvent[] => {
if (!line.trim()) return [];
let parsed: unknown;
try {
parsed = JSON.parse(line) as unknown;
} catch {
return [{ kind: 'status', status: line }];
}
const event = asRecord(parsed);
if (!event) return [{ kind: 'status', status: line }];
const type = stringify(event.type);
const subtype = stringify(event.subtype);
const events: NormalizedAgentEvent[] = [];
if (type === 'system') {
const sessionId = stringify(event.session_id);
if (subtype === 'init' && sessionId) {
events.push({ kind: 'session', sessionId });
}
if (events.length === 0) {
events.push({ kind: 'status', status: subtype || 'system' });
}
return events;
}
if (type === 'assistant') {
const message = asRecord(event.message);
const content = message?.content;
let text = '';
if (Array.isArray(content)) {
for (const block of content) {
const record = asRecord(block);
if (!record) continue;
const blockType = stringify(record.type);
if (blockType === 'text') {
text += textFromPart(record);
} else if (blockType === 'tool_use') {
events.push({
kind: 'tool_started',
name: stringify(record.name ?? 'tool'),
input: JSON.stringify(record.input ?? {}),
});
}
}
}
if (text) events.unshift({ kind: 'assistant_delta', content: text });
if (events.length === 0) {
events.push({ kind: 'status', status: 'assistant' });
}
return events;
}
if (type === 'content_block_delta') {
const delta = asRecord(event.delta);
const text = delta ? textFromPart(delta) : '';
if (text) events.push({ kind: 'assistant_delta', content: text });
if (events.length === 0) {
events.push({ kind: 'status', status: type });
}
return events;
}
if (type === 'user') {
const message = asRecord(event.message);
const content = message?.content;
if (Array.isArray(content)) {
for (const block of content) {
const record = asRecord(block);
if (!record || stringify(record.type) !== 'tool_result') continue;
events.push({
kind: 'tool_completed',
name: 'tool',
output: toolOutputFromRecord(record),
});
}
}
if (events.length === 0) {
events.push({ kind: 'status', status: 'user' });
}
return events;
}
if (type === 'result') {
const sessionId = stringify(event.session_id);
if (sessionId) events.push({ kind: 'session', sessionId });
if (subtype === 'success') {
events.push({
kind: 'assistant_completed',
content: stringify(event.result),
});
} else {
events.push({
kind: 'error',
message: stringify(event.result ?? event.error ?? subtype),
});
}
return events;
}
return [{ kind: 'status', status: type || line }];
};
export const normalizeOpenCodeEvent = (
input: unknown,
): NormalizedAgentEvent[] => {
@@ -464,3 +572,56 @@ export const normalizeOpenCodeEvent = (
}
return events;
};
// `opencode run --format json` emits newline-delimited JSON: streamed events share
// the server's `{ type, properties }` SSE envelope, plus a final result object that
// carries the session id and the assistant's completed text.
export const normalizeOpenCodeRunLine = (
line: string,
): NormalizedAgentEvent[] => {
if (!line.trim()) return [];
let parsed: unknown;
try {
parsed = JSON.parse(line) as unknown;
} catch {
return [{ kind: 'status', status: line }];
}
const record = asRecord(parsed);
if (!record) return [{ kind: 'status', status: line }];
if ('type' in record && 'properties' in record) {
return normalizeOpenCodeEvent(record);
}
const events: NormalizedAgentEvent[] = [];
const sessionId = record.sessionID ?? record.sessionId;
if (typeof sessionId === 'string') {
events.push({ kind: 'session', sessionId });
}
const parts = record.parts;
let text = '';
if (Array.isArray(parts)) {
text = parts
.map((part) => {
const record = asRecord(part);
return record ? textFromPart(record) : '';
})
.join('');
}
if (!text) {
const message = asRecord(record.message);
text =
textFromPart(record) ||
(message ? stringify(message.content ?? message.text) : '');
}
if (text) {
const externalMessageId = stringify(record.messageID ?? record.id);
events.push({
kind: 'assistant_completed',
content: text,
...(externalMessageId ? { externalMessageId } : {}),
});
}
if (events.length === 0) {
events.push({ kind: 'status', status: line });
}
return events;
};
+97
View File
@@ -0,0 +1,97 @@
import { fetchBoxPassword } from './box-settings';
import {
buildBoxInitScript,
buildClearPasswordCommand,
buildSudoPolicyScript,
CHPASSWD_COMMAND,
linuxUsername,
} from './runtime/box-user';
import { runExecInContainer } from './runtime/docker';
const noRedact = (value: string) => value;
// Root exec with loud failure — a broken init would leave a root terminal, so
// surface the script output instead of continuing silently.
const execRoot = async (
containerName: string,
command: string[],
label: string,
input?: string,
redact: (value: string) => string = noRedact,
): Promise<void> => {
const result = await runExecInContainer({
containerName,
command,
environment: {},
containerCwd: '/',
redact,
timeoutMs: 120_000,
input,
// Explicit: under podman keep-id the container's DEFAULT user is the
// mapped uid (1000), not root, and useradd/chpasswd/sudoers need root.
user: 'root',
});
if (result.exitCode !== 0) {
throw new Error(`Box ${label} failed: ${result.output}`);
}
};
/**
* One-time (per container) box-user setup, run as root right after `run`:
* create the Linux user at the mounted home, apply the stored password (via
* chpasswd stdin — never argv), and set the sudo policy (NOPASSWD drop-in
* only while no password is stored). Used as ensureUserContainer's onCreated
* hook by every creation path.
*/
export const initializeBoxUser = async (
containerName: string,
spoonUsername: string,
containerHome: string,
): Promise<void> => {
const user = linuxUsername(spoonUsername);
await execRoot(
containerName,
['bash', '-c', buildBoxInitScript({ username: user, home: containerHome })],
'user init',
);
const password = await fetchBoxPassword(spoonUsername);
await applyBoxPassword(containerName, spoonUsername, password);
};
/**
* Apply (or clear, with null) the box password + matching sudo policy on a
* running container. Shared by init and the live /box/set-password route.
*/
export const applyBoxPassword = async (
containerName: string,
spoonUsername: string,
password: string | null,
): Promise<void> => {
const user = linuxUsername(spoonUsername);
if (password == null) {
await execRoot(
containerName,
buildClearPasswordCommand(user),
'password clear',
);
} else {
await execRoot(
containerName,
CHPASSWD_COMMAND,
'password apply',
`${user}:${password}\n`,
// chpasswd never echoes the password, but redact defensively in case a
// future runtime error message includes stdin.
(value) => value.replaceAll(password, '[redacted]'),
);
}
await execRoot(
containerName,
[
'bash',
'-c',
buildSudoPolicyScript({ username: user, passwordSet: password != null }),
],
'sudo policy',
);
};
+30
View File
@@ -0,0 +1,30 @@
import { ConvexHttpClient } from 'convex/browser';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { env } from './env';
// Lazy: unit tests mock ./env with a partial object, and the constructor
// validates the URL eagerly.
let client: ConvexHttpClient | undefined;
const getClient = () => (client ??= new ConvexHttpClient(env.convexUrl));
/**
* The user's stored box password (decrypted), or null when none is set. Also
* null on ANY error: a box must still start when Convex is unreachable — the
* user just gets the passwordless-sudo default until the next recreation.
*/
export const fetchBoxPassword = async (
username: string,
): Promise<string | null> => {
try {
const result = await getClient().action(
api.boxSettingsNode.getBoxPasswordForWorker,
{ workerToken: env.workerToken, username },
);
return result.password;
} catch (error) {
console.error(`Failed to fetch box password for ${username}:`, error);
return null;
}
};
+181
View File
@@ -0,0 +1,181 @@
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { applyBoxPassword, initializeBoxUser } from './box-init';
import { env } from './env';
import { assertContainedRealPath } from './path-containment';
import {
chownInBox,
ensureUserContainer,
inspectUserBoxStatus,
stopWorkspaceContainer,
userContainerName,
} from './runtime/docker';
import { resetBox } from './user-container';
// Matches FileTreeNode in apps/next/src/components/agent-workspace/types.ts.
type FileTreeNode = {
name: string;
path: string;
type: 'file' | 'directory';
children?: FileTreeNode[];
};
// Per-user box home layout, shared by the HTTP routes and the terminal WS bridge
// so path derivation lives in exactly one place (must match worker.ts).
export const boxHomePaths = (username: string) => ({
homeDir: path.resolve(env.workdir, 'homes', username),
containerHome: path.posix.join('/home', username),
});
export type BoxStatus = {
running: boolean;
image: string | null;
startedAt: string | null;
memoryLimitBytes: number | null;
containerName: string;
};
export const getBoxStatus = async (username: string): Promise<BoxStatus> => {
const status = await inspectUserBoxStatus(username);
return { ...status, containerName: userContainerName(username) };
};
export const startBox = async (username: string): Promise<void> => {
const { homeDir, containerHome } = boxHomePaths(username);
await ensureUserContainer({
username,
workdir: homeDir,
containerHome,
onCreated: (name) => initializeBoxUser(name, username, containerHome),
});
};
// Live password apply for the /box/set-password route: only touches the
// container when it's running — otherwise the stored password is picked up by
// the next creation's init.
export const setBoxUserPassword = async (
username: string,
password: string | null,
): Promise<{ applied: boolean }> => {
const status = await inspectUserBoxStatus(username);
if (!status.running) return { applied: false };
await applyBoxPassword(userContainerName(username), username, password);
return { applied: true };
};
export const stopBox = async (username: string): Promise<void> => {
await stopWorkspaceContainer(userContainerName(username));
resetBox(username);
};
export const restartBox = async (username: string): Promise<void> => {
await stopBox(username);
await startBox(username);
};
// Directories never surfaced in the box file tree — VCS internals, package
// caches and build output that would bloat (or hang) the walk.
const BOX_TREE_IGNORE = new Set([
'.git',
'node_modules',
'.cache',
'.npm',
'.bun',
'.cargo',
'dist',
'build',
'.next',
]);
// Home-relative paths (POSIX) that are always skipped regardless of depth.
const BOX_TREE_IGNORE_PATHS = new Set(['.local/share/Trash']);
// Guard against a huge home hanging the request; stop descending past this.
const BOX_TREE_MAX_NODES = 5000;
// Realpath-checked absolute path inside the user's home. Rejects lexical `..`
// escapes and symlinks that resolve outside home (reuses the Phase-1
// containment helper); for writes, also rejects a symlinked final target.
const safeHomePath = (
homeDir: string,
relPath: string,
opts: { forWrite?: boolean } = {},
): Promise<string> =>
assertContainedRealPath(homeDir, relPath, { ...opts, label: 'home' });
export const listBoxTree = async (username: string): Promise<FileTreeNode> => {
const { homeDir } = boxHomePaths(username);
let count = 0;
const buildChildren = async (
absoluteDir: string,
relativeDir: string,
): Promise<FileTreeNode[]> => {
const entries = await readdir(absoluteDir);
const nodes = await Promise.all(
entries
.sort((a, b) => a.localeCompare(b))
.map(async (entry): Promise<FileTreeNode | null> => {
const relativePath = relativeDir ? `${relativeDir}/${entry}` : entry;
if (
BOX_TREE_IGNORE.has(entry) ||
BOX_TREE_IGNORE_PATHS.has(relativePath)
) {
return null;
}
if (count >= BOX_TREE_MAX_NODES) return null;
count += 1;
const absolutePath = path.join(absoluteDir, entry);
const stats = await stat(absolutePath).catch(() => null);
if (!stats) return null;
if (stats.isDirectory()) {
return {
name: entry,
path: relativePath,
type: 'directory',
children: await buildChildren(absolutePath, relativePath),
};
}
return { name: entry, path: relativePath, type: 'file' };
}),
);
return nodes.filter((node): node is FileTreeNode => Boolean(node));
};
return {
name: '~',
path: '',
type: 'directory',
children: await buildChildren(homeDir, ''),
};
};
export const readBoxFile = async (
username: string,
relPath: string,
): Promise<string> => {
const { homeDir } = boxHomePaths(username);
const target = await safeHomePath(homeDir, relPath);
return await readFile(target, 'utf8');
};
export const writeBoxFile = async (
username: string,
relPath: string,
content: string,
): Promise<{ success: true }> => {
const { homeDir, containerHome } = boxHomePaths(username);
const target = await safeHomePath(homeDir, relPath, { forWrite: true });
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, content);
// Host-side write: repair ownership so the box user can edit the file in a
// shell too (no-op under dev keep-id; best-effort when the box is stopped —
// the next creation's marker chown won't rerun, but a later editor write or
// manual sudo chown recovers, and read access is unaffected).
await chownInBox({
containerName: userContainerName(username),
paths: [path.posix.join(containerHome, relPath)],
});
return { success: true };
};
+88
View File
@@ -0,0 +1,88 @@
type CommandResult = { exitCode: number; output: string };
const launchScript = `
import os
import sys
pid_file, job_id, *command = sys.argv[1:]
if os.getpgrp() != os.getpid():
os.setsid()
os.environ["SPOON_CODEX_JOB_ID"] = job_id
with open(pid_file, "w", encoding="utf-8") as handle:
handle.write(str(os.getpid()))
handle.flush()
os.fsync(handle.fileno())
os.execvpe(command[0], command, os.environ)
`;
const abortScript = `
import os
import signal
import sys
import time
pid_file, job_id = sys.argv[1:]
pid = None
for _ in range(20):
try:
with open(pid_file, encoding="utf-8") as handle:
pid = int(handle.read().strip())
break
except FileNotFoundError:
time.sleep(0.1)
if pid is None:
sys.exit(0)
try:
with open(f"/proc/{pid}/environ", "rb") as handle:
environment = handle.read().split(b"\\0")
marker = f"SPOON_CODEX_JOB_ID={job_id}".encode()
if marker not in environment or os.getpgid(pid) != pid:
raise RuntimeError("Codex process identity did not match the requested job")
os.killpg(pid, signal.SIGTERM)
for _ in range(20):
time.sleep(0.1)
try:
os.killpg(pid, 0)
except ProcessLookupError:
break
else:
os.killpg(pid, signal.SIGKILL)
except ProcessLookupError:
pass
finally:
try:
os.unlink(pid_file)
except FileNotFoundError:
pass
`;
export const managedCodexCommand = (args: {
jobId: string;
pidFile: string;
command: string[];
}) => [
'python3',
'-c',
launchScript,
args.pidFile,
args.jobId,
...args.command,
];
export const abortManagedCodexProcess = async (args: {
jobId: string;
pidFile: string;
execute: (command: string[]) => Promise<CommandResult>;
}) => {
const result = await args.execute([
'python3',
'-c',
abortScript,
args.pidFile,
args.jobId,
]);
if (result.exitCode !== 0) {
throw new Error(`Failed to abort Codex process: ${result.output}`);
}
};
+5 -13
View File
@@ -27,27 +27,20 @@ export const env = {
'docker',
containerVolumeOptions:
process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS?.trim(),
containerAccess:
process.env.SPOON_AGENT_CONTAINER_ACCESS?.trim() === 'host_port'
? 'host_port'
: 'network',
jobImage:
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
// Interactive terminal: image for the persistent shell container (defaults to
// the job image), the secret shared with the Next app for verifying terminal
// tokens, and how long an idle terminal container survives before cleanup.
terminalImage:
process.env.SPOON_AGENT_TERMINAL_IMAGE?.trim() ??
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ??
'spoon-agent-job:latest',
// Secret shared with the Next app for verifying interactive terminal tokens.
terminalSecret:
process.env.SPOON_AGENT_TERMINAL_SECRET?.trim() ??
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN?.trim() ??
process.env.SPOON_WORKER_TOKEN?.trim() ??
'',
terminalIdleMs: intEnv('SPOON_AGENT_TERMINAL_IDLE_MS', 1_800_000),
// How long a per-user box container survives with no active jobs/terminals.
boxIdleMs: intEnv('SPOON_AGENT_BOX_IDLE_MS', 1_800_000),
// Override for the box container's --userns flag. Unset → keep-id mapping
// under podman (host user becomes the box user), nothing under docker.
// Empty string disables entirely; any other value passes through verbatim.
boxUserns: process.env.SPOON_AGENT_BOX_USERNS,
// Dev-only: exit if the parent dev runner dies, so the worker never orphans
// and holds port 3921 across restarts. Set by scripts/dev-agent-worker.
devWatchdog: process.env.SPOON_AGENT_DEV_WATCHDOG === '1',
@@ -60,7 +53,6 @@ export const env = {
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN?.trim() ??
process.env.SPOON_WORKER_TOKEN?.trim() ??
'',
maxConcurrentJobs: intEnv('SPOON_AGENT_MAX_CONCURRENT_JOBS', 1),
jobTimeoutMs: intEnv('SPOON_AGENT_JOB_TIMEOUT_MS', 1_800_000),
githubAppId: requiredEnv('GITHUB_APP_ID'),
githubPrivateKey: requiredEnv('GITHUB_APP_PRIVATE_KEY').replaceAll(
@@ -0,0 +1,42 @@
export const createHeartbeatController = (args: {
intervalMs: number;
heartbeat: (jobId: string) => Promise<{ cancelRequested: boolean }>;
onCancel: (jobId: string) => Promise<void>;
onError: (error: unknown) => void;
}) => {
const timers = new Map<string, ReturnType<typeof setInterval>>();
const inFlight = new Set<string>();
const stop = (jobId: string) => {
const timer = timers.get(jobId);
if (timer) clearInterval(timer);
timers.delete(jobId);
};
const tick = async (jobId: string) => {
if (inFlight.has(jobId) || !timers.has(jobId)) return;
inFlight.add(jobId);
try {
const result = await args.heartbeat(jobId);
if (result.cancelRequested) {
stop(jobId);
await args.onCancel(jobId);
}
} catch (error) {
args.onError(error);
} finally {
inFlight.delete(jobId);
}
};
return {
start: (jobId: string) => {
stop(jobId);
const timer = setInterval(() => void tick(jobId), args.intervalMs);
timer.unref();
timers.set(jobId, timer);
},
stop,
has: (jobId: string) => timers.has(jobId),
};
};
+2
View File
@@ -1,5 +1,6 @@
import { env } from './env';
import { startWorkerServer } from './server';
import { reconcileExistingBoxes } from './user-container';
import { startWorker } from './worker';
// Dev-only watchdog: the dev runner chain (turbo → with-env → dotenv → bash)
@@ -26,4 +27,5 @@ if (env.devWatchdog) {
}
startWorkerServer();
await reconcileExistingBoxes();
await startWorker();
+38
View File
@@ -0,0 +1,38 @@
import { chmod, mkdir, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { assertContainedRealPath } from './path-containment';
export type MaterializedSecret = { name: string; value: string };
// Format matches the historical `NAME="value"` env-file layout the agent CLIs
// source; values are JSON-quoted so newlines/quotes survive.
export const formatMaterializedEnv = (secrets: MaterializedSecret[]) =>
`${secrets
.map((secret) => `${secret.name}=${JSON.stringify(secret.value)}`)
.join('\n')}\n`;
// Resolves the contained env-file path inside the checkout and writes it 0600
// so plaintext secrets never sit world/group-readable in the persistent home.
export const writeMaterializedEnv = async (
repoDir: string,
envFilePath: string,
secrets: MaterializedSecret[],
): Promise<string> => {
const envPath = await assertContainedRealPath(repoDir, envFilePath, {
forWrite: true,
});
await mkdir(path.dirname(envPath), { recursive: true });
await writeFile(envPath, formatMaterializedEnv(secrets), { mode: 0o600 });
// writeFile's mode only applies on create; chmod unconditionally tightens an
// already-existing (possibly 0644) file so plaintext secrets never leak.
await chmod(envPath, 0o600);
return envPath;
};
// Removes a previously materialized env file; no-ops when the path is unset or
// already gone so it is safe on every teardown path.
export const removeMaterializedEnv = async (envPath: string | undefined) => {
if (!envPath) return;
await rm(envPath, { force: true });
};
-128
View File
@@ -1,128 +0,0 @@
import type { OpencodeClient } from '@opencode-ai/sdk';
import { createOpencodeClient } from '@opencode-ai/sdk';
import type { NormalizedAgentEvent } from './agent-events';
import { normalizeOpenCodeEvent } from './agent-events';
export type OpenCodeSession = {
client: OpencodeClient;
sessionId: string;
close: () => void;
};
const basicAuth = (username: string, password: string) =>
`Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const modelParts = (model: string) => {
const [rawProviderId, ...rest] = model.split('/');
const providerID =
rawProviderId && rawProviderId.length > 0 ? rawProviderId : 'openai';
const modelID = rest.length > 0 ? rest.join('/') : model;
return {
providerID,
modelID,
};
};
export const createOpenCodeSession = async (args: {
baseUrl: string;
password: string;
directory: string;
title: string;
onEvent: (event: NormalizedAgentEvent) => Promise<void>;
}) => {
const abortController = new AbortController();
const client = createOpencodeClient({
baseUrl: args.baseUrl,
directory: args.directory,
headers: {
authorization: basicAuth('opencode', args.password),
},
});
const created = await client.session.create({
query: { directory: args.directory },
body: { title: args.title },
});
if (!created.data) {
throw new Error('OpenCode session could not be created.');
}
const sessionId = created.data.id;
void (async () => {
const events = await client.event.subscribe({
signal: abortController.signal,
query: { directory: args.directory },
onSseEvent: (event) => {
for (const normalized of normalizeOpenCodeEvent(event.data)) {
void args.onEvent(normalized);
}
},
onSseError: (error) => {
void args.onEvent({
kind: 'error',
message: error instanceof Error ? error.message : String(error),
});
},
});
for await (const event of events.stream) {
for (const normalized of normalizeOpenCodeEvent(event)) {
await args.onEvent(normalized);
}
}
})().catch((error: unknown) => {
if (!abortController.signal.aborted) {
void args.onEvent({
kind: 'error',
message: error instanceof Error ? error.message : String(error),
});
}
});
return {
client,
sessionId,
close: () => abortController.abort(),
} satisfies OpenCodeSession;
};
export const promptOpenCodeSession = async (args: {
session: OpenCodeSession;
prompt: string;
model: string;
directory: string;
}) => {
const model = modelParts(args.model);
const result = await args.session.client.session.promptAsync({
path: { id: args.session.sessionId },
query: { directory: args.directory },
body: {
model,
parts: [{ type: 'text', text: args.prompt }],
},
});
if (result.error) {
throw new Error('OpenCode prompt was rejected.');
}
};
export const abortOpenCodeSession = async (session: OpenCodeSession) => {
await session.client.session.abort({
path: { id: session.sessionId },
});
};
export const replyOpenCodePermission = async (args: {
session: OpenCodeSession;
permissionId: string;
response: 'once' | 'always' | 'reject';
directory: string;
}) => {
const result = await args.session.client.postSessionIdPermissionsPermissionId(
{
path: { id: args.session.sessionId, permissionID: args.permissionId },
query: { directory: args.directory },
body: { response: args.response },
},
);
if (result.error) {
throw new Error('OpenCode permission response was rejected.');
}
};
+41
View File
@@ -0,0 +1,41 @@
import { lstat, realpath } from 'node:fs/promises';
import path from 'node:path';
const realpathOrDeepestExisting = async (target: string): Promise<string> => {
let current = target;
const tail: string[] = [];
for (;;) {
try {
const real = await realpath(current);
return path.join(real, ...tail.reverse());
} catch (error) {
if ((error as { code?: string }).code !== 'ENOENT') throw error;
const parent = path.dirname(current);
if (parent === current) throw error;
tail.push(path.basename(current));
current = parent;
}
}
};
export const assertContainedRealPath = async (
root: string,
requested: string,
opts: { forWrite?: boolean; label?: string } = {},
): Promise<string> => {
const lexical = path.resolve(root, requested);
const realRoot = await realpath(root);
const resolved = await realpathOrDeepestExisting(lexical);
if (resolved !== realRoot && !resolved.startsWith(`${realRoot}${path.sep}`)) {
throw new Error(
`Refusing to access path outside ${opts.label ?? 'root'}: ${requested}`,
);
}
if (opts.forWrite) {
const info = await lstat(lexical).catch(() => null);
if (info?.isSymbolicLink()) {
throw new Error(`Refusing to write through a symlink: ${requested}`);
}
}
return resolved;
};
+10 -7
View File
@@ -1,8 +1,11 @@
const secretPatterns = [
/ghs_[A-Za-z0-9_]+/g,
/github_pat_[A-Za-z0-9_]+/g,
/sk-[A-Za-z0-9_-]+/g,
/(client_secret|auth_secret|api_key|token|password)=([^ \n\r]+)/gi,
const secretPatterns: { pattern: RegExp; replacement: string }[] = [
{ pattern: /ghs_[A-Za-z0-9_]+/g, replacement: '[redacted]' },
{ pattern: /github_pat_[A-Za-z0-9_]+/g, replacement: '[redacted]' },
{ pattern: /sk-[A-Za-z0-9_-]+/g, replacement: '[redacted]' },
{
pattern: /(client_secret|auth_secret|api_key|token|password)=([^ \n\r]+)/gi,
replacement: '$1=[redacted]',
},
];
export const createRedactor = (values: string[]) => {
@@ -12,8 +15,8 @@ export const createRedactor = (values: string[]) => {
for (const secret of secrets) {
output = output.split(secret).join('[redacted]');
}
for (const pattern of secretPatterns) {
output = output.replace(pattern, '$1=[redacted]');
for (const { pattern, replacement } of secretPatterns) {
output = output.replace(pattern, replacement);
}
return output;
};
@@ -0,0 +1,49 @@
import type { NormalizedAgentEvent } from '../agent-events';
import type { streamExecInContainer } from './docker';
import type { AdapterWorkspace } from './provider';
export type AgentRuntimeName = 'codex' | 'opencode' | 'claude';
export type ExecStreamFn = typeof streamExecInContainer;
export type TurnResult = {
// Assistant text the adapter captured out-of-band (Codex --output-last-message
// file, Claude `result` event). Empty/undefined when everything streamed via onEvent.
finalMessage?: string;
// Runtime session id to persist for continuity (resume/--session/--resume).
sessionId?: string;
// Non-empty when the runtime reported a hard failure the caller must surface.
error?: string;
};
export type AgentRuntime = {
readonly name: AgentRuntimeName;
prepareAuth(workspace: AdapterWorkspace): Promise<void>;
runTurn(
workspace: AdapterWorkspace,
prompt: string,
onEvent: (event: NormalizedAgentEvent) => Promise<void>,
): Promise<TurnResult>;
abort(workspace: AdapterWorkspace): Promise<void>;
};
export type AdapterFactory = (deps?: {
execStream?: ExecStreamFn;
}) => AgentRuntime;
const factories = new Map<AgentRuntimeName, AdapterFactory>();
export const registerAdapter = (
name: AgentRuntimeName,
factory: AdapterFactory,
): void => {
factories.set(name, factory);
};
export const getAdapter = (name: AgentRuntimeName): AgentRuntime => {
const factory = factories.get(name);
if (!factory) {
throw new Error(`No agent runtime adapter registered for "${name}".`);
}
return factory();
};
+20
View File
@@ -0,0 +1,20 @@
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
// Normalize + pretty-print an auth JSON blob before writing it with owner-only
// permissions. Shared by the credential-writing adapters (Claude, and available
// to Codex/OpenCode). `label` names the source in the parse-error message.
export const writeJsonFile = async (
filePath: string,
content: string,
label = 'auth',
) => {
let normalized = content.trim();
try {
normalized = `${JSON.stringify(JSON.parse(normalized), null, 2)}\n`;
} catch {
throw new Error(`${label} JSON is not valid JSON.`);
}
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, normalized, { mode: 0o600 });
};
+84
View File
@@ -0,0 +1,84 @@
// The per-box Linux user. Terminal sessions and agent turns exec as this user
// instead of root (Claude Code refuses some operations as root, and a real
// machine has a named user); sudo is passwordless until the user stores a
// password, after which wheel membership makes sudo prompt for it.
export const BOX_UID = 1000;
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
// Linux-safe username derived from the Spoon username. The home path keeps
// the Spoon username everywhere; only the passwd entry uses this.
export const linuxUsername = (spoonUsername: string): string => {
const cleaned = spoonUsername
.toLowerCase()
.replaceAll(/[^a-z0-9_-]/g, '-')
.slice(0, 32);
if (!cleaned) return 'spoon';
return /^[a-z_]/.test(cleaned) ? cleaned : `u${cleaned}`.slice(0, 32);
};
// Idempotent root init: ensure a SINGLE uid-1000 passwd entry with our name,
// grant wheel, and chown the home ONCE (marker-guarded — later host-side
// writes are repaired by targeted chowns, so a big home is never re-walked).
//
// The uid-1000 entry may already exist: podman --userns=keep-id injects one
// named after the HOST user. A second entry at the same uid would win the
// getpwuid lookup (whoami would report the host username), so an existing
// entry is RENAMED rather than duplicated. `-M` skips skeleton copy since the
// mounted home persists across containers.
export const buildBoxInitScript = (args: {
username: string;
home: string;
}): string => {
const user = shellQuote(args.username);
const home = shellQuote(args.home);
const marker = shellQuote(`${args.home}/.spoon/chown-v1`);
return [
'set -e',
`existing="$(getent passwd ${BOX_UID} | cut -d: -f1 || true)"`,
`if [ -n "$existing" ] && [ "$existing" != ${user} ]; then`,
` usermod -l ${user} -d ${home} -s /bin/bash "$existing"`,
`elif [ -z "$existing" ]; then`,
` useradd -u ${BOX_UID} -o -M -d ${home} -s /bin/bash ${user}`,
'fi',
`usermod -aG wheel ${user}`,
`if [ ! -f ${marker} ]; then`,
// Marker is created BEFORE the recursive chown so ~/.spoon itself ends up
// user-owned by the same sweep.
` mkdir -p "$(dirname ${marker})" && touch ${marker}`,
` chown -R ${BOX_UID}:${BOX_UID} ${home}`,
'fi',
].join('\n');
};
// Sudo policy: NOPASSWD drop-in only while no password is stored; with a
// password, wheel membership makes sudo prompt for it. The drop-in is staged
// to a temp file and checked with `visudo -c` before install so a bad write
// can never brick sudo.
export const buildSudoPolicyScript = (args: {
username: string;
passwordSet: boolean;
}): string => {
if (args.passwordSet) {
return 'rm -f /etc/sudoers.d/spoon-box';
}
const line = `${args.username} ALL=(ALL) NOPASSWD:ALL`;
return [
'set -e',
`printf '%s\\n' ${shellQuote(line)} > /etc/sudoers.d/spoon-box.tmp`,
'visudo -c -f /etc/sudoers.d/spoon-box.tmp',
'chmod 0440 /etc/sudoers.d/spoon-box.tmp',
'mv /etc/sudoers.d/spoon-box.tmp /etc/sudoers.d/spoon-box',
].join('\n');
};
// `chpasswd` reads `user:password` from stdin, so the password never appears
// in argv (visible in `ps`) or in a shell script body.
export const CHPASSWD_COMMAND = ['chpasswd'];
export const buildClearPasswordCommand = (username: string): string[] => [
'passwd',
'-d',
username,
];
@@ -0,0 +1,131 @@
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import type { NormalizedAgentEvent } from '../agent-events';
import type { AdapterFactory, TurnResult } from './agent-runtime';
import type { AdapterWorkspace } from './provider';
import { normalizeClaudeJsonLine } from '../agent-events';
import { env } from '../env';
import { writeJsonFile } from './auth';
import { linuxUsername } from './box-user';
import {
buildMarkedCommand,
killBoxProcessesByMarker,
streamExecInContainer,
} from './docker';
import { claudeEnv, claudeModel, isClaudeOAuthProfile } from './provider';
export const createClaudeAdapter: AdapterFactory = (deps) => {
const execStream = deps?.execStream ?? streamExecInContainer;
const prepareAuth = async (workspace: AdapterWorkspace) => {
// API-key profiles authenticate purely via ANTHROPIC_API_KEY in the
// environment; only OAuth-json profiles need `.credentials.json` on disk.
if (!isClaudeOAuthProfile(workspace.claim)) return;
const secret = workspace.claim.aiProviderProfile?.secret;
if (!secret) {
throw new Error(
'Claude auth profile is missing credentials.json contents.',
);
}
const credentialsPath = path.join(
workspace.homeDir,
'.claude',
'.credentials.json',
);
await writeJsonFile(credentialsPath, secret, 'Claude auth');
};
const runTurn = async (
workspace: AdapterWorkspace,
prompt: string,
onEvent: (event: NormalizedAgentEvent) => Promise<void>,
): Promise<TurnResult> => {
workspace.runtime = 'claude';
workspace.turnMarker = `spoon-turn-${randomUUID()}`;
const marker = workspace.turnMarker;
// Flags verified against `claude --help` (@anthropic-ai/claude-code@2.1.207):
// `-p`, `--output-format stream-json`, `--verbose`, `--dangerously-skip-permissions`,
// `--model`, and `--resume <sessionId>` (continuity) all exist.
const argv = [
'claude',
'-p',
prompt,
'--output-format',
'stream-json',
'--verbose',
'--dangerously-skip-permissions',
'--model',
claudeModel(workspace.claim),
...(workspace.claudeSessionId
? ['--resume', workspace.claudeSessionId]
: []),
];
const command = buildMarkedCommand(marker, argv);
const aiEnv = claudeEnv(workspace.claim, workspace.containerHome);
const secretEnv = Object.fromEntries(
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
);
let capturedSessionId: string | undefined;
let capturedError: string | undefined;
let finalMessage: string | undefined;
// execa's own `timeout` only kills the local docker-exec client, not the
// in-box process group. Schedule an explicit marker kill on timeout and clear
// it once the exec resolves.
const killTimer = setTimeout(() => {
void killBoxProcessesByMarker({
containerName: workspace.boxName,
marker,
});
}, env.jobTimeoutMs);
let result;
try {
result = await execStream({
containerName: workspace.boxName,
user: linuxUsername(workspace.username),
containerCwd: workspace.containerRepo,
command,
environment: { ...aiEnv, ...secretEnv },
redact: workspace.redact,
timeoutMs: env.jobTimeoutMs,
onStdoutLine: async (line) => {
for (const event of normalizeClaudeJsonLine(line)) {
if (event.kind === 'session') capturedSessionId = event.sessionId;
if (event.kind === 'error') capturedError = event.message;
if (event.kind === 'assistant_completed' && event.content) {
finalMessage = event.content;
}
await onEvent(event);
}
},
onStderrLine: async (line) => {
if (!line.trim()) return;
await onEvent({ kind: 'status', status: line });
},
});
} finally {
clearTimeout(killTimer);
}
if (result.exitCode !== 0) {
capturedError = result.output;
}
return { finalMessage, sessionId: capturedSessionId, error: capturedError };
};
const abort = async (workspace: AdapterWorkspace) => {
if (!workspace.turnMarker) return;
await killBoxProcessesByMarker({
containerName: workspace.boxName,
marker: workspace.turnMarker,
});
};
return { name: 'claude', prepareAuth, runTurn, abort };
};
@@ -0,0 +1,195 @@
import { randomUUID } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { NormalizedAgentEvent } from '../agent-events';
import type { AdapterFactory, TurnResult } from './agent-runtime';
import type { AdapterWorkspace } from './provider';
import { normalizeCodexJsonLine } from '../agent-events';
import { prepareCodexWorkspaceFiles } from '../codex-runtime';
import { env } from '../env';
import { linuxUsername } from './box-user';
import {
buildMarkedCommand,
killBoxProcessesByMarker,
streamExecInContainer,
} from './docker';
import {
codexModelArgs,
isCodexLoginProfile,
providerEnvironment,
} from './provider';
// Reused verbatim from the worker: normalize + pretty-print an auth JSON blob
// before writing it with owner-only permissions.
const writeJsonFile = async (filePath: string, content: string) => {
let normalized = content.trim();
try {
normalized = `${JSON.stringify(JSON.parse(normalized), null, 2)}\n`;
} catch {
throw new Error('Codex auth JSON is not valid JSON.');
}
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, normalized, { mode: 0o600 });
};
const isNoise = (line: string) => {
const trimmed = line.trim();
return (
!trimmed ||
trimmed === 'Reading additional input from stdin...' ||
trimmed.includes('`[features].codex_hooks` is deprecated')
);
};
export const createCodexAdapter: AdapterFactory = (deps) => {
const execStream = deps?.execStream ?? streamExecInContainer;
const prepareAuth = async (workspace: AdapterWorkspace) => {
// API-key profiles (e.g. OpenAI api_key) authenticate purely via
// OPENAI_API_KEY from providerEnvironment; only ChatGPT-login/JSON-snapshot
// profiles need a Codex `auth.json` on disk. Mirror the Claude adapter,
// which early-returns for non-OAuth profiles.
if (!isCodexLoginProfile(workspace.claim)) return;
const secret = workspace.claim.aiProviderProfile?.secret;
if (!secret) {
throw new Error('Codex auth profile is missing auth.json contents.');
}
await prepareCodexWorkspaceFiles({
workdir: workspace.workdir,
repoDir: workspace.repoDir,
});
const codexAuthPath = path.join(workspace.workdir, '.codex', 'auth.json');
await writeJsonFile(codexAuthPath, secret);
// Also seed OpenCode's auth location with the saved JSON for forward
// compatibility if this profile later runs through OpenCode directly.
const openCodeAuthPath = path.join(
workspace.workdir,
'.local',
'share',
'opencode',
'auth.json',
);
await writeJsonFile(openCodeAuthPath, secret);
};
const runTurn = async (
workspace: AdapterWorkspace,
prompt: string,
onEvent: (event: NormalizedAgentEvent) => Promise<void>,
): Promise<TurnResult> => {
workspace.runtime = 'codex';
workspace.turnMarker = `spoon-turn-${randomUUID()}`;
const marker = workspace.turnMarker;
const outputFileName = `last-message-${workspace.claim.job._id}.txt`;
const outputFileHostPath = path.join(
workspace.workdir,
'.codex',
outputFileName,
);
const outputFileContainerPath = path.posix.join(
workspace.containerHome,
'.codex',
outputFileName,
);
const codexArgv = workspace.codexSessionId
? [
'codex',
'exec',
'resume',
'--json',
...codexModelArgs(workspace.claim),
'--dangerously-bypass-approvals-and-sandbox',
'--output-last-message',
outputFileContainerPath,
workspace.codexSessionId,
prompt,
]
: [
'codex',
'exec',
'--json',
...codexModelArgs(workspace.claim),
'--dangerously-bypass-approvals-and-sandbox',
'--output-last-message',
outputFileContainerPath,
'--cd',
workspace.containerRepo,
prompt,
];
const command = buildMarkedCommand(marker, codexArgv);
const aiEnv = providerEnvironment(workspace.claim, workspace.containerHome);
const secretEnv = Object.fromEntries(
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
);
let capturedSessionId: string | undefined;
let capturedError: string | undefined;
// execa's own `timeout` only kills the local docker-exec client, not the
// in-box process group. Schedule an explicit marker kill on timeout and
// clear it once the exec resolves.
const killTimer = setTimeout(() => {
void killBoxProcessesByMarker({
containerName: workspace.boxName,
marker,
});
}, env.jobTimeoutMs);
let result;
try {
result = await execStream({
containerName: workspace.boxName,
user: linuxUsername(workspace.username),
containerCwd: workspace.containerRepo,
command,
environment: { ...aiEnv, ...secretEnv },
redact: workspace.redact,
timeoutMs: env.jobTimeoutMs,
onStdoutLine: async (line) => {
for (const event of normalizeCodexJsonLine(line)) {
if (event.kind === 'session') capturedSessionId = event.sessionId;
if (event.kind === 'error') capturedError = event.message;
await onEvent(event);
}
},
onStderrLine: async (line) => {
if (isNoise(line)) return;
await onEvent({ kind: 'status', status: line });
},
});
} finally {
clearTimeout(killTimer);
}
if (result.exitCode !== 0) {
capturedError = result.output;
}
let finalMessage: string | undefined;
try {
const lastMessage = await readFile(outputFileHostPath, 'utf8');
if (lastMessage.trim()) finalMessage = lastMessage.trim();
} catch (error) {
const code = error && typeof error === 'object' ? 'code' in error : false;
if (!code || (error as { code?: string }).code !== 'ENOENT') {
throw error;
}
}
return { finalMessage, sessionId: capturedSessionId, error: capturedError };
};
const abort = async (workspace: AdapterWorkspace) => {
if (!workspace.turnMarker) return;
await killBoxProcessesByMarker({
containerName: workspace.boxName,
marker: workspace.turnMarker,
});
};
return { name: 'codex', prepareAuth, runTurn, abort };
};
+225 -206
View File
@@ -1,15 +1,24 @@
import { mkdir } from 'node:fs/promises';
import path from 'node:path';
import type { Readable } from 'node:stream';
import Docker from 'dockerode';
import { execa } from 'execa';
import { env } from '../env';
import { BOX_UID, linuxUsername } from './box-user';
type CommandResult = {
exitCode: number;
output: string;
};
let dockerClient: Docker | undefined;
export const getDockerClient = () => {
dockerClient ??= new Docker();
return dockerClient;
};
const environmentArgs = (environment: Record<string, string>) =>
Object.entries(environment).flatMap(([name, value]) => [
'-e',
@@ -18,6 +27,21 @@ const environmentArgs = (environment: Record<string, string>) =>
const networkArgs = () => (env.network ? ['--network', env.network] : []);
// Box user namespace mapping. Under rootless podman, keep-id maps the host
// user onto the box user's uid so host-side writes (clones, dotfiles, file
// editor) are already user-owned inside the box. Rootful docker has no
// equivalent — ownership is repaired with targeted chowns instead (see
// chownInBox). SPOON_AGENT_BOX_USERNS overrides: empty disables, any other
// value passes through.
export const boxUsernsArgs = (): string[] => {
if (env.boxUserns !== undefined) {
return env.boxUserns ? [`--userns=${env.boxUserns}`] : [];
}
return containerRuntime().endsWith('podman')
? [`--userns=keep-id:uid=${BOX_UID},gid=${BOX_UID}`]
: [];
};
const containerRuntime = () => env.containerRuntime;
// `docker run` reuses a stale local `:latest` forever, so without an explicit
@@ -95,138 +119,6 @@ export const jobWorkspaceVolumeSpec = (
: `${source}:${containerHome}`;
};
export const runInJobContainer = async (args: {
workdir: string;
containerHome?: string;
containerCwd?: string;
command: string[];
environment: Record<string, string>;
redact: (value: string) => string;
timeoutMs: number;
}): Promise<CommandResult> => {
await ensureJobImagePulled();
const result = await execa(
containerRuntime(),
[
'run',
'--rm',
'--memory',
'4g',
'--cpus',
'2',
...networkArgs(),
...environmentArgs(args.environment),
'-v',
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
'-w',
args.containerCwd ?? '/workspace/repo',
env.jobImage,
...args.command,
],
{
all: true,
reject: false,
stdin: 'ignore',
timeout: args.timeoutMs,
},
);
return normalizeRunResult(result, result.all, args.redact);
};
export const startWorkspaceContainer = async (args: {
workdir: string;
containerHome?: string;
containerCwd?: string;
containerName: string;
environment: Record<string, string>;
command?: string[];
publishTcpPort?: number;
}) => {
await ensureJobImagePulled();
await execa(containerRuntime(), ['rm', '-f', args.containerName], {
reject: false,
});
const result = await execa(
containerRuntime(),
[
'run',
'-d',
'--name',
args.containerName,
'--memory',
'4g',
'--cpus',
'2',
...networkArgs(),
...(args.publishTcpPort
? ['-p', `127.0.0.1::${args.publishTcpPort}`]
: []),
...environmentArgs(args.environment),
'-v',
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
'-w',
args.containerCwd ?? '/workspace/repo',
env.jobImage,
...(args.command ?? ['sleep', 'infinity']),
],
{ all: true, stdin: 'ignore' },
);
return {
containerId: result.stdout.trim(),
containerName: args.containerName,
hostPort: args.publishTcpPort
? await getPublishedPort(args.containerName, args.publishTcpPort)
: undefined,
};
};
const getPublishedPort = async (
containerName: string,
containerPort: number,
) => {
const result = await execa(
containerRuntime(),
['port', containerName, `${containerPort}/tcp`],
{ all: true, reject: false, stdin: 'ignore' },
);
const output = result.all.trim();
const match = /:(\d+)\s*$/.exec(output);
if (!match?.[1]) {
throw new Error(
`Could not determine published port for ${containerName}:${containerPort}.`,
);
}
return match[1];
};
export const execInWorkspaceContainer = async (args: {
containerName: string;
command: string[];
environment?: Record<string, string>;
redact: (value: string) => string;
timeoutMs: number;
}): Promise<CommandResult> => {
const result = await execa(
containerRuntime(),
[
'exec',
...(args.environment ? environmentArgs(args.environment) : []),
args.containerName,
...args.command,
],
{
all: true,
reject: false,
stdin: 'ignore',
timeout: args.timeoutMs,
},
);
return {
exitCode: result.exitCode ?? 0,
output: args.redact(result.all),
};
};
// Shared line-streaming + result normalization for a started subprocess
// (used by both `docker run` and `docker exec` paths).
type StreamingSubprocess = {
@@ -288,51 +180,6 @@ const streamSubprocess = async (
return normalizeRunResult(result, output.join(''), redact);
};
export const streamInJobContainer = async (args: {
workdir: string;
containerHome?: string;
containerCwd?: string;
command: string[];
environment: Record<string, string>;
redact: (value: string) => string;
timeoutMs: number;
onStdoutLine?: (line: string) => Promise<void>;
onStderrLine?: (line: string) => Promise<void>;
}): Promise<CommandResult> => {
await ensureJobImagePulled();
const subprocess = execa(
containerRuntime(),
[
'run',
'--rm',
'--memory',
'4g',
'--cpus',
'2',
...networkArgs(),
...environmentArgs(args.environment),
'-v',
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
'-w',
args.containerCwd ?? '/workspace/repo',
env.jobImage,
...args.command,
],
{
all: true,
reject: false,
stdin: 'ignore',
timeout: args.timeoutMs,
},
);
return streamSubprocess(
subprocess,
args.redact,
args.onStdoutLine,
args.onStderrLine,
);
};
// Per-user persistent "box" container that all of a user's threads exec into
// (Phase 2). Started once, reused; the home volume persists state across stops.
export const userContainerName = (username: string) =>
@@ -342,6 +189,10 @@ export const ensureUserContainer = async (args: {
username: string;
workdir: string;
containerHome: string;
// Runs once per fresh container, after `run` succeeds — box-user init
// (useradd, sudo policy, password) hooks in here so every creation path
// (acquire, explicit start) sets the user up without duplicating the logic.
onCreated?: (containerName: string) => Promise<void>;
}): Promise<string> => {
await ensureJobImagePulled();
const name = userContainerName(args.username);
@@ -362,12 +213,16 @@ export const ensureUserContainer = async (args: {
[
'run',
'-d',
'--init',
'--name',
name,
'--hostname',
`${linuxUsername(args.username)}-box`,
'--memory',
'4g',
'--cpus',
'2',
...boxUsernsArgs(),
...networkArgs(),
'-v',
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
@@ -379,9 +234,155 @@ export const ensureUserContainer = async (args: {
],
{ stdin: 'ignore' },
);
if (args.onCreated) await args.onCreated(name);
return name;
};
// Inspect the per-user box in one shot. Non-zero exit (no such container) yields
// an all-null/false status. `--memory 0` means unlimited, which the Go template
// prints as `0`; we normalize that (and any unparseable value) to null.
export const inspectUserBoxStatus = async (
username: string,
): Promise<{
running: boolean;
image: string | null;
startedAt: string | null;
memoryLimitBytes: number | null;
}> => {
const result = await execa(
containerRuntime(),
[
'inspect',
'--format',
'{{.State.Running}}|{{.Config.Image}}|{{.State.StartedAt}}|{{.HostConfig.Memory}}',
userContainerName(username),
],
{ reject: false, stdin: 'ignore' },
);
if (result.exitCode !== 0) {
return {
running: false,
image: null,
startedAt: null,
memoryLimitBytes: null,
};
}
const [runningField, imageField, startedAtField, memoryField] = result.stdout
.trim()
.split('|');
const running = runningField === 'true';
return {
running,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty field means "no image"
image: imageField || null,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty field means "not started"
startedAt: running ? startedAtField || null : null,
memoryLimitBytes: Number(memoryField) || null,
};
};
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
// The marker is embedded literally into a `# comment` line and into a `pgrep`
// pattern, so it must not contain shell metacharacters or a newline (either could
// break out of the comment or the pattern). Callers generate it, but validate
// defensively so a malformed marker fails loudly instead of silently corrupting
// the script.
const MARKER_PATTERN = /^[A-Za-z0-9_-]+$/;
const assertMarker = (marker: string) => {
if (!MARKER_PATTERN.test(marker)) {
throw new Error(
`Invalid process marker ${JSON.stringify(marker)}: must match ${String(MARKER_PATTERN)}.`,
);
}
};
// Wraps a CLI argv so the process runs as a new session/process-group leader whose
// bash parent carries the marker in its argv (matchable by `pgrep -f`). We do NOT
// `exec` the CLI: bash tail-exec's a final simple command, which would replace the
// marker-carrying bash with the CLI and make `pgrep -f <marker>` miss the running
// turn. Keeping a trailing `rc=$?; exit "$rc"` after the CLI means the CLI is no
// longer the script's final statement, so bash survives as the group leader while
// still propagating the CLI's exit code (downstream `normalizeRunResult` relies on
// it — a bare trailing `wait` would mask a non-zero CLI failure as 0). The CLI runs
// in the same process group as this bash, so the kill helper's group signal reaches
// it. stdout/stderr fds are inherited by the CLI so streaming still works.
export const buildMarkedCommand = (
marker: string,
command: string[],
): string[] => {
assertMarker(marker);
const script = [
`# ${marker}`,
'exec 0</dev/null',
command.map(shellQuote).join(' '),
'rc=$?',
'exit "$rc"',
].join('\n');
return ['setsid', 'bash', '-lc', script];
};
// Kills every process group whose bash parent matches the marker, TERM then KILL.
// The negative pid (`kill -TERM -"$pgid"`) targets the whole process group, so the
// CLI and any children it spawned die together.
//
// pgrep self-match: this kill script runs via `bash -lc <script>`, whose OWN argv
// contains the marker literal, so `pgrep -f <marker>` matches the kill script (and
// its command-substitution subshells) too. Left unguarded it would `kill -TERM` its
// own process group and die on iteration one, leaking the real target. So we compute
// this script's pgid up front and skip any match sharing it, killing only the
// target's process group.
export const buildKillScript = (marker: string): string => {
assertMarker(marker);
return [
`self_pgid=$(ps -o pgid= -p $$ | tr -d ' ')`,
`pids=$(pgrep -f ${shellQuote(marker)} || true)`,
`for pid in $pids; do`,
` pgid=$(ps -o pgid= -p "$pid" | tr -d ' ')`,
` [ -z "$pgid" ] && continue`,
` [ "$pgid" = "$self_pgid" ] && continue`,
` kill -TERM -"$pgid" 2>/dev/null || true`,
`done`,
`sleep 2`,
`for pid in $pids; do`,
` pgid=$(ps -o pgid= -p "$pid" | tr -d ' ')`,
` [ -z "$pgid" ] && continue`,
` [ "$pgid" = "$self_pgid" ] && continue`,
` kill -KILL -"$pgid" 2>/dev/null || true`,
`done`,
].join('\n');
};
export const killBoxProcessesByMarker = async (args: {
containerName: string;
marker: string;
}): Promise<void> => {
await execa(
containerRuntime(),
['exec', args.containerName, 'bash', '-lc', buildKillScript(args.marker)],
{ reject: false, stdin: 'ignore' },
);
};
// Shared `<runtime> exec` argv. `user` runs the command as that container
// user instead of root — terminals and agent turns pass the box user; init,
// chown and kill paths stay root.
export const execArgv = (args: {
containerName: string;
command: string[];
environment: Record<string, string>;
containerCwd: string;
user?: string;
}): string[] => [
'exec',
...environmentArgs(args.environment),
'-w',
args.containerCwd,
...(args.user ? ['-u', args.user] : []),
args.containerName,
...args.command,
];
export const streamExecInContainer = async (args: {
containerName: string;
command: string[];
@@ -389,21 +390,16 @@ export const streamExecInContainer = async (args: {
containerCwd: string;
redact: (value: string) => string;
timeoutMs: number;
user?: string;
onStdoutLine?: (line: string) => Promise<void>;
onStderrLine?: (line: string) => Promise<void>;
}): Promise<CommandResult> => {
const subprocess = execa(
containerRuntime(),
[
'exec',
...environmentArgs(args.environment),
'-w',
args.containerCwd,
args.containerName,
...args.command,
],
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
);
const subprocess = execa(containerRuntime(), execArgv(args), {
all: true,
reject: false,
stdin: 'ignore',
timeout: args.timeoutMs,
});
return streamSubprocess(
subprocess,
args.redact,
@@ -419,20 +415,54 @@ export const runExecInContainer = async (args: {
containerCwd: string;
redact: (value: string) => string;
timeoutMs: number;
user?: string;
// Piped to the command's stdin (e.g. `chpasswd` credentials, which must
// never appear in argv). Without it stdin is closed.
input?: string;
}): Promise<CommandResult> => {
const result = await execa(containerRuntime(), execArgv(args), {
all: true,
reject: false,
timeout: args.timeoutMs,
...(args.input == null
? { stdin: 'ignore' as const }
: { input: args.input }),
});
return normalizeRunResult(result, result.all, args.redact);
};
// Best-effort ownership repair after host-side writes into a box home. Under
// dev podman (keep-id) this is a no-op — files are already the box uid; under
// prod docker it fixes uid-0 ownership left by the worker container. Failures
// are logged, not thrown: a stopped box just means the next creation's
// marker-guarded chown (or the next call) picks it up.
export const chownInBox = async (args: {
containerName: string;
paths: string[];
}): Promise<void> => {
if (args.paths.length === 0) return;
const result = await execa(
containerRuntime(),
[
'exec',
...environmentArgs(args.environment),
'-w',
args.containerCwd,
// Explicit root: under podman keep-id the container default user is the
// mapped uid, which cannot chown files it does not own.
'-u',
'root',
args.containerName,
...args.command,
'chown',
'-R',
`${BOX_UID}:${BOX_UID}`,
...args.paths,
],
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
{ all: true, reject: false, stdin: 'ignore' },
);
return normalizeRunResult(result, result.all, args.redact);
const normalized = normalizeRunResult(result, result.all, (value) => value);
if (normalized.exitCode !== 0) {
console.error(
`chown in ${args.containerName} failed (${String(normalized.exitCode)}): ${normalized.output}`,
);
}
};
export const stopWorkspaceContainer = async (containerName: string) => {
@@ -441,17 +471,6 @@ export const stopWorkspaceContainer = async (containerName: string) => {
});
};
export const inspectWorkspaceContainer = async (containerName: string) => {
const result = await execa(containerRuntime(), ['inspect', containerName], {
all: true,
reject: false,
});
return {
exists: result.exitCode === 0,
output: result.all,
};
};
export const listWorkspaceContainerNames = async (prefix: string) => {
const result = await execa(
containerRuntime(),
@@ -0,0 +1,131 @@
import net from 'node:net';
import type { Duplex } from 'node:stream';
import { getDockerClient } from './docker';
// Hand-rolled replacement for docker-modem's hijacked `exec.start`. Bun's
// node:http client never emits the `'upgrade'` event (it surfaces the 101 as a
// plain response), so dockerode's `exec.start({ hijack: true })` waits forever
// under bun — the terminal connects but no bytes ever flow. Docker and podman
// both answer the exec-start upgrade with `101 UPGRADED` followed by the raw
// TTY byte stream on the same connection, so we speak that one exchange
// directly over a net socket and hand the socket to the caller.
export type UpgradeParse =
| { complete: false }
| { complete: true; statusCode: number; remainder: Buffer };
const HEADER_TERMINATOR = Buffer.from('\r\n\r\n');
// Parse an accumulating exec-start response buffer. Incomplete until the CRLF
// CRLF header terminator arrives; after it, `remainder` is the start of the raw
// stream (or the error body) and must be preserved byte-exact.
export const parseUpgradeResponse = (data: Buffer): UpgradeParse => {
const headerEnd = data.indexOf(HEADER_TERMINATOR);
if (headerEnd === -1) return { complete: false };
const statusLine =
data.subarray(0, headerEnd).toString('latin1').split('\r\n', 1)[0] ?? '';
const match = /^HTTP\/1\.[01] (\d{3})/.exec(statusLine);
return {
complete: true,
statusCode: match?.[1] ? Number.parseInt(match[1], 10) : 0,
remainder: data.subarray(headerEnd + HEADER_TERMINATOR.length),
};
};
type ModemTarget = {
socketPath?: string;
host?: string;
port?: number | string;
protocol?: string;
};
/**
* Start a created exec and return the raw bidirectional TTY stream. Writes are
* stdin; reads are the terminal output (Tty:true, so no stdcopy framing). The
* socket is returned PAUSED: output that arrives while the caller is still
* wiring handlers stays buffered instead of being dropped (the first tmux
* screen paint often lands within the same tick as the upgrade). Callers must
* `stream.resume()` once their `data` handler is attached.
*/
export const openExecStream = (
execId: string,
{ timeoutMs = 15_000 }: { timeoutMs?: number } = {},
): Promise<Duplex> =>
new Promise((resolve, reject) => {
const modem = getDockerClient().modem as ModemTarget;
if (modem.protocol && modem.protocol !== 'http' && !modem.socketPath) {
reject(
new Error(
`Exec streaming supports unix sockets and plain tcp docker hosts; got protocol "${modem.protocol}".`,
),
);
return;
}
const socket = modem.socketPath
? net.connect({ path: modem.socketPath })
: net.connect({
host: modem.host ?? 'localhost',
port: Number(modem.port ?? 2375),
});
let buffered = Buffer.alloc(0);
let settled = false;
const fail = (error: Error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
socket.destroy();
reject(error);
};
const timer = setTimeout(
() => fail(new Error(`Exec start timed out after ${timeoutMs}ms.`)),
timeoutMs,
);
socket.on('error', (error: Error) =>
fail(new Error(`Exec stream connection failed: ${error.message}`)),
);
socket.on('close', () =>
fail(
new Error(
`Exec stream closed before the upgrade completed${buffered.length ? `: ${buffered.toString('utf8').slice(0, 300)}` : '.'}`,
),
),
);
socket.on('connect', () => {
const body = JSON.stringify({ Detach: false, Tty: true });
socket.write(
`POST /exec/${encodeURIComponent(execId)}/start HTTP/1.1\r\n` +
'Host: docker\r\n' +
'Content-Type: application/json\r\n' +
`Content-Length: ${Buffer.byteLength(body)}\r\n` +
'Connection: Upgrade\r\n' +
'Upgrade: tcp\r\n' +
'\r\n' +
body,
);
});
const onData = (chunk: Buffer) => {
buffered = Buffer.concat([buffered, chunk]);
const parsed = parseUpgradeResponse(buffered);
if (!parsed.complete) return;
if (parsed.statusCode !== 101 && parsed.statusCode !== 200) {
fail(
new Error(
`Exec start failed: HTTP ${parsed.statusCode || 'unparseable'} ${parsed.remainder.toString('utf8').slice(0, 300)}`.trim(),
),
);
return;
}
settled = true;
clearTimeout(timer);
socket.off('data', onData);
socket.pause();
if (parsed.remainder.length > 0) socket.unshift(parsed.remainder);
resolve(socket);
};
socket.on('data', onData);
});
@@ -0,0 +1,142 @@
import { randomUUID } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { NormalizedAgentEvent } from '../agent-events';
import type { AdapterFactory, TurnResult } from './agent-runtime';
import type { AdapterWorkspace } from './provider';
import { normalizeOpenCodeRunLine } from '../agent-events';
import { env } from '../env';
import { linuxUsername } from './box-user';
import {
buildMarkedCommand,
killBoxProcessesByMarker,
streamExecInContainer,
} from './docker';
import { opencodeModel, providerEnvironment } from './provider';
// Normalize + pretty-print an auth JSON blob before writing it with owner-only
// permissions (mirrors the CodexAdapter helper).
const writeJsonFile = async (filePath: string, content: string) => {
let normalized = content.trim();
try {
normalized = `${JSON.stringify(JSON.parse(normalized), null, 2)}\n`;
} catch {
throw new Error('OpenCode auth JSON is not valid JSON.');
}
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, normalized, { mode: 0o600 });
};
export const createOpenCodeAdapter: AdapterFactory = (deps) => {
const execStream = deps?.execStream ?? streamExecInContainer;
const prepareAuth = async (workspace: AdapterWorkspace) => {
// API-key profiles authenticate purely via ANTHROPIC_API_KEY/OPENAI_API_KEY
// in the environment; only `opencode_auth_json` profiles need auth.json on disk.
const profile = workspace.claim.aiProviderProfile;
if (profile?.authType !== 'opencode_auth_json') return;
if (!profile.secret) {
throw new Error('OpenCode auth profile is missing auth.json contents.');
}
const authPath = path.join(
workspace.workdir,
'.local',
'share',
'opencode',
'auth.json',
);
await writeJsonFile(authPath, profile.secret);
};
const runTurn = async (
workspace: AdapterWorkspace,
prompt: string,
onEvent: (event: NormalizedAgentEvent) => Promise<void>,
): Promise<TurnResult> => {
workspace.runtime = 'opencode';
workspace.turnMarker = `spoon-turn-${randomUUID()}`;
const marker = workspace.turnMarker;
// Flags verified against `opencode run --help` (opencode-ai@1.17.9):
// `--format json`, `--model`, and `--session <id>` all exist.
const argv = [
'opencode',
'run',
'--format',
'json',
'--model',
opencodeModel(workspace.claim),
...(workspace.opencodeSessionId
? ['--session', workspace.opencodeSessionId]
: []),
'--',
prompt,
];
const command = buildMarkedCommand(marker, argv);
const aiEnv = providerEnvironment(workspace.claim, workspace.containerHome);
const secretEnv = Object.fromEntries(
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
);
let capturedSessionId: string | undefined;
let capturedError: string | undefined;
let finalMessage: string | undefined;
// execa's own `timeout` only kills the local docker-exec client, not the
// in-box process group. Schedule an explicit marker kill on timeout and clear
// it once the exec resolves.
const killTimer = setTimeout(() => {
void killBoxProcessesByMarker({
containerName: workspace.boxName,
marker,
});
}, env.jobTimeoutMs);
let result;
try {
result = await execStream({
containerName: workspace.boxName,
user: linuxUsername(workspace.username),
containerCwd: workspace.containerRepo,
command,
environment: { ...aiEnv, ...secretEnv },
redact: workspace.redact,
timeoutMs: env.jobTimeoutMs,
onStdoutLine: async (line) => {
for (const event of normalizeOpenCodeRunLine(line)) {
if (event.kind === 'session') capturedSessionId = event.sessionId;
if (event.kind === 'error') capturedError = event.message;
if (event.kind === 'assistant_completed' && event.content) {
finalMessage = event.content;
}
await onEvent(event);
}
},
onStderrLine: async (line) => {
if (!line.trim()) return;
await onEvent({ kind: 'status', status: line });
},
});
} finally {
clearTimeout(killTimer);
}
if (result.exitCode !== 0) {
capturedError = result.output;
}
return { finalMessage, sessionId: capturedSessionId, error: capturedError };
};
const abort = async (workspace: AdapterWorkspace) => {
if (!workspace.turnMarker) return;
await killBoxProcessesByMarker({
containerName: workspace.boxName,
marker: workspace.turnMarker,
});
};
return { name: 'opencode', prepareAuth, runTurn, abort };
};
+199
View File
@@ -0,0 +1,199 @@
import path from 'node:path';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import type { AgentRuntimeName } from './agent-runtime';
export type Claim = {
job: {
_id: Id<'agentJobs'>;
prompt: string;
runtime?: 'codex' | 'opencode' | 'claude';
jobType?: 'user_change' | 'maintenance_review' | 'conflict_resolution';
envFilePath?: string;
materializeEnvFile?: boolean;
baseBranch: string;
workBranch: string;
forkOwner: string;
forkRepo: string;
upstreamOwner: string;
upstreamRepo: string;
};
spoon: { name: string };
openai: {
apiKey?: string;
model: string;
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
};
aiProviderProfile?: {
id: string;
name: string;
provider:
| 'openai'
| 'anthropic'
| 'google'
| 'openrouter'
| 'requesty'
| 'litellm'
| 'cloudflare_ai_gateway'
| 'custom_openai_compatible'
| 'opencode_openai_login';
authType: 'api_key' | 'opencode_auth_json' | 'anthropic_oauth_json' | 'none';
secret?: string;
baseUrl?: string;
model: string;
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
};
github: { installationId?: string };
agentSettings?: {
installCommand?: string;
checkCommand?: string;
testCommand?: string;
autoDetectCommands?: boolean;
} | null;
secrets: { name: string; value: string }[];
};
// The subset of the worker's active workspace that agent adapters read.
export type AdapterWorkspace = {
claim: Claim;
workdir: string;
homeDir: string;
username: string;
containerHome: string;
containerRepo: string;
repoDir: string;
boxName: string;
redact: (value: string) => string;
runtime: AgentRuntimeName;
codexSessionId?: string;
opencodeSessionId?: string;
claudeSessionId?: string;
turnMarker?: string;
};
export const isCodexLoginProfile = (claim: Claim) =>
claim.aiProviderProfile?.provider === 'opencode_openai_login' ||
claim.aiProviderProfile?.authType === 'opencode_auth_json';
export const collectJsonStringValues = (value?: string): string[] => {
if (!value) return [];
try {
const parsed = JSON.parse(value) as unknown;
const values: string[] = [];
const visit = (item: unknown) => {
if (typeof item === 'string') {
if (item.length >= 12) values.push(item);
return;
}
if (Array.isArray(item)) {
item.forEach(visit);
return;
}
if (item && typeof item === 'object') {
Object.values(item).forEach(visit);
}
};
visit(parsed);
return values;
} catch {
return [];
}
};
export const providerEnvironment = (
claim: Claim,
workspaceRoot?: string,
): Record<string, string> => {
if (isCodexLoginProfile(claim)) {
if (!workspaceRoot) {
throw new Error('Codex auth profiles require a prepared workspace.');
}
return {
CODEX_HOME: path.join(workspaceRoot, '.codex'),
HOME: workspaceRoot,
XDG_DATA_HOME: path.join(workspaceRoot, '.local', 'share'),
XDG_CONFIG_HOME: path.join(workspaceRoot, '.config'),
};
}
const profile = claim.aiProviderProfile;
const secret = profile?.secret ?? claim.openai.apiKey;
if (!secret) {
throw new Error('No AI provider credential is configured for this job.');
}
const baseUrl: Record<string, string> = profile?.baseUrl
? { OPENAI_BASE_URL: profile.baseUrl }
: {};
if (!profile || profile.provider === 'openai') {
return { OPENAI_API_KEY: secret, ...baseUrl };
}
if (profile.provider === 'anthropic') return { ANTHROPIC_API_KEY: secret };
if (profile.provider === 'google') return { GOOGLE_API_KEY: secret };
if (profile.provider === 'openrouter') {
return { OPENROUTER_API_KEY: secret, ...baseUrl };
}
if (profile.provider === 'requesty') {
return { REQUESTY_API_KEY: secret, ...baseUrl };
}
if (profile.provider === 'cloudflare_ai_gateway') {
return { CLOUDFLARE_API_KEY: secret, ...baseUrl };
}
if (
profile.provider === 'litellm' ||
profile.provider === 'custom_openai_compatible'
) {
return { OPENAI_API_KEY: secret, ...baseUrl };
}
throw new Error('Unsupported AI provider profile.');
};
export const opencodeModel = (claim: Claim) => {
const profile = claim.aiProviderProfile;
const model = profile?.model ?? claim.openai.model;
if (model.includes('/')) return model;
if (!profile) return `openai/${model}`;
if (
profile.provider === 'custom_openai_compatible' ||
profile.provider === 'cloudflare_ai_gateway'
) {
return model;
}
if (profile.provider === 'opencode_openai_login') return `openai/${model}`;
return `${profile.provider}/${model}`;
};
export const codexModel = (claim: Claim) => {
const model = claim.aiProviderProfile?.model ?? claim.openai.model;
return model.includes('/') ? (model.split('/').at(-1) ?? model) : model;
};
export const codexModelArgs = (claim: Claim) =>
isCodexLoginProfile(claim) ? [] : ['--model', codexModel(claim)];
// Claude Code OAuth profiles store their credentials as a JSON blob written to
// `~/.claude/.credentials.json`; API-key profiles authenticate via
// ANTHROPIC_API_KEY. Keyed on the Anthropic OAuth snapshot type.
export const isClaudeOAuthProfile = (claim: Claim) =>
claim.aiProviderProfile?.authType === 'anthropic_oauth_json';
// The `claude --model` flag expects a bare model id (e.g. `claude-sonnet-4`),
// so strip any `provider/` prefix like codexModel does.
export const claudeModel = (claim: Claim) => {
const model = claim.aiProviderProfile?.model ?? claim.openai.model;
return model.includes('/') ? (model.split('/').at(-1) ?? model) : model;
};
// Environment for a `claude -p` turn. OAuth-json profiles authenticate from the
// on-disk credentials file (only HOME is needed); API-key profiles export
// ANTHROPIC_API_KEY.
export const claudeEnv = (
claim: Claim,
containerHome: string,
): Record<string, string> => {
if (isClaudeOAuthProfile(claim)) return { HOME: containerHome };
const secret = claim.aiProviderProfile?.secret ?? claim.openai.apiKey;
if (!secret) {
throw new Error('No Anthropic API key is configured for this Claude job.');
}
return { ANTHROPIC_API_KEY: secret, HOME: containerHome };
};
@@ -0,0 +1,8 @@
import { registerAdapter } from './agent-runtime';
import { createClaudeAdapter } from './claude-adapter';
import { createCodexAdapter } from './codex-adapter';
import { createOpenCodeAdapter } from './opencode-adapter';
registerAdapter('codex', createCodexAdapter);
registerAdapter('opencode', createOpenCodeAdapter);
registerAdapter('claude', createClaudeAdapter);
@@ -0,0 +1,60 @@
import type { AgentRuntimeName } from './agent-runtime';
// Pure, side-effect-free decision for how a completed runtime turn should be
// surfaced. Kept out of worker.ts so it is unit-testable without loading the
// worker's Convex/Docker dependencies.
//
// Inputs:
// - assistantText: text already streamed to the user (may be partial).
// - recoveredText: text recovered from `TurnResult.finalMessage` (already
// redacted+truncated by the caller), used only when nothing streamed.
// - error: `TurnResult.error` — non-empty when the runtime reported a hard
// failure (nonzero exit / failure event).
// - runtime: runtime name, for the failure message prefix.
//
// Output:
// - text: the assistant text to persist (streamed text, else recovered text).
// - failure: when set, the caller must surface the turn as FAILED (throw). The
// partial text in `text` is still preserved so the user can see it.
export const resolveTurnOutcome = (args: {
assistantText: string;
recoveredText?: string;
error?: string;
runtime: AgentRuntimeName;
}): { text: string; failure?: string } => {
const { assistantText, recoveredText, error, runtime } = args;
let text = assistantText;
if (!text.trim() && recoveredText) {
text = recoveredText;
}
if (!text.trim()) {
return {
text,
failure: error
? `${runtime} failed:\n${error}`
: 'Codex completed without producing an assistant response.',
};
}
// A hard failure that streamed partial text must still be surfaced as failed;
// the streamed text is preserved above so the user sees the truncated answer.
if (error) {
return { text, failure: `${runtime} failed:\n${error}` };
}
return { text };
};
// Pure decision for whether the `content` on an `assistant_completed` event
// should be folded into the accumulated assistant text.
//
// Some runtimes (Claude `-p --output-format stream-json`, OpenCode) emit the
// final answer TWICE: once as streamed `assistant_delta` chunks and again as
// the `content` on the terminal `assistant_completed`/`result` event. Appending
// both yields a doubled "<answer><answer>". So only fold in the completed
// content when nothing has streamed yet (a runtime that emits only a final
// result). When deltas already carried the answer, skip it. The truly-empty
// case is still covered by resolveTurnOutcome's recoveredText/finalMessage
// fallback, so nothing is lost.
export const shouldAppendCompletedContent = (
currentText: string,
completedContent?: string,
): boolean => Boolean(completedContent) && !currentText.trim();
+91
View File
@@ -3,6 +3,16 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import {
getBoxStatus,
listBoxTree,
readBoxFile,
restartBox,
setBoxUserPassword,
startBox,
stopBox,
writeBoxFile,
} from './box';
import { env } from './env';
import { attachTerminalServer } from './terminal';
import {
@@ -56,6 +66,13 @@ const jobRoute = (pathname: string) => {
return { jobId: decodeURIComponent(match[1]), action: match[2] };
};
export const boxRoute = (pathname: string) => {
const match = /^\/box\/(status|lifecycle|tree|file|set-password)$/.exec(
pathname,
);
return match?.[1] ? { action: match[1] } : null;
};
export const startWorkerServer = () => {
const server = createServer((request, response) => {
void (async () => {
@@ -73,6 +90,78 @@ export const startWorkerServer = () => {
sendJson(response, 200, await cleanupOrphanedWorkspaces());
return;
}
const box = boxRoute(url.pathname);
if (box) {
const user = url.searchParams.get('user') ?? '';
if (!user) {
sendJson(response, 400, { error: 'Missing user' });
return;
}
if (request.method === 'GET' && box.action === 'status') {
sendJson(response, 200, { status: await getBoxStatus(user) });
return;
}
if (request.method === 'POST' && box.action === 'lifecycle') {
const body = await parseJson<{ action?: string }>(request);
if (body.action === 'start') {
await startBox(user);
} else if (body.action === 'stop') {
await stopBox(user);
} else if (body.action === 'restart') {
await restartBox(user);
} else {
sendJson(response, 400, { error: 'Unknown action' });
return;
}
sendJson(response, 200, { status: await getBoxStatus(user) });
return;
}
if (request.method === 'GET' && box.action === 'tree') {
sendJson(response, 200, { tree: await listBoxTree(user) });
return;
}
if (request.method === 'GET' && box.action === 'file') {
const filePath = url.searchParams.get('path') ?? '';
sendJson(response, 200, {
path: filePath,
content: await readBoxFile(user, filePath),
});
return;
}
if (request.method === 'PUT' && box.action === 'file') {
const body = await parseJson<{ path?: string; content?: string }>(
request,
);
sendJson(
response,
200,
await writeBoxFile(user, body.path ?? '', body.content ?? ''),
);
return;
}
if (request.method === 'POST' && box.action === 'set-password') {
const body = await parseJson<{ password?: string | null }>(request);
// Convex owns validation/storage; this route only applies to the
// live container. Re-check bounds anyway so a bad caller can't
// push a malformed chpasswd line.
const password = body.password ?? null;
if (
password !== null &&
(password.length < 8 ||
password.length > 128 ||
// eslint-disable-next-line no-control-regex -- rejecting control chars is the point
/[\u0000-\u001f\u007f]/.test(password))
) {
sendJson(response, 400, { error: 'Invalid password' });
return;
}
sendJson(response, 200, await setBoxUserPassword(user, password));
return;
}
sendJson(response, 404, { error: 'Not found' });
return;
}
const route = jobRoute(url.pathname);
if (!route) {
sendJson(response, 404, { error: 'Not found' });
@@ -175,6 +264,8 @@ export const startWorkerServer = () => {
const status =
message === 'Unauthorized'
? 401
: message.startsWith('Refusing to')
? 400
: message.includes('not supported')
? 409
: 500;
+38
View File
@@ -27,3 +27,41 @@ export const verifyTerminalToken = (
timingSafeEqual(providedBuf, expectedBuf)
);
};
// Extract the username a box token authorizes without verifying its signature.
// The token itself authorizes the connection, so the upgrade handler reads the
// username from it (rather than a query param) and then verifies the whole
// token against that username. Returns null unless the format matches
// `${expiresAtMs}.box.${username}.${hmacSha256Hex}`.
export const parseBoxTokenUsername = (token: string): string | null => {
const parts = token.split('.');
if (parts.length !== 4 || parts[1] !== 'box') return null;
const username = parts[2];
if (!username) return null;
return username;
};
// User-scoped variant authorizing a terminal connection to a user's box.
// Embeds a literal `box` segment so its four parts never collide with the
// three-part job token above. Format:
// `${expiresAtMs}.box.${username}.${hmacSha256Hex}`
export const verifyBoxTerminalToken = (
token: string,
username: string,
secret: string,
): boolean => {
if (!token || !secret) return false;
const parts = token.split('.');
if (parts.length !== 4 || parts[1] !== 'box') return false;
const [expRaw, , tokenUsername, provided] = parts;
if (tokenUsername !== username) return false;
const exp = Number.parseInt(expRaw ?? '', 10);
if (!Number.isFinite(exp) || Date.now() > exp) return false;
const expected = signature(`${expRaw}.box.${tokenUsername}`, secret);
const providedBuf = Buffer.from(provided ?? '', 'hex');
const expectedBuf = Buffer.from(expected, 'hex');
return (
providedBuf.length === expectedBuf.length &&
timingSafeEqual(providedBuf, expectedBuf)
);
};
+263 -97
View File
@@ -1,12 +1,26 @@
import { spawn } from 'node:child_process';
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import type { Server } from 'node:http';
import type { Duplex } from 'node:stream';
import type { WebSocket } from 'ws';
import { WebSocketServer } from 'ws';
import type { BoxHandle } from './user-container';
import { boxHomePaths } from './box';
import { env } from './env';
import { verifyTerminalToken } from './terminal-token';
import { acquireUserBox, releaseUserBox } from './user-container';
import { linuxUsername } from './runtime/box-user';
import {
chownInBox,
getDockerClient,
killBoxProcessesByMarker,
} from './runtime/docker';
import { openExecStream } from './runtime/exec-stream';
import {
parseBoxTokenUsername,
verifyBoxTerminalToken,
verifyTerminalToken,
} from './terminal-token';
import { acquireUserBox } from './user-container';
import { ensureBashProfile } from './user-environment';
import { getTerminalWorkspace } from './worker';
const clampDimension = (value: unknown) => {
@@ -15,55 +29,100 @@ const clampDimension = (value: unknown) => {
return Math.min(Math.max(n, 1), 1000);
};
// Single-quote a string for a POSIX shell.
const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`;
const bridge = async (ws: WebSocket, jobId: string) => {
const workspace = getTerminalWorkspace(jobId);
if (!workspace) {
ws.close(1011, 'Workspace is not active.');
return;
}
// bun can't load node-pty (native ABI mismatch) and dockerode can't attach to
// podman, so we drive the runtime CLI (`<runtime> exec -i`) and allocate the PTY
// *inside* the container with `script`, bridging the plain pipes to the socket.
//
// Register the message handler immediately and buffer input/size until the exec
// is ready (acquiring the box can take seconds on first connect), so the initial
// resize and early keystrokes aren't dropped.
const procHolder: { current?: ChildProcessWithoutNullStreams } = {};
const pendingInput: Buffer[] = [];
let cols = 80;
let rows = 24;
ws.on('message', (data: Buffer, isBinary: boolean) => {
if (!isBinary) {
// Text frames are control messages (resize); anything else is raw input.
export const parseResizeMessage = (
data: Buffer,
isBinary: boolean,
): { cols: number; rows: number } | null => {
if (isBinary) return null;
try {
const message = JSON.parse(data.toString('utf8')) as {
type?: string;
cols?: number;
rows?: number;
};
if (message.type === 'resize') {
const c = clampDimension(message.cols);
const r = clampDimension(message.rows);
if (c && r) {
cols = c;
rows = r;
if (message.type !== 'resize') return null;
const cols = clampDimension(message.cols);
const rows = clampDimension(message.rows);
if (!cols || !rows) return null;
return { cols, rows };
} catch {
return null;
}
};
// Close with a reason the browser can display. The WebSocket close-frame
// payload caps the reason at 123 bytes — `ws` throws past that — so clamp.
const closeWithReason = (ws: WebSocket, code: number, reason: string) => {
ws.close(code, Buffer.from(reason, 'utf8').subarray(0, 120).toString('utf8'));
};
// The login-shell command shared by the job and box terminals: prefer a
// resumable tmux session, else fall back to a plain interactive login shell.
//
// The marker comment keeps a per-connection tag in the wrapper bash's argv so
// the bridge can kill THIS connection's process group when the WebSocket
// closes. Without it, every disconnect leaks an attached tmux client (the
// exec process survives the closed attach socket); the session then stays
// sized to the smallest zombie client, and once enough dead clients' ptys
// fill up, tmux stops rendering for live ones entirely. tmux/bash must NOT be
// exec'd — that would replace the marker-carrying argv.
export const buildTerminalShellCommand = (marker: string): string[] => [
'/bin/bash',
'-lc',
`# ${marker}
if command -v tmux >/dev/null 2>&1; then tmux new-session -A -s spoon; else bash -il; fi`,
];
type ShellBridgeOptions = {
// Acquire the per-user box (reference-counted); its handle is released on
// cleanup. Called after the input handler is wired so early keystrokes buffer.
acquire: () => Promise<BoxHandle>;
// Host-side preparation before the shell starts (e.g. seed .bash_profile).
prepare?: () => Promise<void>;
// Runs after the box is acquired (so the container exists) — ownership
// repair for anything `prepare` wrote host-side.
postAcquire?: (boxName: string) => Promise<void>;
// Container user the shell runs as (the box user, not root).
user: string;
// Directory the login shell starts in (container path).
cwd: string;
// Env entries appended after TERM (e.g. HOME, and job secrets for job terms).
envFlags: string[];
};
/**
* Shared PTY bridge: wire a WebSocket to an interactive login shell running via
* a real TTY (dockerode `exec` with `Tty:true` + `exec.resize`) inside the
* user's box. Registers the input handler immediately and buffers input/size
* until the exec is ready (acquiring the box can take seconds on first connect),
* so the initial resize and early keystrokes aren't dropped. Used by both the
* job terminal (`bridge`) and the box terminal (`bridgeBox`); only the box to
* acquire, the working directory, and the env differ.
*/
const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
const execHolder: {
current?: { exec: import('dockerode').Exec; stream: Duplex };
} = {};
const pendingInput: Buffer[] = [];
let cols = 80;
let rows = 24;
ws.on('message', (data: Buffer, isBinary: boolean) => {
const resize = parseResizeMessage(data, isBinary);
if (resize) {
cols = resize.cols;
rows = resize.rows;
void execHolder.current?.exec
.resize({ h: rows, w: cols })
.catch(() => undefined);
return;
}
} catch {
// fall through: treat as raw input
}
}
if (procHolder.current) procHolder.current.stdin.write(data);
if (execHolder.current) execHolder.current.stream.write(data);
else pendingInput.push(data);
});
let acquired = false;
const marker = `spoon-term-${randomUUID()}`;
const handleHolder: { current?: BoxHandle } = {};
let released = false;
// Read through a function so TS doesn't narrow `released` to a constant — the
// cleanup handler flips it asynchronously when the socket closes.
@@ -71,8 +130,18 @@ const bridge = async (ws: WebSocket, jobId: string) => {
const cleanup = () => {
if (released) return;
released = true;
procHolder.current?.kill();
if (acquired) releaseUserBox(workspace.username);
execHolder.current?.stream.end();
execHolder.current?.stream.destroy();
// Closing the attach socket does NOT kill the exec'd shell — reap this
// connection's process group so dead tmux clients don't accumulate. The
// tmux server daemonized into its own group and survives.
const boxName = handleHolder.current?.boxName;
if (boxName && execHolder.current) {
void killBoxProcessesByMarker({ containerName: boxName, marker }).catch(
() => undefined,
);
}
handleHolder.current?.release();
};
ws.on('close', cleanup);
ws.on('error', cleanup);
@@ -81,78 +150,138 @@ const bridge = async (ws: WebSocket, jobId: string) => {
// the terminal share the exact same container (Phase 2).
let boxName: string;
try {
boxName = await acquireUserBox({
username: workspace.username,
workdir: workspace.workdir,
containerHome: workspace.containerHome,
});
acquired = true;
if (opts.prepare) await opts.prepare();
const handle = await opts.acquire();
handleHolder.current = handle;
boxName = handle.boxName;
} catch (error) {
ws.close(
console.error('Terminal box acquire failed:', error);
closeWithReason(
ws,
1011,
`Failed to start terminal: ${error instanceof Error ? error.message : 'unknown error'}`,
);
return;
}
if (isReleased()) return; // client disconnected during startup; cleanup ran
// Cleanup may have run before the awaited handle existed.
if (isReleased()) {
handleHolder.current.release();
return;
}
// Reattach a persistent tmux session across reconnects when available, else a
// plain login shell. `stty` sizes the PTY to the client's viewport up front.
const launcher =
`stty rows ${rows} cols ${cols} 2>/dev/null; ` +
// Reattach a persistent tmux session when tmux is present; otherwise fall back
// to an interactive login shell (`-i` so it prints a prompt and line-edits).
// Check with `command -v` rather than `exec tmux || …`: a failed `exec` makes a
// non-interactive shell exit before the `||`, so the fallback never runs.
'if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s spoon; ' +
'else exec bash -il; fi';
const envFlags = [
'-e',
'TERM=xterm-256color',
'-e',
`HOME=${workspace.containerHome}`,
...workspace.secrets.flatMap((s) => ['-e', `${s.name}=${s.value}`]),
];
if (opts.postAcquire) await opts.postAcquire(boxName);
const proc = spawn(
env.containerRuntime,
[
'exec',
'-i',
...envFlags,
'-w',
workspace.containerRepo,
boxName,
'/bin/bash',
'-lc',
`exec script -qfc ${shellQuote(launcher)} /dev/null`,
],
{ stdio: ['pipe', 'pipe', 'pipe'] },
// The exec create is a plain API call, but the attach is NOT dockerode's
// `exec.start({ hijack: true })`: docker-modem's hijack waits for node's http
// client `'upgrade'` event, which bun never emits, so under bun it hangs
// forever with a connected-but-dead terminal. `openExecStream` speaks the
// upgrade exchange over a raw socket instead (works under bun and node,
// against docker and podman). `exec.resize` is a plain POST and stays on
// dockerode.
let exec: import('dockerode').Exec;
let stream: Duplex;
try {
const docker = getDockerClient();
const container = docker.getContainer(boxName);
exec = await container.exec({
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
User: opts.user,
Cmd: buildTerminalShellCommand(marker),
Env: ['TERM=xterm-256color', ...opts.envFlags],
WorkingDir: opts.cwd,
});
stream = await openExecStream(exec.id);
} catch (error) {
console.error('Terminal exec attach failed:', error);
closeWithReason(
ws,
1011,
`Failed to attach terminal: ${error instanceof Error ? error.message : 'unknown error'}`,
);
procHolder.current = proc;
cleanup();
return;
}
execHolder.current = { exec, stream };
// Replay any keystrokes the client sent before the process was ready.
for (const buffered of pendingInput) proc.stdin.write(buffered);
pendingInput.length = 0;
if (isReleased()) {
stream.end();
stream.destroy();
return;
}
const forward = (chunk: Buffer) => {
await exec.resize({ h: rows, w: cols }).catch(() => undefined);
stream.on('data', (chunk: Buffer) => {
if (ws.readyState === ws.OPEN) ws.send(chunk, { binary: true });
};
proc.stdout.on('data', forward);
proc.stderr.on('data', forward);
proc.on('exit', () => {
});
stream.on('end', () => {
if (ws.readyState === ws.OPEN) ws.close();
});
proc.on('error', () => {
stream.on('error', () => {
if (ws.readyState === ws.OPEN) ws.close();
});
// Replay any keystrokes the client sent before the process was ready, then
// start the (paused) stream now that the output handler is attached.
for (const buffered of pendingInput) stream.write(buffered);
pendingInput.length = 0;
stream.resume();
};
// Job terminal: opens the shell at the repo checkout, with the job's selected
// secrets in the environment (it is scoped to that job's active workspace).
const bridge = async (ws: WebSocket, jobId: string) => {
const workspace = getTerminalWorkspace(jobId);
if (!workspace) {
ws.close(1011, 'Workspace is not active.');
return;
}
await runShellBridge(ws, {
acquire: () =>
acquireUserBox({
username: workspace.username,
workdir: workspace.workdir,
containerHome: workspace.containerHome,
}),
user: linuxUsername(workspace.username),
cwd: workspace.containerRepo,
envFlags: [
`HOME=${workspace.containerHome}`,
...workspace.secrets.map((secret) => `${secret.name}=${secret.value}`),
],
});
};
// Box terminal: opens a login shell rooted at the user's home (`~`), with no
// per-job secrets — it is user-scoped, not tied to any job's workspace.
const bridgeBox = async (ws: WebSocket, username: string) => {
const { homeDir, containerHome } = boxHomePaths(username);
await runShellBridge(ws, {
prepare: () => ensureBashProfile(homeDir),
// prepare's host-side .bash_profile write may be root-owned under prod
// docker; repair once the container is known to exist.
postAcquire: (boxName) =>
chownInBox({
containerName: boxName,
paths: [`${containerHome}/.bash_profile`],
}),
acquire: () =>
acquireUserBox({ username, workdir: homeDir, containerHome }),
user: linuxUsername(username),
cwd: containerHome,
envFlags: [`HOME=${containerHome}`],
});
};
/**
* Attaches the interactive-terminal WebSocket endpoint to the worker's HTTP
* server. Browser connects to `/jobs/:jobId/terminal?token=…` with a short-lived
* token minted by the Next app (which has already verified job ownership).
* Attaches the interactive-terminal WebSocket endpoints to the worker's HTTP
* server. Two routes, each authorized by a short-lived token minted by the Next
* app: `/jobs/:jobId/terminal?token=…` (job-scoped, after ownership check) and
* `/box/terminal?token=…` (user-scoped, username read from the token itself).
*/
export const attachTerminalServer = (server: Server) => {
if (env.runtime !== 'docker') return;
@@ -160,20 +289,57 @@ export const attachTerminalServer = (server: Server) => {
server.on('upgrade', (request, socket, head) => {
const url = new URL(request.url ?? '', `http://localhost:${env.httpPort}`);
const token = url.searchParams.get('token') ?? '';
// User-scoped box terminal: the token authorizes the connection, so the
// username is read FROM the token, then verified against the whole token.
if (url.pathname === '/box/terminal') {
const username = parseBoxTokenUsername(token);
if (
!username ||
!verifyBoxTerminalToken(token, username, env.terminalSecret)
) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
void bridgeBox(ws, username).catch((error: unknown) => {
console.error(`Box terminal bridge failed (${username}):`, error);
if (ws.readyState === ws.OPEN) {
closeWithReason(
ws,
1011,
`Terminal bridge failed: ${error instanceof Error ? error.message : 'unknown error'}`,
);
}
});
});
return;
}
const match = /^\/jobs\/([^/]+)\/terminal$/.exec(url.pathname);
if (!match?.[1]) {
socket.destroy();
return;
}
const jobId = decodeURIComponent(match[1]);
const token = url.searchParams.get('token') ?? '';
if (!verifyTerminalToken(token, jobId, env.terminalSecret)) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
void bridge(ws, jobId);
void bridge(ws, jobId).catch((error: unknown) => {
console.error(`Job terminal bridge failed (${jobId}):`, error);
if (ws.readyState === ws.OPEN) {
closeWithReason(
ws,
1011,
`Terminal bridge failed: ${error instanceof Error ? error.message : 'unknown error'}`,
);
}
});
});
});
+120 -19
View File
@@ -1,40 +1,141 @@
import { initializeBoxUser } from './box-init';
import { env } from './env';
import {
ensureUserContainer,
listWorkspaceContainerNames,
stopWorkspaceContainer,
userContainerName,
} from './runtime/docker';
// Phase 2: one persistent "box" container per user that all of their threads
// (agent turns + terminal + commands) exec into. Reference-counted so it stays
// up while any thread workspace is active or a terminal is connected, and is
// reaped after an idle period once nothing holds it.
type Box = { refs: number; idleTimer?: NodeJS.Timeout };
const boxes = new Map<string, Box>();
// Phase 2: one persistent "box" container per user. Reference-counted by opaque
// handles (not a shared integer), serialized per-username with an async mutex,
// and idle-reaped once no handle is held.
export type BoxHandle = { boxName: string; release: () => void };
export const acquireUserBox = async (args: {
type Box = {
name: string;
refs: Set<symbol>;
idleTimer?: ReturnType<typeof setTimeout>;
};
const boxes = new Map<string, Box>();
const locks = new Map<string, Promise<unknown>>();
// Per-username async mutex: chain each operation after the previous one.
const withLock = <T>(username: string, fn: () => Promise<T>): Promise<T> => {
const previous = locks.get(username) ?? Promise.resolve();
const next = previous.then(fn, fn);
locks.set(
username,
next.then(
() => undefined,
() => undefined,
),
);
return next;
};
const scheduleReapIfIdle = (username: string) => {
const box = boxes.get(username);
if (!box || box.refs.size > 0) return;
if (box.idleTimer) clearTimeout(box.idleTimer);
box.idleTimer = setTimeout(() => {
void withLock(username, async () => {
const current = boxes.get(username);
if (current !== box || current.refs.size > 0) return;
await stopWorkspaceContainer(current.name);
if (boxes.get(username) === current && current.refs.size === 0) {
boxes.delete(username);
}
});
}, env.boxIdleMs);
};
const makeHandle = (username: string, box: Box): BoxHandle => {
const token = Symbol('box-ref');
box.refs.add(token);
let released = false;
return {
boxName: box.name,
release: () => {
if (released) return;
released = true;
box.refs.delete(token);
scheduleReapIfIdle(username);
},
};
};
export const acquireUserBox = (args: {
username: string;
workdir: string;
containerHome: string;
}): Promise<string> => {
const name = await ensureUserContainer(args);
const box = boxes.get(args.username) ?? { refs: 0 };
if (box.idleTimer) {
}): Promise<BoxHandle> =>
withLock(args.username, async () => {
let box = boxes.get(args.username);
if (box?.idleTimer) {
clearTimeout(box.idleTimer);
box.idleTimer = undefined;
}
box.refs += 1;
if (!box) {
box = {
name: userContainerName(args.username),
refs: new Set(),
};
boxes.set(args.username, box);
return name;
}
// Register the ref before the slow Docker call so a failed acquire can
// release it without leaking registry state.
const handle = makeHandle(args.username, box);
try {
// Re-verify with the container runtime on EVERY acquire, not just the
// first: the registry goes stale when the box is removed behind the
// worker's back (manual `docker rm`, another worker's idle reaper), and a
// cached "initialized" flag then hands out handles to a dead container
// until restart. ensureUserContainer is idempotent — an inspect when the
// box is running, a recreate when it isn't.
box.name = await ensureUserContainer({
...args,
onCreated: (containerName) =>
initializeBoxUser(containerName, args.username, args.containerHome),
});
return handle;
} catch (error) {
handle.release();
if (boxes.get(args.username) === box && box.refs.size === 0) {
if (box.idleTimer) clearTimeout(box.idleTimer);
boxes.delete(args.username);
}
throw error;
}
});
// Adopt boxes left by a prior worker process so the idle reaper cleans them up.
export const reconcileExistingBoxes = async (): Promise<void> => {
const names = await listWorkspaceContainerNames('spoon-box-');
for (const name of names) {
const username = name.replace(/^spoon-box-/, '');
if (boxes.has(username)) continue;
const box: Box = { name, refs: new Set() };
boxes.set(username, box);
scheduleReapIfIdle(username);
}
};
export const releaseUserBox = (username: string) => {
export const runningBoxUsernames = (): Set<string> => new Set(boxes.keys());
// Drop a box from the in-memory registry (used after an explicit stop so a
// stopped box isn't treated as "held" by the idle reaper or reconcile logic).
export const resetBox = (username: string): void => {
const box = boxes.get(username);
if (!box) return;
box.refs = Math.max(0, box.refs - 1);
if (box.refs > 0) return;
box.idleTimer = setTimeout(() => {
void stopWorkspaceContainer(userContainerName(username));
if (box.idleTimer) clearTimeout(box.idleTimer);
boxes.delete(username);
}, env.boxIdleMs);
};
export const _resetBoxRegistryForTests = () => {
for (const box of boxes.values()) {
if (box.idleTimer) clearTimeout(box.idleTimer);
}
boxes.clear();
locks.clear();
};
+30 -20
View File
@@ -7,7 +7,9 @@ import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { env } from './env';
import { runExecInContainer } from './runtime/docker';
import { assertContainedRealPath } from './path-containment';
import { linuxUsername } from './runtime/box-user';
import { chownInBox, runExecInContainer } from './runtime/docker';
const client = new ConvexHttpClient(env.convexUrl);
@@ -31,14 +33,21 @@ export const fetchUserEnvironment = async (
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
// Keep a written path inside the home directory.
const safeHomeJoin = (homeDir: string, relPath: string) => {
const target = path.resolve(homeDir, relPath);
const root = path.resolve(homeDir);
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
throw new Error(`Refusing to write dotfile outside home: ${relPath}`);
}
return target;
/**
* A mounted home has no /etc/skel, so login shells wouldn't source ~/.bashrc.
* Write a minimal `.bash_profile` (only if absent) so an interactive login
* shell — the agent's, or the user's box terminal — loads their environment.
* Shared by `materializeUserHome` and the box terminal bridge.
*/
export const ensureBashProfile = async (homeDir: string): Promise<void> => {
await mkdir(homeDir, { recursive: true });
const bashProfile = path.join(homeDir, '.bash_profile');
await readFile(bashProfile, 'utf8').catch(async () => {
await writeFile(
bashProfile,
'# Spoon: load ~/.bashrc for login shells.\n[ -f ~/.bashrc ] && . ~/.bashrc\n',
);
});
};
/**
@@ -56,16 +65,7 @@ export const materializeUserHome = async (args: {
redact: (value: string) => string;
}): Promise<void> => {
const { homeDir, containerHome, boxName, userEnv, redact } = args;
await mkdir(homeDir, { recursive: true });
// A mounted home has no /etc/skel, so ensure login shells source ~/.bashrc.
const bashProfile = path.join(homeDir, '.bash_profile');
await readFile(bashProfile, 'utf8').catch(async () => {
await writeFile(
bashProfile,
'# Spoon: load ~/.bashrc for login shells.\n[ -f ~/.bashrc ] && . ~/.bashrc\n',
);
});
await ensureBashProfile(homeDir);
if (!userEnv.enabled) return;
@@ -103,6 +103,9 @@ export const materializeUserHome = async (args: {
environment: { HOME: containerHome },
redact,
timeoutMs: env.jobTimeoutMs,
// The clone and setup command run as the box user so ~/.dotfiles and
// anything the setup writes stay editable from the terminal.
user: linuxUsername(userEnv.username),
});
await mkdir(path.dirname(markerPath), { recursive: true });
await writeFile(markerPath, configHash);
@@ -110,10 +113,17 @@ export const materializeUserHome = async (args: {
}
// Editable overlay tree (wins over the repo/setup output).
const writtenContainerPaths: string[] = [];
for (const file of userEnv.files) {
const target = safeHomeJoin(homeDir, file.path);
const target = await assertContainedRealPath(homeDir, file.path, {
forWrite: true,
});
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, file.content);
if (file.isExecutable) await chmod(target, 0o755);
writtenContainerPaths.push(path.posix.join(containerHome, file.path));
}
// Overlay files are written host-side — repair ownership so the box user
// can edit them in a shell (no-op under dev keep-id).
await chownInBox({ containerName: boxName, paths: writtenContainerPaths });
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
type TeardownStep = () => unknown;
export const teardownWorkspace = async (steps: {
stopHeartbeat: TeardownStep;
removeActive: TeardownStep;
appendEvent?: TeardownStep;
abortAgent?: TeardownStep;
removeMaterializedEnv?: TeardownStep;
markStopped?: TeardownStep;
closeAgent?: TeardownStep;
stopContainer?: TeardownStep;
release: TeardownStep;
}) => {
const errors: unknown[] = [];
const call = (step: TeardownStep | undefined) => {
if (!step) return Promise.resolve();
try {
return Promise.resolve(step()).catch((error: unknown) => {
errors.push(error);
});
} catch (error) {
errors.push(error);
return Promise.resolve();
}
};
const immediate = [call(steps.stopHeartbeat), call(steps.removeActive)];
await Promise.all(immediate);
await call(steps.appendEvent);
await call(steps.abortAgent);
await call(steps.removeMaterializedEnv);
await call(steps.markStopped);
await call(steps.closeAgent);
await call(steps.stopContainer);
await call(steps.release);
return { errors };
};
@@ -1,8 +1,10 @@
import { describe, expect, test } from 'vitest';
import {
normalizeClaudeJsonLine,
normalizeCodexJsonLine,
normalizeOpenCodeEvent,
normalizeOpenCodeRunLine,
} from '../../src/agent-events';
describe('agent event normalization', () => {
@@ -291,4 +293,110 @@ describe('agent event normalization', () => {
externalMessageId: 'message-2',
});
});
test('normalizes opencode run --format json output lines', () => {
expect(
normalizeOpenCodeRunLine(
JSON.stringify({
type: 'message.part.delta',
properties: { part: { text: 'hi' }, messageID: 'm1' },
}),
),
).toContainEqual({
kind: 'assistant_delta',
content: 'hi',
externalMessageId: 'm1',
});
expect(
normalizeOpenCodeRunLine(
JSON.stringify({
sessionID: 'ses_abc',
parts: [{ text: 'final answer' }],
}),
),
).toEqual(
expect.arrayContaining([
{ kind: 'session', sessionId: 'ses_abc' },
expect.objectContaining({
kind: 'assistant_completed',
content: 'final answer',
}),
]),
);
expect(normalizeOpenCodeRunLine('not json at all')).toContainEqual({
kind: 'status',
status: 'not json at all',
});
});
test('normalizes Claude Code stream-json output lines', () => {
expect(
normalizeClaudeJsonLine(
JSON.stringify({
type: 'system',
subtype: 'init',
session_id: 'abc',
}),
),
).toContainEqual({ kind: 'session', sessionId: 'abc' });
expect(
normalizeClaudeJsonLine(
JSON.stringify({
type: 'assistant',
message: {
content: [
{ type: 'text', text: 'hello ' },
{ type: 'text', text: 'world' },
],
},
}),
),
).toContainEqual({ kind: 'assistant_delta', content: 'hello world' });
expect(
normalizeClaudeJsonLine(
JSON.stringify({
type: 'assistant',
message: {
content: [
{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } },
],
},
}),
),
).toContainEqual({
kind: 'tool_started',
name: 'Bash',
input: JSON.stringify({ command: 'ls' }),
});
expect(
normalizeClaudeJsonLine(
JSON.stringify({
type: 'result',
subtype: 'success',
result: 'final text',
session_id: 'abc',
}),
),
).toContainEqual({ kind: 'assistant_completed', content: 'final text' });
expect(
normalizeClaudeJsonLine(
JSON.stringify({
type: 'result',
subtype: 'error_max_turns',
result: 'hit the turn limit',
}),
),
).toContainEqual({ kind: 'error', message: 'hit the turn limit' });
expect(normalizeClaudeJsonLine('not json at all')).toContainEqual({
kind: 'status',
status: 'not json at all',
});
});
});
@@ -0,0 +1,42 @@
import { describe, expect, test } from 'vitest';
import type { AgentRuntime } from '../../src/runtime/agent-runtime';
import type { Claim } from '../../src/runtime/provider';
import { getAdapter, registerAdapter } from '../../src/runtime/agent-runtime';
import { isCodexLoginProfile, opencodeModel } from '../../src/runtime/provider';
describe('agent runtime registry', () => {
test('registerAdapter/getAdapter resolves a registered runtime', () => {
const fake: AgentRuntime = {
name: 'codex',
prepareAuth: () => Promise.resolve(),
runTurn: () => Promise.resolve({}),
abort: () => Promise.resolve(),
};
registerAdapter('codex', () => fake);
expect(getAdapter('codex').name).toBe('codex');
});
test('getAdapter throws for an unregistered runtime', () => {
expect(() => getAdapter('opencode')).toThrow(/No agent runtime adapter/);
});
});
describe('shared provider helpers re-exported from runtime/provider', () => {
test('isCodexLoginProfile detects opencode login profiles', () => {
expect(
isCodexLoginProfile({
aiProviderProfile: { provider: 'opencode_openai_login' },
} as Claim),
).toBe(true);
expect(isCodexLoginProfile({} as Claim)).toBe(false);
});
test('opencodeModel prefixes the provider', () => {
expect(
opencodeModel({
aiProviderProfile: { provider: 'anthropic', model: 'claude-x' },
} as Claim),
).toBe('anthropic/claude-x');
});
});
@@ -0,0 +1,84 @@
import {
mkdir,
mkdtemp,
rm,
symlink,
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, test, vi } from 'vitest';
const tempDirs: string[] = [];
// Point boxHomePaths('t').homeDir at a real tmp home, then import box fresh so
// env.workdir (captured at import) reflects the tmp workdir for this test.
const loadWithHome = async () => {
const workdir = await mkdtemp(path.join(os.tmpdir(), 'spoon-box-'));
tempDirs.push(workdir);
vi.resetModules();
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----';
process.env.SPOON_AGENT_WORKDIR = workdir;
const box = await import('../../src/box');
const homeDir = box.boxHomePaths('t').homeDir;
await mkdir(homeDir, { recursive: true });
return { box, homeDir, workdir };
};
describe('box file helpers', () => {
afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { force: true, recursive: true })),
);
tempDirs.length = 0;
vi.resetModules();
});
test('writeBoxFile then readBoxFile round-trips', async () => {
const { box } = await loadWithHome();
await expect(
box.writeBoxFile('t', 'notes/todo.txt', 'hello box'),
).resolves.toEqual({ success: true });
await expect(box.readBoxFile('t', 'notes/todo.txt')).resolves.toBe(
'hello box',
);
});
test('readBoxFile rejects a lexical .. escape', async () => {
const { box } = await loadWithHome();
await expect(
box.readBoxFile('t', '../../etc/passwd'),
).rejects.toThrow(/outside home/);
});
test('rejects a symlink inside home that points outside on read and write', async () => {
const { box, homeDir, workdir } = await loadWithHome();
const outside = path.join(workdir, 'outside');
await mkdir(outside, { recursive: true });
await writeFile(path.join(outside, 'secret'), 'S');
await symlink(path.join(outside, 'secret'), path.join(homeDir, 'link'));
await expect(box.readBoxFile('t', 'link')).rejects.toThrow(/outside home/);
await expect(
box.writeBoxFile('t', 'link', 'nope'),
).rejects.toThrow();
});
test('listBoxTree includes a created file and excludes node_modules', async () => {
const { box, homeDir } = await loadWithHome();
await writeFile(path.join(homeDir, 'readme.md'), '# hi');
await mkdir(path.join(homeDir, 'node_modules', 'pkg'), { recursive: true });
await writeFile(path.join(homeDir, 'node_modules', 'pkg', 'x.js'), '1');
const tree = await box.listBoxTree('t');
expect(tree.name).toBe('~');
expect(tree.path).toBe('');
expect(tree.type).toBe('directory');
const names = (tree.children ?? []).map((child) => child.name);
expect(names).toContain('readme.md');
expect(names).not.toContain('node_modules');
});
});
@@ -0,0 +1,29 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
const load = async () => {
vi.resetModules();
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----';
process.env.SPOON_AGENT_WORKDIR = '/work';
return await import('../../src/box');
};
describe('boxHomePaths', () => {
afterEach(() => vi.resetModules());
test('derives the per-user home dir and container home', async () => {
const { boxHomePaths } = await load();
const paths = boxHomePaths('gib');
expect(paths.homeDir.endsWith('homes/gib')).toBe(true);
expect(paths.containerHome).toBe('/home/gib');
});
test('distinct usernames produce distinct home dirs', async () => {
const { boxHomePaths } = await load();
expect(boxHomePaths('gib').homeDir).not.toBe(
boxHomePaths('other').homeDir,
);
});
});
@@ -0,0 +1,75 @@
import { describe, expect, test } from 'vitest';
const loadDocker = async () => {
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----';
return await import('../../src/runtime/docker');
};
describe('in-box process-group marker + kill helpers', () => {
test('buildMarkedCommand wraps argv as a setsid bash session carrying the marker', async () => {
const { buildMarkedCommand } = await loadDocker();
const argv = buildMarkedCommand('spoon-turn-abc', [
'codex',
'exec',
'--json',
'hi',
]);
expect(argv[0]).toBe('setsid');
expect(argv[1]).toBe('bash');
expect(argv[2]).toBe('-lc');
const script = argv[3] ?? '';
expect(script).toContain('# spoon-turn-abc');
expect(script).toContain("'codex' 'exec' '--json' 'hi'");
});
test('buildMarkedCommand keeps a trailing exit after the CLI so bash survives tail-exec while propagating the exit code (C1 guard)', async () => {
const { buildMarkedCommand } = await loadDocker();
const script = buildMarkedCommand('spoon-turn-abc', ['codex', 'run'])[3] ?? '';
// The CLI must NOT be the final simple command (bash would tail-exec it,
// replacing the marker-carrying bash). The exit code must still propagate.
expect(script).toContain("'codex' 'run'\nrc=$?\nexit \"$rc\"");
expect(script.split('\n').at(-1)).toBe('exit "$rc"');
expect(script.split('\n').at(-1)).not.toBe("'codex' 'run'");
});
test('buildMarkedCommand rejects a marker that could break out of the comment', async () => {
const { buildMarkedCommand } = await loadDocker();
expect(() => buildMarkedCommand('bad\nmarker', ['codex'])).toThrow();
});
test('buildKillScript TERM-then-KILLs every group matching the marker', async () => {
const { buildKillScript } = await loadDocker();
const script = buildKillScript('spoon-turn-abc');
expect(script).toContain("pgrep -f 'spoon-turn-abc'");
expect(script).toContain('kill -TERM -"$pgid"');
expect(script).toContain('kill -KILL -"$pgid"');
});
test('buildKillScript excludes its own process group so it does not kill itself (C2 guard)', async () => {
const { buildKillScript } = await loadDocker();
const script = buildKillScript('spoon-turn-abc');
// Computes its own pgid and skips any match sharing it (the kill script's
// own bash + its command-substitution subshells all carry the marker).
expect(script).toContain('self_pgid=$(ps -o pgid= -p $$ | tr -d \' \')');
expect(script).toContain('[ "$pgid" = "$self_pgid" ] && continue');
});
test('buildKillScript escalates TERM -> KILL with a sleep 2 in between (order matters)', async () => {
const { buildKillScript } = await loadDocker();
const script = buildKillScript('spoon-turn-abc');
const termIdx = script.indexOf('kill -TERM -"$pgid"');
const sleepIdx = script.indexOf('\nsleep 2\n');
const killIdx = script.indexOf('kill -KILL -"$pgid"');
expect(termIdx).toBeGreaterThanOrEqual(0);
expect(sleepIdx).toBeGreaterThan(termIdx);
expect(killIdx).toBeGreaterThan(sleepIdx);
});
test('buildKillScript rejects a marker that could break out of the pgrep pattern', async () => {
const { buildKillScript } = await loadDocker();
expect(() => buildKillScript('bad\nmarker')).toThrow();
});
});
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
const load = async () => {
vi.resetModules();
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----';
process.env.SPOON_AGENT_WORKDIR = '/work';
return await import('../../src/server');
};
describe('boxRoute', () => {
afterEach(() => vi.resetModules());
test('matches the five box actions', async () => {
const { boxRoute } = await load();
expect(boxRoute('/box/status')).toEqual({ action: 'status' });
expect(boxRoute('/box/lifecycle')).toEqual({ action: 'lifecycle' });
expect(boxRoute('/box/tree')).toEqual({ action: 'tree' });
expect(boxRoute('/box/file')).toEqual({ action: 'file' });
expect(boxRoute('/box/set-password')).toEqual({ action: 'set-password' });
});
test('rejects unknown, nested, and trailing paths', async () => {
const { boxRoute } = await load();
expect(boxRoute('/box/other')).toBeNull();
expect(boxRoute('/box/file/extra')).toBeNull();
expect(boxRoute('/box/status/')).toBeNull();
expect(boxRoute('/box')).toBeNull();
expect(boxRoute('/jobs/x/file')).toBeNull();
});
});
@@ -0,0 +1,76 @@
import { createHmac } from 'node:crypto';
import { describe, expect, test } from 'vitest';
import {
parseBoxTokenUsername,
verifyBoxTerminalToken,
} from '../../src/terminal-token';
const mintBox = (username: string, expiresAt: number, secret: string) => {
const payload = `${expiresAt}.box.${username}`;
const sig = createHmac('sha256', secret).update(payload).digest('hex');
return `${payload}.${sig}`;
};
describe('verifyBoxTerminalToken', () => {
const secret = 'test-secret';
test('accepts a valid, unexpired, username-matched token', () => {
const token = mintBox('alice', Date.now() + 60_000, secret);
expect(verifyBoxTerminalToken(token, 'alice', secret)).toBe(true);
});
test('rejects an expired token', () => {
const token = mintBox('alice', Date.now() - 1, secret);
expect(verifyBoxTerminalToken(token, 'alice', secret)).toBe(false);
});
test('rejects a token minted for another username', () => {
const token = mintBox('alice', Date.now() + 60_000, secret);
expect(verifyBoxTerminalToken(token, 'bob', secret)).toBe(false);
});
test('rejects a token signed with a different secret', () => {
const token = mintBox('alice', Date.now() + 60_000, 'other-secret');
expect(verifyBoxTerminalToken(token, 'alice', secret)).toBe(false);
});
test('rejects malformed input and an empty secret', () => {
expect(verifyBoxTerminalToken('garbage', 'alice', secret)).toBe(false);
expect(verifyBoxTerminalToken('', 'alice', secret)).toBe(false);
expect(
verifyBoxTerminalToken(mintBox('alice', Date.now() + 1000, ''), 'alice', ''),
).toBe(false);
});
test('rejects a 3-part job token (cross-scheme confusion guard)', () => {
const payload = `${Date.now() + 60_000}.alice`;
const sig = createHmac('sha256', secret).update(payload).digest('hex');
const jobToken = `${payload}.${sig}`;
expect(verifyBoxTerminalToken(jobToken, 'alice', secret)).toBe(false);
});
});
describe('parseBoxTokenUsername', () => {
test('extracts the username from a well-formed box token', () => {
const token = mintBox('alice', Date.now() + 60_000, 'test-secret');
expect(parseBoxTokenUsername(token)).toBe('alice');
});
test('returns null for a 3-part job token', () => {
expect(parseBoxTokenUsername('123.alice.sig')).toBeNull();
});
test('returns null when the literal box segment is missing', () => {
expect(parseBoxTokenUsername('123.notbox.alice.sig')).toBeNull();
});
test('returns null for an empty username segment', () => {
expect(parseBoxTokenUsername('123.box..sig')).toBeNull();
});
test('returns null for garbage input', () => {
expect(parseBoxTokenUsername('garbage')).toBeNull();
expect(parseBoxTokenUsername('')).toBeNull();
});
});
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import {
buildBoxInitScript,
buildClearPasswordCommand,
buildSudoPolicyScript,
linuxUsername,
} from '../../src/runtime/box-user';
describe('linuxUsername', () => {
it('lowercases and passes through simple names', () => {
expect(linuxUsername('gabriel')).toBe('gabriel');
expect(linuxUsername('Gabriel')).toBe('gabriel');
});
it('replaces invalid characters with dashes', () => {
expect(linuxUsername('g@b r!el')).toBe('g-b-r-el');
});
it('prefixes names that do not start with [a-z_]', () => {
expect(linuxUsername('1337')).toBe('u1337');
expect(linuxUsername('-dash')).toBe('u-dash');
});
it('truncates to 32 chars and falls back to spoon', () => {
expect(linuxUsername('a'.repeat(40))).toHaveLength(32);
expect(linuxUsername('')).toBe('spoon');
expect(linuxUsername('@@@')).toBe('u---');
});
});
describe('buildBoxInitScript', () => {
const script = buildBoxInitScript({
username: 'gabriel',
home: '/home/Gabriel',
});
it('renames an existing uid-1000 entry instead of duplicating it', () => {
// podman --userns=keep-id injects a passwd entry named after the HOST user
// at the mapped uid; a second uid-1000 entry would win the getpwuid lookup
// and make whoami report the host username.
expect(script).toContain('getent passwd 1000');
expect(script).toContain("usermod -l 'gabriel'");
});
it('creates the user with uid 1000 at the given home when none exists', () => {
expect(script).toContain('useradd');
expect(script).toContain('-u 1000');
expect(script).toContain("-d '/home/Gabriel'");
expect(script).toContain('-s /bin/bash');
});
it('adds wheel membership', () => {
expect(script).toContain("usermod -aG wheel 'gabriel'");
});
it('chowns the home once, guarded by the marker', () => {
expect(script).toContain('/home/Gabriel/.spoon/chown-v1');
expect(script).toContain("chown -R 1000:1000 '/home/Gabriel'");
});
});
describe('buildSudoPolicyScript', () => {
it('installs a visudo-validated NOPASSWD drop-in when no password is set', () => {
const script = buildSudoPolicyScript({
username: 'gabriel',
passwordSet: false,
});
expect(script).toContain('gabriel ALL=(ALL) NOPASSWD:ALL');
expect(script).toContain('visudo -c');
expect(script).toContain('/etc/sudoers.d/spoon-box');
expect(script).toContain('chmod 0440');
});
it('removes the drop-in when a password is set', () => {
const script = buildSudoPolicyScript({
username: 'gabriel',
passwordSet: true,
});
expect(script).toContain('rm -f /etc/sudoers.d/spoon-box');
expect(script).not.toContain('NOPASSWD');
});
});
describe('password commands', () => {
it('clears via passwd -d', () => {
expect(buildClearPasswordCommand('gabriel')).toEqual([
'passwd',
'-d',
'gabriel',
]);
});
});
@@ -0,0 +1,192 @@
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { NormalizedAgentEvent } from '../../src/agent-events';
import type { ExecStreamFn } from '../../src/runtime/agent-runtime';
import type { AdapterWorkspace, Claim } from '../../src/runtime/provider';
const loadAdapter = async () => {
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----';
return await import('../../src/runtime/claude-adapter');
};
const claim = {
job: {
_id: 'job-1',
prompt: 'hi',
baseBranch: 'main',
workBranch: 'spoon/x',
forkOwner: 'o',
forkRepo: 'r',
upstreamOwner: 'u',
upstreamRepo: 'r',
},
spoon: { name: 'Spoon' },
openai: { model: 'claude-sonnet-4', reasoningEffort: 'medium' },
aiProviderProfile: {
id: 'p',
name: 'Anthropic',
provider: 'anthropic',
authType: 'api_key',
secret: 'sk-ant-test',
model: 'claude-sonnet-4',
reasoningEffort: 'medium',
},
github: {},
secrets: [],
} as unknown as Claim;
const makeWorkspace = (): AdapterWorkspace => ({
claim,
workdir: '/tmp/spoon-claude-adapter-test-missing',
homeDir: '/tmp/spoon-claude-adapter-test-missing',
username: 'tester',
containerHome: '/home/tester',
containerRepo: '/home/tester/Code/spoon',
repoDir: '/tmp/spoon-claude-adapter-test-missing/Code/spoon',
boxName: 'spoon-box-tester',
redact: (value: string) => value,
runtime: 'claude',
});
const makeOAuthWorkspace = (
homeDir: string,
authType: 'anthropic_oauth_json' | 'opencode_auth_json',
): AdapterWorkspace => ({
claim: {
...claim,
aiProviderProfile: {
id: 'p',
name: 'Anthropic',
provider: 'anthropic',
authType,
secret: JSON.stringify({ access_token: 'oauth-token' }),
model: 'claude-sonnet-4',
reasoningEffort: 'medium',
},
} as unknown as Claim,
workdir: homeDir,
homeDir,
username: 'tester',
containerHome: '/home/tester',
containerRepo: '/home/tester/Code/spoon',
repoDir: path.join(homeDir, 'Code/spoon'),
boxName: 'spoon-box-tester',
redact: (value: string) => value,
runtime: 'claude',
});
const tempDirs: string[] = [];
afterEach(async () => {
while (tempDirs.length) {
const dir = tempDirs.pop();
if (dir) await rm(dir, { recursive: true, force: true });
}
});
describe('ClaudeCodeAdapter prepareAuth', () => {
test('writes credentials.json for an anthropic_oauth_json profile', async () => {
const { createClaudeAdapter } = await loadAdapter();
const homeDir = await mkdtemp(path.join(tmpdir(), 'spoon-claude-oauth-'));
tempDirs.push(homeDir);
const adapter = createClaudeAdapter();
await adapter.prepareAuth(
makeOAuthWorkspace(homeDir, 'anthropic_oauth_json'),
);
const credentialsPath = path.join(homeDir, '.claude', '.credentials.json');
const contents = await readFile(credentialsPath, 'utf8');
expect(contents).toContain('oauth-token');
});
test('does not write credentials.json for an opencode_auth_json profile', async () => {
const { createClaudeAdapter } = await loadAdapter();
const homeDir = await mkdtemp(path.join(tmpdir(), 'spoon-claude-oauth-'));
tempDirs.push(homeDir);
const adapter = createClaudeAdapter();
await adapter.prepareAuth(
makeOAuthWorkspace(homeDir, 'opencode_auth_json'),
);
const credentialsPath = path.join(homeDir, '.claude', '.credentials.json');
await expect(stat(credentialsPath)).rejects.toThrow();
});
});
describe('ClaudeCodeAdapter', () => {
test('streams normalized events and marks the turn via buildMarkedCommand', async () => {
const { createClaudeAdapter } = await loadAdapter();
const captured: unknown[] = [];
const execStream = vi.fn(async (args) => {
captured.push(args.command);
await args.onStdoutLine?.(
JSON.stringify({
type: 'system',
subtype: 'init',
session_id: 'abc',
}),
);
await args.onStdoutLine?.(
JSON.stringify({
type: 'assistant',
message: { content: [{ type: 'text', text: 'hi there' }] },
}),
);
await args.onStdoutLine?.(
JSON.stringify({
type: 'result',
subtype: 'success',
result: 'final text',
session_id: 'abc',
}),
);
return { exitCode: 0, output: '' };
}) as unknown as ExecStreamFn;
const adapter = createClaudeAdapter({ execStream });
const events: NormalizedAgentEvent[] = [];
const onEvent = (event: NormalizedAgentEvent): Promise<void> => {
events.push(event);
return Promise.resolve();
};
const result = await adapter.runTurn(makeWorkspace(), 'do it', onEvent);
expect(events).toContainEqual({ kind: 'session', sessionId: 'abc' });
expect(events).toContainEqual({
kind: 'assistant_delta',
content: 'hi there',
});
expect(result.finalMessage).toBe('final text');
expect(result.sessionId).toBe('abc');
expect(result.error).toBeUndefined();
// The turn ran through buildMarkedCommand (setsid bash session) carrying the
// `claude -p` argv in the script.
const command = captured[0] as string[];
expect(command[0]).toBe('setsid');
expect(command[1]).toBe('bash');
expect(command[3]).toContain("'claude' '-p'");
expect(command[3]).toContain("'--output-format' 'stream-json'");
});
test('execs the turn as the box user, not root', async () => {
const { createClaudeAdapter } = await loadAdapter();
let capturedUser: string | undefined;
const execStream = vi.fn((args: { user?: string }) => {
capturedUser = args.user;
return Promise.resolve({ exitCode: 0, output: '' });
}) as unknown as ExecStreamFn;
const adapter = createClaudeAdapter({ execStream });
await adapter.runTurn(makeWorkspace(), 'do it', () => Promise.resolve());
expect(capturedUser).toBe('tester');
});
});
@@ -0,0 +1,73 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
const load = async () => {
vi.resetModules();
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----';
return await import('../../src/worker');
};
describe('planWorkdirCleanup', () => {
afterEach(() => vi.resetModules());
test('never removes the homes/ root or any home directory', async () => {
const { planWorkdirCleanup } = await load();
const plan = planWorkdirCleanup({
root: '/work',
rootEntries: [{ name: 'homes', isDirectory: true }],
homesEntries: { alice: [{ name: 'Code', isDirectory: true }] },
codeLeaves: {},
activeWorkdirs: new Set(),
runningBoxUsernames: new Set(),
});
expect(plan.removeDirs).not.toContain('/work/homes');
expect(plan.removeDirs).not.toContain('/work/homes/alice');
});
test('removes legacy top-level job dirs not in the active set', async () => {
const { planWorkdirCleanup } = await load();
const plan = planWorkdirCleanup({
root: '/work',
rootEntries: [
{ name: 'homes', isDirectory: true },
{ name: 'legacy-job-123', isDirectory: true },
{ name: 'dev', isDirectory: true },
],
homesEntries: {},
codeLeaves: {},
activeWorkdirs: new Set(['/work/dev']),
runningBoxUsernames: new Set(),
});
expect(plan.removeDirs).toContain('/work/legacy-job-123');
expect(plan.removeDirs).not.toContain('/work/dev');
expect(plan.removeDirs).not.toContain('/work/homes');
});
test('removes per-thread checkouts only for users with no running box', async () => {
const { planWorkdirCleanup } = await load();
const plan = planWorkdirCleanup({
root: '/work',
rootEntries: [{ name: 'homes', isDirectory: true }],
homesEntries: {
alice: [{ name: 'Code', isDirectory: true }],
bob: [{ name: 'Code', isDirectory: true }],
},
codeLeaves: {
alice: ['/work/homes/alice/Code/spoon-a/branch-x'],
bob: ['/work/homes/bob/Code/spoon-b/branch-y'],
},
activeWorkdirs: new Set(),
runningBoxUsernames: new Set(['bob']),
});
expect(plan.removeDirs).toContain(
'/work/homes/alice/Code/spoon-a/branch-x',
);
expect(plan.removeDirs).not.toContain(
'/work/homes/bob/Code/spoon-b/branch-y',
);
expect(plan.removeDirs).not.toContain('/work/homes/alice');
expect(plan.removeDirs).not.toContain('/work/homes/alice/Code');
});
});
@@ -0,0 +1,207 @@
import { mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { NormalizedAgentEvent } from '../../src/agent-events';
import type { ExecStreamFn } from '../../src/runtime/agent-runtime';
import type { AdapterWorkspace, Claim } from '../../src/runtime/provider';
const tempDirs: string[] = [];
const pathExists = async (filePath: string): Promise<boolean> => {
try {
await stat(filePath);
return true;
} catch {
return false;
}
};
const loadAdapter = async () => {
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----';
return await import('../../src/runtime/codex-adapter');
};
const claim = {
job: {
_id: 'job-1',
prompt: 'hi',
baseBranch: 'main',
workBranch: 'spoon/x',
forkOwner: 'o',
forkRepo: 'r',
upstreamOwner: 'u',
upstreamRepo: 'r',
},
spoon: { name: 'Spoon' },
openai: { model: 'gpt-5', reasoningEffort: 'medium' },
aiProviderProfile: {
id: 'p',
name: 'Codex',
provider: 'opencode_openai_login',
authType: 'opencode_auth_json',
model: 'gpt-5',
reasoningEffort: 'medium',
},
github: {},
secrets: [],
} as unknown as Claim;
const makeWorkspace = (): AdapterWorkspace => ({
claim,
// A non-existent workdir so the --output-last-message read is a clean ENOENT.
workdir: '/tmp/spoon-codex-adapter-test-missing',
homeDir: '/tmp/spoon-codex-adapter-test-missing',
username: 'tester',
containerHome: '/home/tester',
containerRepo: '/home/tester/Code/spoon',
repoDir: '/tmp/spoon-codex-adapter-test-missing/Code/spoon',
boxName: 'spoon-box-tester',
redact: (value: string) => value,
runtime: 'codex',
});
const makeAuthWorkspace = async (
profile: Record<string, unknown>,
): Promise<AdapterWorkspace> => {
const workdir = await mkdtemp(path.join(os.tmpdir(), 'spoon-codex-auth-'));
tempDirs.push(workdir);
const repoDir = path.join(workdir, 'Code', 'spoon');
await mkdir(repoDir, { recursive: true });
return {
...makeWorkspace(),
claim: { ...claim, aiProviderProfile: profile } as unknown as Claim,
workdir,
homeDir: workdir,
repoDir,
};
};
describe('CodexAdapter', () => {
afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { force: true, recursive: true })),
);
tempDirs.length = 0;
});
test('prepareAuth skips auth.json for an api_key profile', async () => {
const { createCodexAdapter } = await loadAdapter();
const adapter = createCodexAdapter();
const workspace = await makeAuthWorkspace({
id: 'p',
name: 'OpenAI',
provider: 'openai',
authType: 'api_key',
model: 'gpt-5',
// A raw API key, which is deliberately NOT valid JSON.
secret: 'sk-test',
});
await expect(adapter.prepareAuth?.(workspace)).resolves.toBeUndefined();
const codexAuthPath = path.join(workspace.workdir, '.codex', 'auth.json');
const openCodeAuthPath = path.join(
workspace.workdir,
'.local',
'share',
'opencode',
'auth.json',
);
await expect(pathExists(codexAuthPath)).resolves.toBe(false);
await expect(pathExists(openCodeAuthPath)).resolves.toBe(false);
});
test('prepareAuth writes auth.json for a ChatGPT-login profile', async () => {
const { createCodexAdapter } = await loadAdapter();
const adapter = createCodexAdapter();
const secret = JSON.stringify({ tokens: { access: 'abc' } });
const workspace = await makeAuthWorkspace({
id: 'p',
name: 'Codex',
provider: 'opencode_openai_login',
authType: 'opencode_auth_json',
model: 'gpt-5',
secret,
});
await adapter.prepareAuth?.(workspace);
const codexAuthPath = path.join(workspace.workdir, '.codex', 'auth.json');
await expect(readFile(codexAuthPath, 'utf8')).resolves.toBe(
`${JSON.stringify(JSON.parse(secret), null, 2)}\n`,
);
const openCodeAuthPath = path.join(
workspace.workdir,
'.local',
'share',
'opencode',
'auth.json',
);
await expect(readFile(openCodeAuthPath, 'utf8')).resolves.toBe(
`${JSON.stringify(JSON.parse(secret), null, 2)}\n`,
);
});
test('streams normalized events and marks the turn via buildMarkedCommand', async () => {
const { createCodexAdapter } = await loadAdapter();
const captured: unknown[] = [];
const execStream = vi.fn(async (args) => {
captured.push(args.command);
await args.onStdoutLine?.(
JSON.stringify({
type: 'item.completed',
item: { id: 'item-1', type: 'agent_message', text: 'done' },
}),
);
await args.onStdoutLine?.(JSON.stringify({ type: 'turn.completed' }));
return { exitCode: 0, output: '' };
}) as unknown as ExecStreamFn;
const adapter = createCodexAdapter({ execStream });
const events: NormalizedAgentEvent[] = [];
const onEvent = (event: NormalizedAgentEvent): Promise<void> => {
events.push(event);
return Promise.resolve();
};
const result = await adapter.runTurn(makeWorkspace(), 'do it', onEvent);
expect(events).toContainEqual({
kind: 'assistant_delta',
content: 'done\n\n',
externalMessageId: 'item-1',
});
expect(events).toContainEqual({ kind: 'assistant_completed' });
expect(result).toBeTypeOf('object');
// The turn ran through buildMarkedCommand (setsid bash session).
const command = captured[0] as string[];
expect(command[0]).toBe('setsid');
expect(command[1]).toBe('bash');
});
test('surfaces a turn.failed error while exiting zero', async () => {
const { createCodexAdapter } = await loadAdapter();
const execStream = vi.fn(async (args) => {
await args.onStdoutLine?.(
JSON.stringify({
type: 'turn.failed',
error: { message: 'boom' },
}),
);
return { exitCode: 0, output: '' };
}) as unknown as ExecStreamFn;
const adapter = createCodexAdapter({ execStream });
const result = await adapter.runTurn(makeWorkspace(), 'do it', () =>
Promise.resolve(),
);
expect(result.error).toContain('boom');
});
});
@@ -0,0 +1,37 @@
import { describe, expect, test, vi } from 'vitest';
import {
abortManagedCodexProcess,
managedCodexCommand,
} from '../../src/codex-process';
describe('managed Codex process', () => {
test('launches Codex in a job-specific process session', () => {
const command = managedCodexCommand({
jobId: 'job-1',
pidFile: '/home/alice/.codex/job-1.pid',
command: ['codex', 'exec', '--json', 'fix it'],
});
expect(command.slice(0, 2)).toEqual(['python3', '-c']);
expect(command).toContain('/home/alice/.codex/job-1.pid');
expect(command).toContain('job-1');
expect(command.slice(-4)).toEqual(['codex', 'exec', '--json', 'fix it']);
});
test('invokes an isolated in-box kill for the matching job', async () => {
const execute = vi.fn().mockResolvedValue({ exitCode: 0, output: '' });
await abortManagedCodexProcess({
jobId: 'job-1',
pidFile: '/home/alice/.codex/job-1.pid',
execute,
});
expect(execute).toHaveBeenCalledOnce();
const [command] = execute.mock.calls[0] as [string[]];
expect(command.slice(0, 2)).toEqual(['python3', '-c']);
expect(command).toContain('/home/alice/.codex/job-1.pid');
expect(command).toContain('job-1');
});
});
@@ -44,6 +44,65 @@ describe('Docker runtime', () => {
);
});
test('boxUsernsArgs maps the host user onto the box uid under podman', async () => {
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'podman';
const { boxUsernsArgs } = await loadVolumeSpec();
expect(boxUsernsArgs()).toEqual(['--userns=keep-id:uid=1000,gid=1000']);
});
test('boxUsernsArgs adds nothing under docker', async () => {
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'docker';
const { boxUsernsArgs } = await loadVolumeSpec();
expect(boxUsernsArgs()).toEqual([]);
});
test('SPOON_AGENT_BOX_USERNS overrides: value passes through, empty disables', async () => {
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'podman';
process.env.SPOON_AGENT_BOX_USERNS = 'host';
let { boxUsernsArgs } = await loadVolumeSpec();
expect(boxUsernsArgs()).toEqual(['--userns=host']);
process.env.SPOON_AGENT_BOX_USERNS = '';
({ boxUsernsArgs } = await loadVolumeSpec());
expect(boxUsernsArgs()).toEqual([]);
delete process.env.SPOON_AGENT_BOX_USERNS;
});
test('execArgv places -u after the workdir and before the container name', async () => {
const { execArgv } = await loadVolumeSpec();
expect(
execArgv({
containerName: 'spoon-box-gabriel',
command: ['ls', '-la'],
environment: { HOME: '/home/gabriel' },
containerCwd: '/home/gabriel',
user: 'gabriel',
}),
).toEqual([
'exec',
'-e',
'HOME=/home/gabriel',
'-w',
'/home/gabriel',
'-u',
'gabriel',
'spoon-box-gabriel',
'ls',
'-la',
]);
});
test('execArgv omits -u when no user is given', async () => {
const { execArgv } = await loadVolumeSpec();
const argv = execArgv({
containerName: 'c',
command: ['true'],
environment: {},
containerCwd: '/',
});
expect(argv).not.toContain('-u');
});
test('treats a spawn failure (no exitCode) as a non-zero exit, not empty success', async () => {
const { normalizeRunResult } = await loadVolumeSpec();
// This is what execa returns with `reject: false` when the runtime binary is
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
// exec-stream imports the docker runtime, whose env module requires these at
// import time — set them before the (hoist-free) dynamic import.
process.env.SPOON_WORKER_TOKEN ??= 'test-worker-token';
process.env.GITHUB_APP_ID ??= '123';
process.env.GITHUB_APP_PRIVATE_KEY ??= 'test-key';
const { parseUpgradeResponse } = await import('../../src/runtime/exec-stream');
describe('parseUpgradeResponse', () => {
it('reports incomplete until the header terminator arrives', () => {
expect(parseUpgradeResponse(Buffer.from('')).complete).toBe(false);
expect(
parseUpgradeResponse(Buffer.from('HTTP/1.1 101 UPGRADED\r\n')).complete,
).toBe(false);
expect(
parseUpgradeResponse(
Buffer.from('HTTP/1.1 101 UPGRADED\r\nConnection: Upgrade\r\n'),
).complete,
).toBe(false);
});
it('parses a 101 upgrade and preserves stream bytes after the headers', () => {
const payload = Buffer.concat([
Buffer.from(
'HTTP/1.1 101 UPGRADED\r\n' +
'Content-Type: application/vnd.docker.raw-stream\r\n' +
'Connection: Upgrade\r\n' +
'Upgrade: tcp\r\n' +
'\r\n',
),
Buffer.from([0x1b, 0x5b, 0x48, 0x00, 0xff]),
]);
const parsed = parseUpgradeResponse(payload);
expect(parsed).toMatchObject({ complete: true, statusCode: 101 });
if (!parsed.complete) throw new Error('unreachable');
expect([...parsed.remainder]).toEqual([0x1b, 0x5b, 0x48, 0x00, 0xff]);
});
it('parses a 200 stream response (docker without upgrade) as success-shaped', () => {
const parsed = parseUpgradeResponse(
Buffer.from('HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nhi'),
);
expect(parsed).toMatchObject({ complete: true, statusCode: 200 });
if (!parsed.complete) throw new Error('unreachable');
expect(parsed.remainder.toString('utf8')).toBe('hi');
});
it('parses error statuses with their body so callers can report them', () => {
const parsed = parseUpgradeResponse(
Buffer.from(
'HTTP/1.1 404 Not Found\r\nContent-Length: 26\r\n\r\n{"message":"no such exec"}',
),
);
expect(parsed).toMatchObject({ complete: true, statusCode: 404 });
if (!parsed.complete) throw new Error('unreachable');
expect(parsed.remainder.toString('utf8')).toBe(
'{"message":"no such exec"}',
);
});
it('returns statusCode 0 for an unparseable status line', () => {
const parsed = parseUpgradeResponse(Buffer.from('garbage\r\n\r\n'));
expect(parsed).toMatchObject({ complete: true, statusCode: 0 });
});
it('does not treat a bare LF-LF as a header terminator', () => {
expect(
parseUpgradeResponse(Buffer.from('HTTP/1.1 101 UPGRADED\n\n')).complete,
).toBe(false);
});
});
@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { createHeartbeatController } from '../../src/heartbeat-controller';
describe('heartbeat controller', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
test('does not overlap ticks while a heartbeat is in flight', async () => {
let resolveHeartbeat!: (value: { cancelRequested: boolean }) => void;
const heartbeat = vi.fn(
() =>
new Promise<{ cancelRequested: boolean }>((resolve) => {
resolveHeartbeat = resolve;
}),
);
const controller = createHeartbeatController({
intervalMs: 30_000,
heartbeat,
onCancel: vi.fn(),
onError: vi.fn(),
});
controller.start('job-1');
await vi.advanceTimersByTimeAsync(90_000);
expect(heartbeat).toHaveBeenCalledOnce();
resolveHeartbeat({ cancelRequested: false });
await Promise.resolve();
await vi.advanceTimersByTimeAsync(30_000);
expect(heartbeat).toHaveBeenCalledTimes(2);
controller.stop('job-1');
});
test('removes the timer before awaiting cancellation teardown', async () => {
let finishCancel!: () => void;
const onCancel = vi.fn(
() =>
new Promise<void>((resolve) => {
finishCancel = resolve;
}),
);
const heartbeat = vi.fn().mockResolvedValue({ cancelRequested: true });
const controller = createHeartbeatController({
intervalMs: 30_000,
heartbeat,
onCancel,
onError: vi.fn(),
});
controller.start('job-1');
await vi.advanceTimersByTimeAsync(30_000);
expect(onCancel).toHaveBeenCalledOnce();
expect(controller.has('job-1')).toBe(false);
await vi.advanceTimersByTimeAsync(90_000);
expect(heartbeat).toHaveBeenCalledOnce();
finishCancel();
});
});
@@ -0,0 +1,160 @@
import { getFunctionName } from 'convex/server';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
const mocks = vi.hoisted(() => ({
getWorktreeDiff: vi.fn(() =>
Promise.resolve({ exitCode: 0, output: 'unexpected diff' }),
),
mutation: vi.fn(),
streamExecInContainer: vi.fn(),
}));
vi.mock('convex/browser', () => ({
ConvexHttpClient: vi.fn(
class ConvexHttpClient {
mutation = mocks.mutation;
},
),
}));
vi.mock('../../src/env', () => ({
env: {
buildCreatedAt: 'test',
buildSha: 'test',
convexUrl: 'http://convex.test',
jobTimeoutMs: 1000,
runtime: 'docker',
workerId: 'worker-1',
workerToken: 'token',
},
}));
vi.mock('../../src/git', () => ({
cloneRepository: vi.fn(),
commitAndPush: vi.fn(),
getStatus: vi.fn(),
getWorktreeDiff: mocks.getWorktreeDiff,
run: vi.fn(),
}));
vi.mock('../../src/github', () => ({
getInstallationToken: vi.fn(),
openDraftPullRequest: vi.fn(),
}));
vi.mock('../../src/runtime/docker', () => ({
buildMarkedCommand: vi.fn(() => ['setsid', 'bash', '-lc', '# marker']),
killBoxProcessesByMarker: vi.fn(() => Promise.resolve()),
listWorkspaceContainerNames: vi.fn(() => Promise.resolve([])),
runExecInContainer: vi.fn(),
streamExecInContainer: mocks.streamExecInContainer,
}));
vi.mock('../../src/user-container', () => ({
acquireUserBox: vi.fn(),
runningBoxUsernames: vi.fn(() => new Set()),
}));
vi.mock('../../src/user-environment', () => ({
fetchUserEnvironment: vi.fn(),
materializeUserHome: vi.fn(),
}));
const decision = {
decision: 'sync',
risk: 'low',
summary: 'Safe to sync.',
ignoredCommitShas: [],
ignoredReason: '',
recommendedAction: 'Sync upstream.',
requiresUserApproval: false,
};
const load = async () => await import('../../src/worker');
afterEach(async () => {
const worker = await load();
worker._resetActiveWorkspacesForTests();
vi.restoreAllMocks();
vi.clearAllMocks();
});
describe('maintenance workspace cleanup', () => {
test('releases and removes the workspace when status teardown fails', async () => {
const worker = await load();
const release = vi.fn();
const jobId = 'maintenance-mark';
mocks.mutation.mockImplementation((reference) => {
if (getFunctionName(reference) === 'agentJobs:markWorkspaceStopped') {
return Promise.reject(new Error('status teardown failed'));
}
return Promise.resolve('message-1');
});
mocks.streamExecInContainer.mockImplementation(async (args) => {
await args.onStdoutLine(
JSON.stringify({
type: 'item.completed',
item: {
type: 'agent_message',
text: JSON.stringify(decision),
},
}),
);
return { exitCode: 0, output: '' };
});
worker._setActiveWorkspaceForTests(jobId, {
claim: {
job: {
_id: jobId as Id<'agentJobs'>,
prompt: 'Review maintenance changes',
jobType: 'maintenance_review',
baseBranch: 'main',
workBranch: 'spoon/maintenance',
forkOwner: 'team',
forkRepo: 'spoon',
upstreamOwner: 'upstream',
upstreamRepo: 'spoon',
},
spoon: { name: 'Spoon' },
openai: {
model: 'gpt-5',
reasoningEffort: 'medium',
},
aiProviderProfile: {
id: 'profile-1',
name: 'Codex',
provider: 'opencode_openai_login',
authType: 'opencode_auth_json',
model: 'gpt-5',
reasoningEffort: 'medium',
},
github: {},
secrets: [],
},
workdir: '/tmp/workspace',
homeDir: '/tmp/workspace',
username: 'tester',
containerHome: '/home/tester',
containerRepo: '/home/tester/Code/spoon',
repoDir: '/tmp/workspace/Code/spoon',
boxName: 'spoon-box-tester',
boxHandle: { boxName: 'spoon-box-tester', release },
githubToken: 'github-token',
redact: (value: string) => value,
runtime: 'codex',
});
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
await expect(
worker.sendWorkspaceMessage(jobId, 'Review maintenance changes'),
).resolves.toBeUndefined();
expect(release).toHaveBeenCalledTimes(1);
expect(mocks.getWorktreeDiff).not.toHaveBeenCalled();
expect(
mocks.mutation.mock.calls.some(([, args]) => args.kind === 'diff'),
).toBe(false);
await expect(worker.stopWorkspace(jobId)).rejects.toThrow(
'Agent workspace is not active on this worker.',
);
expect(consoleError).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,86 @@
import {
access,
chmod,
mkdtemp,
readFile,
rm,
stat,
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, test } from 'vitest';
import {
removeMaterializedEnv,
writeMaterializedEnv,
} from '../../src/materialize-env';
const tempDirs: string[] = [];
const mode = async (filePath: string) => (await stat(filePath)).mode & 0o777;
const exists = async (filePath: string) =>
access(filePath).then(
() => true,
() => false,
);
describe('materialize env', () => {
afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { force: true, recursive: true })),
);
tempDirs.length = 0;
});
test('writes the env file 0600 at the returned path with quoted values', async () => {
const repoDir = await mkdtemp(path.join(os.tmpdir(), 'spoon-env-'));
tempDirs.push(repoDir);
const envPath = await writeMaterializedEnv(repoDir, '.env.local', [
{ name: 'A', value: 'x' },
]);
await expect(exists(envPath)).resolves.toBe(true);
await expect(mode(envPath)).resolves.toBe(0o600);
await expect(readFile(envPath, 'utf8')).resolves.toBe('A="x"\n');
});
test('tightens perms to 0600 and overwrites content when the env file pre-exists 0644', async () => {
const repoDir = await mkdtemp(path.join(os.tmpdir(), 'spoon-env-'));
tempDirs.push(repoDir);
const preExisting = path.join(repoDir, '.env.local');
// Pre-create the target with loose perms. umask may mask the writeFile
// mode, so chmod explicitly and assert the precondition really is 0644.
await writeFile(preExisting, 'OLD', { mode: 0o644 });
await chmod(preExisting, 0o644);
await expect(mode(preExisting)).resolves.toBe(0o644);
const envPath = await writeMaterializedEnv(repoDir, '.env.local', [
{ name: 'A', value: 'x' },
]);
// Overwrote the plaintext content AND tightened perms unconditionally.
await expect(mode(envPath)).resolves.toBe(0o600);
await expect(readFile(envPath, 'utf8')).resolves.toBe('A="x"\n');
});
test('removeMaterializedEnv deletes an existing file and no-ops when absent', async () => {
const repoDir = await mkdtemp(path.join(os.tmpdir(), 'spoon-env-'));
tempDirs.push(repoDir);
const envPath = await writeMaterializedEnv(repoDir, '.env.local', [
{ name: 'A', value: 'x' },
]);
await expect(exists(envPath)).resolves.toBe(true);
await removeMaterializedEnv(envPath);
await expect(exists(envPath)).resolves.toBe(false);
// No-ops when the file is already gone or the path is undefined.
await expect(removeMaterializedEnv(envPath)).resolves.toBeUndefined();
await expect(removeMaterializedEnv(undefined)).resolves.toBeUndefined();
});
});
@@ -0,0 +1,100 @@
import { describe, expect, test, vi } from 'vitest';
import type { NormalizedAgentEvent } from '../../src/agent-events';
import type { ExecStreamFn } from '../../src/runtime/agent-runtime';
import type { AdapterWorkspace, Claim } from '../../src/runtime/provider';
const loadAdapter = async () => {
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----';
return await import('../../src/runtime/opencode-adapter');
};
const claim = {
job: {
_id: 'job-1',
prompt: 'hi',
baseBranch: 'main',
workBranch: 'spoon/x',
forkOwner: 'o',
forkRepo: 'r',
upstreamOwner: 'u',
upstreamRepo: 'r',
},
spoon: { name: 'Spoon' },
openai: { model: 'gpt-5', reasoningEffort: 'medium' },
aiProviderProfile: {
id: 'p',
name: 'OpenAI',
provider: 'openai',
authType: 'api_key',
secret: 'sk-test',
model: 'gpt-5',
reasoningEffort: 'medium',
},
github: {},
secrets: [],
} as unknown as Claim;
const makeWorkspace = (): AdapterWorkspace => ({
claim,
workdir: '/tmp/spoon-opencode-adapter-test-missing',
homeDir: '/tmp/spoon-opencode-adapter-test-missing',
username: 'tester',
containerHome: '/home/tester',
containerRepo: '/home/tester/Code/spoon',
repoDir: '/tmp/spoon-opencode-adapter-test-missing/Code/spoon',
boxName: 'spoon-box-tester',
redact: (value: string) => value,
runtime: 'opencode',
});
describe('OpenCodeAdapter', () => {
test('streams normalized events and marks the turn via buildMarkedCommand', async () => {
const { createOpenCodeAdapter } = await loadAdapter();
const captured: unknown[] = [];
const execStream = vi.fn(async (args) => {
captured.push(args.command);
await args.onStdoutLine?.(
JSON.stringify({
type: 'message.part.delta',
properties: { part: { text: 'hi' }, messageID: 'm1' },
}),
);
await args.onStdoutLine?.(
JSON.stringify({
sessionID: 'ses_abc',
parts: [{ text: 'final answer' }],
}),
);
return { exitCode: 0, output: '' };
}) as unknown as ExecStreamFn;
const adapter = createOpenCodeAdapter({ execStream });
const events: NormalizedAgentEvent[] = [];
const onEvent = (event: NormalizedAgentEvent): Promise<void> => {
events.push(event);
return Promise.resolve();
};
const result = await adapter.runTurn(makeWorkspace(), 'do it', onEvent);
expect(events).toContainEqual({
kind: 'assistant_delta',
content: 'hi',
externalMessageId: 'm1',
});
expect(result).toBeTypeOf('object');
expect(result.sessionId).toBe('ses_abc');
expect(result.finalMessage).toBe('final answer');
// The turn ran through buildMarkedCommand (setsid bash session) carrying the
// opencode run argv in the script.
const command = captured[0] as string[];
expect(command[0]).toBe('setsid');
expect(command[1]).toBe('bash');
expect(command[3]).toContain("'opencode' 'run' '--format' 'json'");
});
});
@@ -0,0 +1,54 @@
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, test } from 'vitest';
import { assertContainedRealPath } from '../../src/path-containment';
let dir: string;
afterEach(async () => {
if (dir) await rm(dir, { recursive: true, force: true });
});
describe('assertContainedRealPath', () => {
test('allows a normal file inside the root', async () => {
dir = await mkdtemp(path.join(tmpdir(), 'spoon-'));
const root = path.join(dir, 'repo');
await mkdir(root, { recursive: true });
await writeFile(path.join(root, 'a.txt'), 'hi');
await expect(assertContainedRealPath(root, 'a.txt')).resolves.toBe(
path.join(root, 'a.txt'),
);
});
test('rejects a lexical .. escape', async () => {
dir = await mkdtemp(path.join(tmpdir(), 'spoon-'));
const root = path.join(dir, 'repo');
await mkdir(root, { recursive: true });
await expect(assertContainedRealPath(root, '../secret')).rejects.toThrow();
});
test('rejects reading through a symlink that escapes the root', async () => {
dir = await mkdtemp(path.join(tmpdir(), 'spoon-'));
const root = path.join(dir, 'repo');
const outside = path.join(dir, 'outside');
await mkdir(root, { recursive: true });
await mkdir(outside, { recursive: true });
await writeFile(path.join(outside, 'secret'), 'S');
await symlink(outside, path.join(root, 'link'));
await expect(
assertContainedRealPath(root, 'link/secret'),
).rejects.toThrow();
});
test('rejects a write whose final target is a symlink', async () => {
dir = await mkdtemp(path.join(tmpdir(), 'spoon-'));
const root = path.join(dir, 'repo');
await mkdir(root, { recursive: true });
await symlink(path.join(dir, 'evil'), path.join(root, 'out'));
await expect(
assertContainedRealPath(root, 'out', { forWrite: true }),
).rejects.toThrow();
});
});
@@ -0,0 +1,38 @@
import { describe, expect, test } from 'vitest';
import { createRedactor } from '../../src/redact';
describe('createRedactor', () => {
test('redacts group-less token patterns without emitting a literal $1', () => {
const redact = createRedactor([]);
const output = redact('token ghs_ABC123 and sk-xyz789 here');
expect(output).toContain('[redacted]');
expect(output).not.toContain('$1');
expect(output).not.toContain('ghs_ABC123');
expect(output).not.toContain('sk-xyz789');
});
test('redacts github_pat_ tokens to [redacted]', () => {
const redact = createRedactor([]);
const output = redact('github_pat_ABCDEF123456');
expect(output).toBe('[redacted]');
expect(output).not.toContain('$1');
});
test('keeps the key= prefix for key=value secrets', () => {
const redact = createRedactor([]);
expect(redact('api_key=secret')).toBe('api_key=[redacted]');
expect(redact('password=hunter2')).toBe('password=[redacted]');
});
test('redacts caller-supplied secret values verbatim', () => {
const redact = createRedactor(['super-secret-value']);
expect(redact('the value is super-secret-value')).toBe(
'the value is [redacted]',
);
});
});
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
const load = async () => {
vi.resetModules();
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----';
vi.doMock('../../src/worker', () => ({ getTerminalWorkspace: () => null }));
vi.doMock('../../src/user-container', () => ({
acquireUserBox: vi.fn(),
}));
return await import('../../src/terminal');
};
describe('parseResizeMessage', () => {
afterEach(() => vi.resetModules());
test('parses and clamps a resize control frame', async () => {
const { parseResizeMessage } = await load();
const msg = Buffer.from(
JSON.stringify({ type: 'resize', cols: 120, rows: 40 }),
);
expect(parseResizeMessage(msg, false)).toEqual({ cols: 120, rows: 40 });
});
test('returns null for binary input (raw keystrokes)', async () => {
const { parseResizeMessage } = await load();
expect(parseResizeMessage(Buffer.from([1, 2, 3]), true)).toBeNull();
});
test('returns null for non-resize JSON', async () => {
const { parseResizeMessage } = await load();
expect(
parseResizeMessage(Buffer.from('{"type":"other"}'), false),
).toBeNull();
});
test('clamps out-of-range dimensions into [1,1000]', async () => {
const { parseResizeMessage } = await load();
const msg = Buffer.from(
JSON.stringify({ type: 'resize', cols: 99999, rows: 0 }),
);
expect(parseResizeMessage(msg, false)).toEqual({ cols: 1000, rows: 1 });
});
});
describe('buildTerminalShellCommand', () => {
test('embeds the connection marker without exec-ing the shell away', async () => {
const { buildTerminalShellCommand } = await load();
const command = buildTerminalShellCommand('spoon-term-abc-123');
expect(command[0]).toBe('/bin/bash');
const script = command[2] ?? '';
// Marker must stay in the wrapper's argv for pgrep-based cleanup, so the
// inner shell/tmux must NOT be exec'd (exec would replace the argv).
expect(script).toContain('# spoon-term-abc-123');
expect(script).not.toContain('exec tmux');
expect(script).not.toContain('exec bash');
expect(script).toContain('tmux new-session -A -s spoon');
});
});
@@ -0,0 +1,94 @@
import { describe, expect, test } from 'vitest';
import {
resolveTurnOutcome,
shouldAppendCompletedContent,
} from '../../src/runtime/turn-outcome';
describe('shouldAppendCompletedContent', () => {
test('does not re-append when deltas already streamed the answer', () => {
// Claude/OpenCode emit the final answer as assistant_delta AND as the
// content-bearing assistant_completed event. Folding the completed
// content in again would produce "<answer><answer>".
expect(shouldAppendCompletedContent('the answer', 'the answer')).toBe(
false,
);
});
test('appends completed content when nothing has streamed yet', () => {
expect(shouldAppendCompletedContent('', 'the final result')).toBe(true);
});
test('treats whitespace-only streamed text as empty', () => {
expect(shouldAppendCompletedContent(' \n', 'the final result')).toBe(
true,
);
});
test('does not append when the completed event carries no content', () => {
expect(shouldAppendCompletedContent('the answer', undefined)).toBe(false);
expect(shouldAppendCompletedContent('', undefined)).toBe(false);
expect(shouldAppendCompletedContent('', '')).toBe(false);
});
});
describe('resolveTurnOutcome', () => {
test('partial streamed text + error is surfaced as failed and text preserved', () => {
const outcome = resolveTurnOutcome({
assistantText: 'partial answer before crash',
error: 'exit code 1: rate limited',
runtime: 'codex',
});
expect(outcome.text).toBe('partial answer before crash');
expect(outcome.failure).toBe('codex failed:\nexit code 1: rate limited');
});
test('successful turn with streamed text and no error has no failure', () => {
const outcome = resolveTurnOutcome({
assistantText: 'complete answer',
runtime: 'codex',
});
expect(outcome.text).toBe('complete answer');
expect(outcome.failure).toBeUndefined();
});
test('empty turn with error is surfaced as failed', () => {
const outcome = resolveTurnOutcome({
assistantText: ' ',
error: 'exit code 137',
runtime: 'claude',
});
expect(outcome.failure).toBe('claude failed:\nexit code 137');
});
test('empty turn with no error reports the no-response failure', () => {
const outcome = resolveTurnOutcome({
assistantText: '',
runtime: 'codex',
});
expect(outcome.failure).toBe(
'Codex completed without producing an assistant response.',
);
});
test('recovers finalMessage text when nothing streamed and no error', () => {
const outcome = resolveTurnOutcome({
assistantText: '',
recoveredText: 'recovered from last-message file',
runtime: 'codex',
});
expect(outcome.text).toBe('recovered from last-message file');
expect(outcome.failure).toBeUndefined();
});
test('recovered finalMessage text + error is still surfaced as failed', () => {
const outcome = resolveTurnOutcome({
assistantText: '',
recoveredText: 'recovered partial',
error: 'network drop',
runtime: 'codex',
});
expect(outcome.text).toBe('recovered partial');
expect(outcome.failure).toBe('codex failed:\nnetwork drop');
});
});
@@ -0,0 +1,221 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
vi.mock('../../src/env', () => ({ env: { boxIdleMs: 1000 } }));
vi.mock('../../src/runtime/docker', () => ({
ensureUserContainer: vi.fn((args: { username: string }) =>
Promise.resolve(`spoon-box-${args.username}`),
),
stopWorkspaceContainer: vi.fn(() => Promise.resolve()),
userContainerName: (username: string) => `spoon-box-${username}`,
listWorkspaceContainerNames: vi.fn(() => Promise.resolve([])),
}));
const load = async () => await import('../../src/user-container');
describe('box registry', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(async () => {
const module = await load();
module._resetBoxRegistryForTests();
vi.useRealTimers();
vi.resetModules();
vi.clearAllMocks();
});
test('passes an onCreated hook so a fresh box gets its user initialized', async () => {
const module = await load();
const docker = await import('../../src/runtime/docker');
await module.acquireUserBox({
username: 'ada',
workdir: '/w',
containerHome: '/home/ada',
});
const call = vi.mocked(docker.ensureUserContainer).mock.calls[0]?.[0] as {
onCreated?: unknown;
};
expect(call.onCreated).toBeTypeOf('function');
});
test('re-ensures the container on every acquire so an externally removed box self-heals', async () => {
const module = await load();
const docker = await import('../../src/runtime/docker');
const first = await module.acquireUserBox({
username: 'zoe',
workdir: '/w',
containerHome: '/home/zoe',
});
first.release();
// Simulate `docker rm` behind the worker's back: the registry entry still
// exists, but acquire must go back to the runtime rather than trust it.
const second = await module.acquireUserBox({
username: 'zoe',
workdir: '/w',
containerHome: '/home/zoe',
});
expect(docker.ensureUserContainer).toHaveBeenCalledTimes(2);
expect(second.boxName).toBe('spoon-box-zoe');
});
test('serializes concurrent acquires so ensure calls never overlap', async () => {
const module = await load();
const docker = await import('../../src/runtime/docker');
let inFlight = 0;
vi.mocked(docker.ensureUserContainer).mockImplementation(
async (args: { username: string }) => {
inFlight += 1;
expect(inFlight).toBe(1);
await Promise.resolve();
inFlight -= 1;
return `spoon-box-${args.username}`;
},
);
const [a, b] = await Promise.all([
module.acquireUserBox({
username: 'alice',
workdir: '/w',
containerHome: '/home/alice',
}),
module.acquireUserBox({
username: 'alice',
workdir: '/w',
containerHome: '/home/alice',
}),
]);
expect(a.boxName).toBe('spoon-box-alice');
a.release();
await vi.advanceTimersByTimeAsync(2000);
expect(docker.stopWorkspaceContainer).not.toHaveBeenCalled();
b.release();
await vi.advanceTimersByTimeAsync(2000);
expect(docker.stopWorkspaceContainer).toHaveBeenCalledTimes(1);
});
test('double-release of one handle does not reap a box held elsewhere', async () => {
const module = await load();
const docker = await import('../../src/runtime/docker');
const a = await module.acquireUserBox({
username: 'bob',
workdir: '/w',
containerHome: '/home/bob',
});
const b = await module.acquireUserBox({
username: 'bob',
workdir: '/w',
containerHome: '/home/bob',
});
a.release();
a.release();
await vi.advanceTimersByTimeAsync(2000);
expect(docker.stopWorkspaceContainer).not.toHaveBeenCalled();
b.release();
await vi.advanceTimersByTimeAsync(2000);
expect(docker.stopWorkspaceContainer).toHaveBeenCalledTimes(1);
});
test('reconcileExistingBoxes adopts orphans with zero refs and reaps them', async () => {
const module = await load();
const docker = await import('../../src/runtime/docker');
(
docker.listWorkspaceContainerNames as unknown as ReturnType<typeof vi.fn>
).mockResolvedValueOnce(['spoon-box-carol']);
await module.reconcileExistingBoxes();
expect(module.runningBoxUsernames().has('carol')).toBe(true);
await vi.advanceTimersByTimeAsync(2000);
expect(docker.stopWorkspaceContainer).toHaveBeenCalledWith(
'spoon-box-carol',
);
});
test('acquiring an adopted box ensures it and returns valid handles', async () => {
const module = await load();
const docker = await import('../../src/runtime/docker');
vi.mocked(docker.listWorkspaceContainerNames).mockResolvedValueOnce([
'spoon-box-casey',
]);
await module.reconcileExistingBoxes();
const first = await module.acquireUserBox({
username: 'casey',
workdir: '/w',
containerHome: '/home/casey',
});
const second = await module.acquireUserBox({
username: 'casey',
workdir: '/w',
containerHome: '/home/casey',
});
expect(docker.ensureUserContainer).toHaveBeenCalled();
expect(first.boxName).toBe('spoon-box-casey');
expect(second.boxName).toBe('spoon-box-casey');
});
test('retries ensure after the first acquire fails', async () => {
const module = await load();
const docker = await import('../../src/runtime/docker');
const ensure = vi.mocked(docker.ensureUserContainer);
ensure
.mockRejectedValueOnce(new Error('docker failed'))
.mockResolvedValueOnce('spoon-box-dana');
await expect(
module.acquireUserBox({
username: 'dana',
workdir: '/w',
containerHome: '/home/dana',
}),
).rejects.toThrow('docker failed');
const handle = await module.acquireUserBox({
username: 'dana',
workdir: '/w',
containerHome: '/home/dana',
});
expect(ensure).toHaveBeenCalledTimes(2);
expect(handle.boxName).toBe('spoon-box-dana');
});
test('finishes an old idle reap before a new acquire can ensure the box', async () => {
const module = await load();
const docker = await import('../../src/runtime/docker');
const ensure = vi.mocked(docker.ensureUserContainer);
const stop = vi.mocked(docker.stopWorkspaceContainer);
const events: string[] = [];
let finishStop!: () => void;
const stopDone = new Promise<void>((resolve) => {
finishStop = resolve;
});
ensure.mockReset().mockImplementation((args: { username: string }) => {
events.push('ensure');
return Promise.resolve(`spoon-box-${args.username}`);
});
stop.mockReset().mockImplementationOnce(() => {
events.push('stop:start');
return stopDone.then(() => {
events.push('stop:end');
});
});
const first = await module.acquireUserBox({
username: 'erin',
workdir: '/w',
containerHome: '/home/erin',
});
first.release();
await vi.advanceTimersByTimeAsync(1000);
const secondPromise = module.acquireUserBox({
username: 'erin',
workdir: '/w',
containerHome: '/home/erin',
});
await Promise.resolve();
await Promise.resolve();
expect(events).toEqual(['ensure', 'stop:start']);
finishStop();
const second = await secondPromise;
expect(second.boxName).toBe('spoon-box-erin');
expect(events).toEqual(['ensure', 'stop:start', 'stop:end', 'ensure']);
});
});
@@ -0,0 +1,62 @@
import { describe, expect, test, vi } from 'vitest';
import { abortManagedCodexProcess } from '../../src/codex-process';
import { teardownWorkspace } from '../../src/workspace-teardown';
describe('workspace teardown', () => {
test('event failure does not skip abort, stop, removal, or release', async () => {
const calls: string[] = [];
const record = (name: string) => vi.fn(() => calls.push(name));
const result = await teardownWorkspace({
stopHeartbeat: record('heartbeat'),
removeActive: record('remove'),
appendEvent: vi.fn().mockRejectedValue(new Error('event failed')),
abortAgent: record('abort'),
markStopped: vi.fn().mockRejectedValue(new Error('mutation failed')),
closeAgent: record('close'),
stopContainer: vi.fn().mockRejectedValue(new Error('container failed')),
release: record('release'),
});
expect(calls).toEqual(['heartbeat', 'remove', 'abort', 'close', 'release']);
expect(result.errors).toHaveLength(3);
});
test('removes active state before asynchronous cancellation work', async () => {
const active = new Set(['job-1']);
let activeDuringAbort = true;
await teardownWorkspace({
stopHeartbeat: vi.fn(),
removeActive: () => active.delete('job-1'),
abortAgent: () => {
activeDuringAbort = active.has('job-1');
},
release: vi.fn(),
});
expect(activeDuringAbort).toBe(false);
expect(active.has('job-1')).toBe(false);
});
test('Codex cancellation invokes isolated kill and leaves no active workspace', async () => {
const active = new Set(['job-1']);
const execute = vi.fn().mockResolvedValue({ exitCode: 0, output: '' });
await teardownWorkspace({
stopHeartbeat: vi.fn(),
removeActive: () => active.delete('job-1'),
abortAgent: async () =>
await abortManagedCodexProcess({
jobId: 'job-1',
pidFile: '/home/alice/.codex/job-1.pid',
execute,
}),
release: vi.fn(),
});
expect(execute).toHaveBeenCalledOnce();
expect(active.has('job-1')).toBe(false);
});
});
@@ -20,7 +20,11 @@ type Provider =
| 'cloudflare_ai_gateway'
| 'custom_openai_compatible'
| 'opencode_openai_login';
type AuthType = 'api_key' | 'opencode_auth_json' | 'none';
type AuthType =
| 'api_key'
| 'opencode_auth_json'
| 'anthropic_oauth_json'
| 'none';
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
type ExistingProfile = {
@@ -174,6 +178,7 @@ export const AiProviderProfileForm = ({
options={[
{ label: 'API key', value: 'api_key' },
{ label: 'Codex auth JSON', value: 'opencode_auth_json' },
{ label: 'Anthropic OAuth JSON', value: 'anthropic_oauth_json' },
{ label: 'None', value: 'none' },
]}
value={authType}
@@ -186,10 +191,20 @@ export const AiProviderProfileForm = ({
agent workspaces for Codex CLI runs.
</Text>
) : null}
{authType === 'anthropic_oauth_json' ? (
<Text className='text-muted-foreground text-sm leading-5'>
Paste the credentials JSON from your Claude auth folder, for example
~/.claude/.credentials.json, and paste it here. Spoon writes it into
isolated agent workspaces for Claude Code CLI runs.
</Text>
) : null}
{authType !== 'none' ? (
<Field
label={authType === 'api_key' ? 'API key' : 'Auth JSON'}
multiline={authType === 'opencode_auth_json'}
multiline={
authType === 'opencode_auth_json' ||
authType === 'anthropic_oauth_json'
}
secureTextEntry={authType === 'api_key'}
value={secret}
onChangeText={setSecret}
-7
View File
@@ -1,7 +0,0 @@
import { redirect } from 'next/navigation';
const AgentsRedirectPage = () => {
redirect('/threads?source=user_request');
};
export default AgentsRedirectPage;
+47 -13
View File
@@ -3,27 +3,38 @@
import Link from 'next/link';
import { MetricCard } from '@/components/dashboard/metric-card';
import { SpoonCard } from '@/components/spoons/spoon-card';
import { ListSkeleton } from '@/components/ui/list-skeleton';
import { MaintenanceQueue } from '@/components/threads/maintenance-queue';
import { useQuery } from 'convex/react';
import { GitBranch, MessageSquare, RefreshCw, ShieldCheck } from 'lucide-react';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Skeleton,
} from '@spoon/ui';
const DashboardPage = () => {
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
const activeSpoons = spoons.filter(
const spoons = useQuery(api.spoons.listMineWithState, {});
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 });
const threads = useQuery(api.threads.listMine, { limit: 25 });
const spoonList = spoons ?? [];
const threadList = threads ?? [];
const metricsLoading = spoons === undefined || threads === undefined;
const activeSpoons = spoonList.filter(
(spoon) => spoon.status === 'active',
).length;
const behind = spoons.filter(
const behind = spoonList.filter(
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy === 0,
).length;
const diverged = spoons.filter(
const diverged = spoonList.filter(
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy > 0,
).length;
const openPullRequests = spoons.reduce(
const openPullRequests = spoonList.reduce(
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
0,
);
@@ -43,10 +54,22 @@ const DashboardPage = () => {
</Button>
</div>
{metricsLoading ? (
<div
role='status'
aria-label='Loading'
className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'
>
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className='h-32 w-full' />
))}
<span className='sr-only'>Loading</span>
</div>
) : (
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<MetricCard
label='Spoons'
value={spoons.length}
value={spoonList.length}
note={`${activeSpoons} active`}
icon={GitBranch}
/>
@@ -59,7 +82,7 @@ const DashboardPage = () => {
<MetricCard
label='Open threads'
value={
threads.filter(
threadList.filter(
(thread) =>
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
thread.status,
@@ -76,16 +99,23 @@ const DashboardPage = () => {
icon={ShieldCheck}
/>
</div>
)}
<section className='space-y-3'>
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
{threads === undefined ? (
<ListSkeleton rows={2} />
) : (
<MaintenanceQueue threads={threads} />
)}
</section>
<div className='grid gap-6 xl:grid-cols-2'>
<section className='space-y-3'>
<h2 className='text-lg font-semibold'>Recent Spoons</h2>
{spoons.length ? (
{spoons === undefined ? (
<ListSkeleton rows={3} />
) : spoons.length ? (
spoons
.slice(0, 3)
.map((spoon) => <SpoonCard key={spoon._id} spoon={spoon} />)
@@ -108,7 +138,9 @@ const DashboardPage = () => {
<CardTitle className='text-base'>Upstream checks</CardTitle>
</CardHeader>
<CardContent>
{syncRuns.length ? (
{syncRuns === undefined ? (
<ListSkeleton rows={3} rowClassName='h-14 w-full' />
) : syncRuns.length ? (
<div className='space-y-3'>
{syncRuns.map((run) => (
<div
@@ -137,7 +169,9 @@ const DashboardPage = () => {
<CardTitle className='text-base'>Recent threads</CardTitle>
</CardHeader>
<CardContent>
{threads.length ? (
{threads === undefined ? (
<ListSkeleton rows={3} rowClassName='h-14 w-full' />
) : threads.length ? (
<div className='space-y-3'>
{threads.slice(0, 5).map((thread) => (
<div
+30
View File
@@ -0,0 +1,30 @@
'use client';
import { useEffect } from 'react';
import * as Sentry from '@sentry/nextjs';
import { Button } from '@spoon/ui';
const AppError = ({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) => {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<main className='flex min-h-[50vh] flex-col items-center justify-center gap-4 p-6 text-center'>
<h1 className='text-2xl font-semibold'>Something went wrong</h1>
<p className='text-muted-foreground max-w-md text-sm'>
This page hit an unexpected error. It has been reported.
</p>
<Button onClick={() => reset()}>Try again</Button>
</main>
);
};
export default AppError;
+5
View File
@@ -0,0 +1,5 @@
import { MachineShell } from '@/components/machine/machine-shell';
const MachinePage = () => <MachineShell />;
export default MachinePage;
+17
View File
@@ -0,0 +1,17 @@
import Link from 'next/link';
import { Button } from '@spoon/ui';
const NotFound = () => (
<main className='flex min-h-[50vh] flex-col items-center justify-center gap-4 p-6 text-center'>
<h1 className='text-2xl font-semibold'>Not found</h1>
<p className='text-muted-foreground max-w-md text-sm'>
This item does not exist, or you do not have access to it.
</p>
<Button asChild>
<Link href='/dashboard'>Back to dashboard</Link>
</Button>
</main>
);
export default NotFound;
@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation';
const AiSettingsPage = () => redirect('/settings/ai-providers');
export default AiSettingsPage;
+10 -1
View File
@@ -3,7 +3,15 @@
import type { ReactNode } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Brain, FileCog, Github, ServerCog, Shield, User } from 'lucide-react';
import {
Bell,
Brain,
FileCog,
Github,
ServerCog,
Shield,
User,
} from 'lucide-react';
import { cn } from '@spoon/ui';
@@ -12,6 +20,7 @@ const settingsItems = [
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
{ href: '/settings/dotfiles', label: 'Dotfiles', icon: FileCog },
{ href: '/settings/notifications', label: 'Notifications', icon: Bell },
{ href: '/settings/worker', label: 'Worker', icon: ServerCog },
{ href: '/settings/security', label: 'Security', icon: Shield },
];
@@ -0,0 +1,19 @@
import { NotificationPreferencesPanel } from '@/components/settings/notification-preferences-panel';
const SettingsNotificationsPage = () => {
return (
<section className='max-w-3xl space-y-4'>
<div>
<h2 className='text-xl font-semibold'>Notifications</h2>
<p className='text-muted-foreground mt-1 text-sm'>
Choose which notifications also reach you by email. In-app
notifications always show in the bell; these toggles only control the
email copies.
</p>
</div>
<NotificationPreferencesPanel />
</section>
);
};
export default SettingsNotificationsPage;
@@ -1,5 +1,3 @@
'use server';
import {
AvatarUpload,
ProfileHeader,
@@ -21,7 +21,24 @@ const AgentWorkspacePage = () => {
if (job?.threadId) router.replace(`/threads/${job.threadId}`);
}, [job?.threadId, router]);
if (job?.threadId) {
if (job === undefined) {
return (
<main className='text-muted-foreground p-6'>Loading workspace...</main>
);
}
if (job === null) {
return (
<main className='space-y-4 p-6'>
<p className='text-muted-foreground'>This workspace was not found.</p>
<Button asChild variant='outline' size='sm'>
<Link href={`/spoons/${params.spoonId}`}>Back to Spoon</Link>
</Button>
</main>
);
}
if (job.threadId) {
return (
<main className='text-muted-foreground p-6'>
Opening thread workspace...
@@ -37,7 +54,7 @@ const AgentWorkspacePage = () => {
Back to Spoon
</Link>
</Button>
<AgentWorkspaceShell jobId={jobId} />
<AgentWorkspaceShell key={jobId} jobId={jobId} />
</main>
);
};
+31 -26
View File
@@ -1,8 +1,8 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
import { ListSkeleton } from '@/components/ui/list-skeleton';
import { useQuery } from 'convex/react';
import {
ArrowUpRight,
@@ -17,6 +17,7 @@ import {
Button,
Card,
CardContent,
Skeleton,
Table,
TableBody,
TableCell,
@@ -31,16 +32,19 @@ const formatDate = (value?: number) =>
: 'Never';
const SpoonsPage = () => {
const router = useRouter();
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
const active = spoons.filter((spoon) => spoon.status === 'active').length;
const needsReview = threads.filter(
const spoons = useQuery(api.spoons.listMineWithState, {});
const threads = useQuery(api.threads.listMine, { limit: 100 });
const spoonList = spoons ?? [];
const threadList = threads ?? [];
const spoonsLoading = spoons === undefined;
const metricsLoading = spoons === undefined || threads === undefined;
const active = spoonList.filter((spoon) => spoon.status === 'active').length;
const needsReview = threadList.filter(
(thread) =>
thread.spoonId &&
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
).length;
const upstreamWaiting = spoons.reduce(
const upstreamWaiting = spoonList.reduce(
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
0,
);
@@ -65,7 +69,11 @@ const SpoonsPage = () => {
<CardContent className='flex items-center justify-between p-4'>
<div>
<p className='text-muted-foreground text-sm'>Managed</p>
<p className='text-2xl font-semibold'>{spoons.length}</p>
{spoonsLoading ? (
<Skeleton className='mt-1 h-8 w-10' />
) : (
<p className='text-2xl font-semibold'>{spoonList.length}</p>
)}
</div>
<GitBranch className='text-muted-foreground size-5' />
</CardContent>
@@ -74,7 +82,11 @@ const SpoonsPage = () => {
<CardContent className='flex items-center justify-between p-4'>
<div>
<p className='text-muted-foreground text-sm'>Active</p>
{spoonsLoading ? (
<Skeleton className='mt-1 h-8 w-10' />
) : (
<p className='text-2xl font-semibold'>{active}</p>
)}
</div>
<RefreshCw className='text-muted-foreground size-5' />
</CardContent>
@@ -83,14 +95,20 @@ const SpoonsPage = () => {
<CardContent className='flex items-center justify-between p-4'>
<div>
<p className='text-muted-foreground text-sm'>Open threads</p>
{metricsLoading ? (
<Skeleton className='mt-1 h-8 w-10' />
) : (
<p className='text-2xl font-semibold'>{needsReview}</p>
)}
</div>
<MessageSquare className='text-muted-foreground size-5' />
</CardContent>
</Card>
</div>
{spoons.length ? (
{spoons === undefined ? (
<ListSkeleton rows={4} rowClassName='h-16 w-full' />
) : spoons.length ? (
<Card className='shadow-none'>
<CardContent className='p-0'>
<Table>
@@ -111,22 +129,12 @@ const SpoonsPage = () => {
return (
<TableRow
key={spoon._id}
role='link'
tabIndex={0}
className='hover:bg-muted/50 cursor-pointer'
onClick={() => router.push(href)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
router.push(href);
}
}}
className='hover:bg-muted/50 relative cursor-pointer'
>
<TableCell className='pl-4'>
<Link
href={href}
className='group inline-flex min-w-0 flex-col'
onClick={(event) => event.stopPropagation()}
className='group inline-flex min-w-0 flex-col after:absolute after:inset-0 after:content-[""]'
>
<span className='group-hover:text-primary font-medium transition-colors'>
{spoon.name}
@@ -170,10 +178,7 @@ const SpoonsPage = () => {
<TableCell>{formatDate(spoon.lastCheckedAt)}</TableCell>
<TableCell className='pr-4 text-right'>
<Button size='sm' variant='outline' asChild>
<Link
href={href}
onClick={(event) => event.stopPropagation()}
>
<Link href={href} className='relative z-10'>
Open
<ArrowUpRight className='size-3' />
</Link>
@@ -201,7 +206,7 @@ const SpoonsPage = () => {
</Card>
)}
{spoons.length ? (
{spoonList.length ? (
<p className='text-muted-foreground text-sm'>
Actionable upstream commits waiting across all Spoons:{' '}
{upstreamWaiting}
@@ -43,6 +43,14 @@ const ThreadDetailPage = () => {
return <main className='text-muted-foreground p-6'>Loading thread...</main>;
}
if (details === null) {
return (
<main className='text-muted-foreground p-6'>
This thread was not found.
</main>
);
}
const { thread, spoon, latestJob } = details;
if (latestJob && spoon) {
return (
@@ -53,7 +61,7 @@ const ThreadDetailPage = () => {
Back to Spoon
</Link>
</Button>
<AgentWorkspaceShell jobId={latestJob._id} />
<AgentWorkspaceShell key={latestJob._id} jobId={latestJob._id} />
</main>
);
}
+19 -24
View File
@@ -4,6 +4,7 @@ import { useState } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { DeleteThreadButton } from '@/components/threads/delete-thread-button';
import { ListSkeleton } from '@/components/ui/list-skeleton';
import { useMutation, useQuery } from 'convex/react';
import { MessageSquare, Plus } from 'lucide-react';
import { toast } from 'sonner';
@@ -48,8 +49,7 @@ const ThreadsPage = () => {
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
const defaultProfile = profiles.find((profile) => profile.isDefault);
const threads =
useQuery(api.threads.listMine, {
const threads = useQuery(api.threads.listMine, {
source: source as
| 'all'
| 'user_request'
@@ -70,8 +70,9 @@ const ThreadsPage = () => {
| 'failed'
| 'cancelled',
limit: 100,
}) ?? [];
const visibleThreads = threads.filter((thread) => {
});
const threadsLoading = threads === undefined;
const visibleThreads = (threads ?? []).filter((thread) => {
if (spoonFilter !== 'all' && thread.spoonId !== spoonFilter) return false;
if (priorityFilter !== 'all' && thread.priority !== priorityFilter) {
return false;
@@ -312,25 +313,25 @@ const ThreadsPage = () => {
</div>
<div className='space-y-3'>
{visibleThreads.length ? (
{threadsLoading ? (
<ListSkeleton rows={4} />
) : visibleThreads.length ? (
visibleThreads.map((thread) => (
<Card
key={thread._id}
role='link'
tabIndex={0}
className='hover:border-primary/50 cursor-pointer shadow-none transition-colors'
onClick={() => router.push(threadTarget(thread))}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
router.push(threadTarget(thread));
}
}}
className='hover:border-primary/50 relative cursor-pointer shadow-none transition-colors'
>
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
<div className='min-w-0'>
<div className='flex flex-wrap items-center gap-2'>
<h2 className='truncate font-medium'>{thread.title}</h2>
<h2 className='truncate font-medium'>
<Link
href={threadTarget(thread)}
className='after:absolute after:inset-0 after:content-[""]'
>
{thread.title}
</Link>
</h2>
{thread.spoonName ? (
<Badge variant='outline'>{thread.spoonName}</Badge>
) : null}
@@ -361,15 +362,10 @@ const ThreadsPage = () => {
{thread.latestJobWorkspaceStatus.replaceAll('_', ' ')}
</p>
) : null}
<div className='mt-2 flex justify-start gap-2 md:justify-end'>
<div className='relative z-10 mt-2 flex justify-start gap-2 md:justify-end'>
{thread.latestAgentJobId ? (
<Button size='sm' variant='outline' asChild>
<Link
href={threadTarget(thread)}
onClick={(event) => event.stopPropagation()}
>
Open workspace
</Link>
<Link href={threadTarget(thread)}>Open workspace</Link>
</Button>
) : null}
{thread.latestJobPullRequestUrl ? (
@@ -378,7 +374,6 @@ const ThreadsPage = () => {
href={thread.latestJobPullRequestUrl}
target='_blank'
rel='noreferrer'
onClick={(event) => event.stopPropagation()}
>
PR
</a>
-7
View File
@@ -1,7 +0,0 @@
import { redirect } from 'next/navigation';
const UpdatesRedirectPage = () => {
redirect('/threads?source=upstream_update');
};
export default UpdatesRedirectPage;
@@ -100,12 +100,14 @@ const ForgotPassword = () => {
await signIn('password', formData).then(() => {
setEmail(values.email);
setFlow('reset-verification');
// Only clear the form once the request succeeded; a failed request
// keeps the typed email so the user can retry without re-entering it.
forgotPasswordForm.reset();
});
} catch (error) {
console.error('Error resetting password: ', error);
toast.error('Error resetting password.');
} finally {
forgotPasswordForm.reset();
setLoading(false);
}
};
@@ -121,12 +123,14 @@ const ForgotPassword = () => {
setLoading(true);
try {
await signIn('password', formData);
// Only clear on success (navigation follows); a failed submit keeps the
// entered password so the user can correct and retry.
resetVerificationForm.reset();
router.push('/');
} catch (error) {
console.error('Error resetting password: ', error);
toast.error('Error resetting password.');
} finally {
resetVerificationForm.reset();
setLoading(false);
}
};
@@ -230,7 +234,7 @@ const ForgotPassword = () => {
</FormControl>
<FormDescription>
Please enter the one-time password sent to your
phone.
email.
</FormDescription>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
@@ -265,7 +269,7 @@ const ForgotPassword = () => {
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>
Confirm Passsword
Confirm Password
</FormLabel>
<FormControl>
<Input
@@ -1,25 +0,0 @@
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { isAuthenticatedNextjs } from '@convex-dev/auth/nextjs/server';
export const generateMetadata = (): Metadata => {
return {
title: 'Profile',
robots: {
index: false,
follow: false,
googleBot: {
index: false,
follow: false,
},
},
};
};
const ProfileLayout = async ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
if (!(await isAuthenticatedNextjs())) redirect('/sign-in');
return <>{children}</>;
};
export default ProfileLayout;
@@ -1,6 +0,0 @@
import { redirect } from 'next/navigation';
const Profile = () => {
redirect('/settings/profile');
};
export default Profile;
+11 -7
View File
@@ -132,12 +132,14 @@ const SignIn = () => {
formData.append('flow', flow);
setLoading(true);
try {
await signIn('password', formData).then(() => router.push('/dashboard'));
await signIn('password', formData).then(() => {
signInForm.reset();
router.push('/dashboard');
});
} catch (error) {
console.error('Error signing in:', error);
toast.error('Error signing in.');
} finally {
signInForm.reset();
setLoading(false);
}
};
@@ -153,6 +155,7 @@ const SignIn = () => {
if (values.confirmPassword !== values.password)
throw new ConvexError('Passwords do not match.');
await signIn('password', formData).then(() => {
signUpForm.reset();
setEmail(values.email);
setFlow('email-verification');
});
@@ -160,7 +163,6 @@ const SignIn = () => {
console.error('Error signing up:', error);
toast.error('Error signing up.');
} finally {
signUpForm.reset();
setLoading(false);
}
};
@@ -174,12 +176,14 @@ const SignIn = () => {
formData.append('email', email);
setLoading(true);
try {
await signIn('password', formData).then(() => router.push('/dashboard'));
await signIn('password', formData).then(() => {
verifyEmailForm.reset();
router.push('/dashboard');
});
} catch (error) {
console.error('Error verifying email:', error);
toast.error('Error verifying email.');
} finally {
verifyEmailForm.reset();
setLoading(false);
}
};
@@ -232,7 +236,7 @@ const SignIn = () => {
/>
<SubmitButton
disabled={loading}
pendingText='Signing Up...'
pendingText='Verifying...'
className='mx-auto w-2/3 text-xl font-semibold'
>
Verify Email
@@ -423,7 +427,7 @@ const SignIn = () => {
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>
Confirm Passsword
Confirm Password
</FormLabel>
<FormControl>
<Input
+21
View File
@@ -0,0 +1,21 @@
import { proxyBox, withBox } from '@/lib/agent-worker-proxy';
export const GET = async (request: Request) =>
await withBox(async (username) => {
const url = new URL(request.url);
return await proxyBox(
username,
'file',
{ method: 'GET' },
new URLSearchParams({ path: url.searchParams.get('path') ?? '' }),
);
});
export const PUT = async (request: Request) =>
await withBox(
async (username) =>
await proxyBox(username, 'file', {
method: 'PUT',
body: await request.text(),
}),
);
@@ -0,0 +1,10 @@
import { proxyBox, withBox } from '@/lib/agent-worker-proxy';
export const POST = async (request: Request) =>
await withBox(
async (username) =>
await proxyBox(username, 'lifecycle', {
method: 'POST',
body: await request.text(),
}),
);
@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { proxyBox, withBox } from '@/lib/agent-worker-proxy';
import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server';
import { fetchAction } from 'convex/nextjs';
import { api } from '@spoon/backend/convex/_generated/api.js';
// Set (or clear, with null/'') the box user's password. Convex is the source
// of truth and is written FIRST (box creation re-applies from it); the worker
// call then applies it to the live container. A worker failure downgrades to
// applied:false — the password still takes effect on the next box restart.
export const POST = async (request: Request) =>
await withBox(async (username) => {
const body = (await request.json().catch(() => ({}))) as {
password?: string | null;
};
const password =
typeof body.password === 'string' && body.password.length > 0
? body.password
: null;
const token = await convexAuthNextjsToken();
const stored = await fetchAction(
api.boxSettingsNode.setBoxPassword,
{ password },
{ token },
);
let applied = false;
try {
const workerResponse = await proxyBox(username, 'set-password', {
method: 'POST',
body: JSON.stringify({ password }),
});
if (workerResponse.ok) {
applied = ((await workerResponse.json()) as { applied?: boolean })
.applied
? true
: false;
}
} catch {
// Stored but not live-applied; init picks it up at the next creation.
}
return NextResponse.json({ hasPassword: stored.hasPassword, applied });
});
@@ -0,0 +1,6 @@
import { proxyBox, withBox } from '@/lib/agent-worker-proxy';
export const GET = async () =>
await withBox(
async (username) => await proxyBox(username, 'status', { method: 'GET' }),
);
@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { mintBoxTerminalToken, resolveBoxUsername } from '@/lib/agent-worker-proxy';
export const GET = async () => {
const resolved = await resolveBoxUsername();
if (!resolved.ok) return resolved.response;
const minted = mintBoxTerminalToken(resolved.username);
return minted
? NextResponse.json(minted)
: NextResponse.json(
{ error: 'Terminal is not configured on this deployment.' },
{ status: 503 },
);
};
+6
View File
@@ -0,0 +1,6 @@
import { proxyBox, withBox } from '@/lib/agent-worker-proxy';
export const GET = async () =>
await withBox(
async (username) => await proxyBox(username, 'tree', { method: 'GET' }),
);
@@ -35,7 +35,14 @@ export const POST = async (
{ status: 400 },
);
}
const details = await fetchQuery(api.threads.get, { threadId }, { token });
const details = (await fetchQuery(
api.threads.get,
{ threadId },
{ token },
));
if (details === null) {
return NextResponse.json({ error: 'Thread not found.' }, { status: 404 });
}
const latestJob = details.latestJob;
const canSendToWorker =
latestJob &&
@@ -2,6 +2,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
ArrowDown,
Ban,
FilePenLine,
MessagesSquare,
@@ -16,6 +17,7 @@ import { Badge, Button, Textarea } from '@spoon/ui';
import { DiffFileView, useDiffTheme } from './diff-file-view';
import { parseDiffFileForPath } from './diff-utils';
import { isNearBottom } from './workspace-helpers';
type ActivityFilter = 'all' | 'chat' | 'activity' | 'files' | 'errors';
@@ -70,6 +72,7 @@ export const AgentThread = ({
const [filter, setFilter] = useState<ActivityFilter>('all');
const diffTheme = useDiffTheme();
const scrollRef = useRef<HTMLDivElement>(null);
const [showJumpToLatest, setShowJumpToLatest] = useState(false);
const chatMessages = useMemo(
() =>
messages.filter((message) => {
@@ -111,20 +114,35 @@ export const AgentThread = ({
? []
: workspaceChanges;
useEffect(() => {
const scrollToLatest = () => {
const node = scrollRef.current;
if (!node) return;
const distanceFromBottom =
node.scrollHeight - node.scrollTop - node.clientHeight;
if (distanceFromBottom < 160 || agentTurnActive) {
if (typeof node.scrollTo === 'function') {
node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' });
} else {
node.scrollTop = node.scrollHeight;
}
setShowJumpToLatest(false);
};
// Only auto-scroll to the newest content when the user is already near the
// bottom. If they have scrolled up to read, don't yank them — surface a
// "jump to latest" affordance instead. (The agent streaming no longer forces
// a scroll; that was fighting the reader.)
useEffect(() => {
const node = scrollRef.current;
if (!node) return;
if (isNearBottom(node)) {
if (typeof node.scrollTo === 'function') {
node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' });
} else {
node.scrollTop = node.scrollHeight;
}
setShowJumpToLatest(false);
} else {
setShowJumpToLatest(true);
}
}, [
agentTurnActive,
events.length,
interactions.length,
messages.length,
@@ -227,8 +245,13 @@ export const AgentThread = ({
</Button>
))}
</div>
<div className='relative flex min-h-0 flex-1 flex-col'>
<div
ref={scrollRef}
onScroll={() => {
const node = scrollRef.current;
if (node && isNearBottom(node)) setShowJumpToLatest(false);
}}
className='min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain p-3'
>
{(filter === 'all' || filter === 'chat') && interactions.length > 0
@@ -430,6 +453,19 @@ export const AgentThread = ({
yet.
</p>
) : null}
</div>
{showJumpToLatest ? (
<Button
type='button'
size='sm'
variant='secondary'
className='absolute bottom-3 left-1/2 z-10 -translate-x-1/2 shadow-md'
onClick={scrollToLatest}
>
<ArrowDown className='size-3' />
Jump to latest
</Button>
) : null}
</div>
<div className='border-border flex-none space-y-2 border-t p-3'>
<Textarea
@@ -1,13 +1,14 @@
'use client';
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import {
FileCode,
GitCompare,
Loader2,
MessagesSquare,
PanelLeft,
SquareTerminal,
} from 'lucide-react';
import { toast } from 'sonner';
@@ -25,6 +26,11 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
Button,
cn,
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
Tabs,
TabsContent,
TabsList,
@@ -41,6 +47,7 @@ import { FileTree } from './file-tree';
import { JobStatusBar } from './job-status-bar';
import { WorkspaceActions } from './workspace-actions';
import { WorkspaceTerminal } from './workspace-terminal';
import { createRequestSequencer, shouldShowRecovery } from './workspace-helpers';
type WorkspaceTab = 'editor' | 'diff' | 'thread' | 'terminal';
@@ -51,6 +58,9 @@ type OpenFileState = {
loading: boolean;
saving: boolean;
error?: string;
// Set when the agent edited this file on disk while the user had unsaved
// local edits, so the UI can warn instead of silently stomping either side.
conflicted?: boolean;
};
type PendingOverwrite = {
@@ -61,17 +71,27 @@ type PendingOverwrite = {
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const job = useQuery(api.agentJobs.get, { jobId });
const messages =
useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? [];
useQuery(
api.agentJobs.listMessages,
job ? { jobId, limit: 200 } : 'skip',
) ?? [];
const events =
useQuery(api.agentJobs.listEvents, { jobId, limit: 200 }) ?? [];
useQuery(api.agentJobs.listEvents, job ? { jobId, limit: 200 } : 'skip') ??
[];
const workspaceChanges =
useQuery(api.agentJobs.listWorkspaceChanges, { jobId, limit: 200 }) ?? [];
useQuery(
api.agentJobs.listWorkspaceChanges,
job ? { jobId, limit: 200 } : 'skip',
) ?? [];
const interactions =
useQuery(api.agentJobs.listInteractionRequests, {
jobId,
status: 'all',
}) ?? [];
const uiState = useQuery(api.agentJobs.getWorkspaceUiState, { jobId });
useQuery(
api.agentJobs.listInteractionRequests,
job ? { jobId, status: 'all' } : 'skip',
) ?? [];
const uiState = useQuery(
api.agentJobs.getWorkspaceUiState,
job ? { jobId } : 'skip',
);
const patchUiState = useMutation(api.agentJobs.patchWorkspaceUiState);
const createJobForThread = useMutation(api.agentJobs.createForThread);
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
@@ -94,6 +114,39 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
useState<WorkspaceTab>('editor');
const [pendingOverwrite, setPendingOverwrite] = useState<PendingOverwrite>();
const [pendingClosePath, setPendingClosePath] = useState<string>();
const [fileTreeOpen, setFileTreeOpen] = useState(false);
// Per-loader sequence guards: only the latest response for each loader is
// applied, so a slower earlier fetch can't overwrite newer tree/diff data.
const treeSequencerRef = useRef<
ReturnType<typeof createRequestSequencer> | undefined
>(undefined);
treeSequencerRef.current ??= createRequestSequencer();
const treeSequencer = treeSequencerRef.current;
const diffSequencerRef = useRef<
ReturnType<typeof createRequestSequencer> | undefined
>(undefined);
diffSequencerRef.current ??= createRequestSequencer();
const diffSequencer = diffSequencerRef.current;
// Count consecutive workspace status/health failures. A single transient
// 'workspace is not active' blip (worker restart) must not flash the big red
// recovery panel — only surface it after RECOVERY_THRESHOLD in a row.
const consecutiveFailuresRef = useRef(0);
const processedChangeSignatureRef = useRef(0);
const conflictToastPathsRef = useRef<Set<string>>(new Set());
const recordWorkspaceSuccess = useCallback(() => {
consecutiveFailuresRef.current = 0;
setWorkspaceError(undefined);
}, []);
const recordWorkspaceFailure = useCallback((message: string) => {
consecutiveFailuresRef.current += 1;
if (shouldShowRecovery(consecutiveFailuresRef.current)) {
setWorkspaceError(message);
}
}, []);
const workspaceDisabled =
!job ||
@@ -122,20 +175,24 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
);
const loadTree = useCallback(async () => {
const token = treeSequencer.next();
const response = await fetch(`/api/agent-jobs/${jobId}/tree`);
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as { tree: FileTreeNode | null };
setWorkspaceError(undefined);
if (!treeSequencer.isLatest(token)) return;
recordWorkspaceSuccess();
setTree(data.tree);
}, [jobId]);
}, [jobId, recordWorkspaceSuccess, treeSequencer]);
const loadDiff = useCallback(async () => {
const token = diffSequencer.next();
const response = await fetch(`/api/agent-jobs/${jobId}/diff`);
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as DiffResponse;
setWorkspaceError(undefined);
if (!diffSequencer.isLatest(token)) return;
recordWorkspaceSuccess();
setDiff(data.diff);
}, [jobId]);
}, [diffSequencer, jobId, recordWorkspaceSuccess]);
const loadAgentStatus = useCallback(async () => {
const response = await fetch(`/api/agent-jobs/${jobId}/agent/status`);
@@ -143,14 +200,14 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
setAgentTurnActive(false);
const body = await response.text();
if (body.includes('workspace is not active')) {
setWorkspaceError(body);
recordWorkspaceFailure(body);
}
return;
}
const data = (await response.json()) as { active?: boolean };
setWorkspaceError(undefined);
recordWorkspaceSuccess();
setAgentTurnActive(Boolean(data.active));
}, [jobId]);
}, [jobId, recordWorkspaceFailure, recordWorkspaceSuccess]);
const loadFile = useCallback(
async (path: string) => {
@@ -183,6 +240,40 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
[jobId],
);
// Re-fetch an already-open file and replace its editor buffer. Used when the
// agent edits a file that is currently open: if the local buffer is clean we
// adopt the new on-disk content; if the user has unsaved edits we flag a
// conflict instead of stomping their work.
const refreshFileBuffer = useCallback(
async (path: string) => {
const response = await fetch(
`/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`,
);
if (!response.ok) return;
const data = (await response.json()) as FileResponse;
setFiles((current) => {
const existing = current[path];
if (!existing) return current;
if (existing.content !== existing.savedContent) {
return { ...current, [path]: { ...existing, conflicted: true } };
}
conflictToastPathsRef.current.delete(path);
return {
...current,
[path]: {
path,
content: data.content,
savedContent: data.content,
loading: false,
saving: false,
conflicted: false,
},
};
});
},
[jobId],
);
const openFile = useCallback(
(path: string) => {
setOpenFilePaths((current) =>
@@ -209,9 +300,10 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
useEffect(() => {
if (!workspaceReady) return;
// Recovery is driven solely by the recurring status heartbeat
// (loadAgentStatus) so a one-off tree/diff hiccup can't trip the panel.
const handleError = (error: unknown) => {
console.error(error);
setWorkspaceError(error instanceof Error ? error.message : String(error));
};
const timeout = window.setTimeout(() => {
void loadTree().catch(handleError);
@@ -249,6 +341,11 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
(latest, change) => Math.max(latest, change._creationTime),
0,
);
// Read the (freshly-allocated-each-render) changes array via a ref inside the
// signature-gated effect below, so the effect's deps stay on the stable
// numeric signature instead of the array identity.
const workspaceChangesRef = useRef(workspaceChanges);
workspaceChangesRef.current = workspaceChanges;
useEffect(() => {
if (!workspaceReady) return;
const timeout = window.setTimeout(() => {
@@ -264,6 +361,46 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
loadTree,
]);
// When the agent records a workspace change that touches a currently OPEN
// file, refresh that file's editor buffer (clean buffers adopt the new
// content; dirty buffers get a conflict flag + one-time toast) so the editor
// doesn't keep showing pre-edit content.
useEffect(() => {
if (!workspaceReady) return;
if (workspaceChangeSignature <= processedChangeSignatureRef.current) return;
const previousSignature = processedChangeSignatureRef.current;
processedChangeSignatureRef.current = workspaceChangeSignature;
const changedPaths = new Set(
workspaceChangesRef.current
.filter((change) => change._creationTime > previousSignature)
.map((change) => change.path),
);
for (const path of openFilePaths) {
if (!changedPaths.has(path)) continue;
const file = files[path];
if (!file) continue;
if (file.content !== file.savedContent) {
setFiles((current) =>
current[path]
? { ...current, [path]: { ...current[path], conflicted: true } }
: current,
);
if (!conflictToastPathsRef.current.has(path)) {
conflictToastPathsRef.current.add(path);
toast.warning(`${path} changed on disk`);
}
} else {
void refreshFileBuffer(path).catch(() => undefined);
}
}
}, [
files,
openFilePaths,
refreshFileBuffer,
workspaceChangeSignature,
workspaceReady,
]);
useEffect(() => {
if (!uiState || hydratedUiState) return;
const timeout = window.setTimeout(() => {
@@ -323,6 +460,14 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
);
}
if (job === null) {
return (
<main className='text-muted-foreground p-6'>
This workspace was not found.
</main>
);
}
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
const recoverWorkspace = async () => {
if (!job.threadId) return;
@@ -374,6 +519,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
}));
throw new Error(await response.text());
}
conflictToastPathsRef.current.delete(path);
setFiles((current) => ({
...current,
[path]: {
@@ -384,6 +530,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
content,
savedContent: content,
saving: false,
conflicted: false,
},
}));
await loadDiff();
@@ -473,7 +620,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
};
return (
<main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'>
<main className='border-border bg-muted/20 flex h-[calc(100dvh-8.5rem)] min-h-[70dvh] flex-col overflow-hidden rounded-md border lg:min-h-[720px]'>
<JobStatusBar job={job} />
{workspacePending && !workspaceError ? (
<div className='border-border bg-background border-b p-4'>
@@ -555,9 +702,40 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
</div>
</div>
) : null}
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
<div className='border-border bg-background flex items-center justify-between gap-2 border-b px-4 py-2'>
<Button
type='button'
variant='outline'
size='sm'
className='lg:hidden'
onClick={() => setFileTreeOpen(true)}
>
<PanelLeft className='size-4' />
Files
</Button>
<div className='flex flex-1 items-center justify-end'>
<WorkspaceActions job={job} disabled={workspaceDisabled} />
</div>
</div>
<Sheet open={fileTreeOpen} onOpenChange={setFileTreeOpen}>
<SheetContent side='left' className='w-[300px] p-0 lg:hidden'>
<SheetHeader className='border-border border-b'>
<SheetTitle>Files</SheetTitle>
</SheetHeader>
<div className='min-h-0 flex-1 overflow-y-auto'>
<FileTree
tree={tree}
selectedPath={activeFilePath}
expandedPaths={expandedDirectoryPaths}
onSelect={(path) => {
openFile(path);
setFileTreeOpen(false);
}}
onToggleDirectory={toggleDirectory}
/>
</div>
</SheetContent>
</Sheet>
<div
className='grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] 2xl:grid-cols-[300px_minmax(0,1fr)_6px_var(--agent-thread-width)]'
style={
@@ -566,7 +744,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
} as CSSProperties
}
>
<aside className='border-border bg-background min-h-0 border-r'>
<aside className='border-border bg-background hidden min-h-0 border-r lg:block'>
<div className='border-border border-b p-3'>
<h2 className='text-sm font-semibold'>Files</h2>
<p className='text-muted-foreground text-xs'>Current workspace</p>
@@ -638,6 +816,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
savedContent={activeFile?.savedContent ?? ''}
readOnly={workspaceDisabled}
vimEnabled={vimEnabled}
conflicted={activeFile?.conflicted}
onSave={saveFile}
onVimEnabledChange={setVimEnabled}
onChange={(content) => {
@@ -659,11 +838,19 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
</TabsContent>
<TabsContent
value='terminal'
className='m-0 min-h-0 flex-1 overflow-hidden'
forceMount
className={cn(
'm-0 min-h-0 flex-1 overflow-hidden',
activeWorkspaceTab !== 'terminal' && 'hidden',
)}
>
<WorkspaceTerminal
jobId={jobId}
active={activeWorkspaceTab === 'terminal' && workspaceReady}
active={workspaceReady}
visible={activeWorkspaceTab === 'terminal'}
waitingLabel={
workspacePending ? 'Waiting for workspace…' : undefined
}
/>
</TabsContent>
<TabsContent value='diff' className='m-0 min-h-0 flex-1'>
@@ -24,6 +24,7 @@ const EDITOR_FONT_FAMILY =
type MonacoEditorInstance = {
getModel?: () => unknown;
dispose?: () => void;
};
type VimMode = {
@@ -36,6 +37,7 @@ export const CodeEditor = ({
savedContent,
readOnly,
vimEnabled,
conflicted,
onSave,
onChange,
onVimEnabledChange,
@@ -45,6 +47,7 @@ export const CodeEditor = ({
savedContent: string;
readOnly: boolean;
vimEnabled: boolean;
conflicted?: boolean;
onSave: (content: string) => Promise<void>;
onChange: (content: string) => void;
onVimEnabledChange: (enabled: boolean) => void;
@@ -56,6 +59,18 @@ export const CodeEditor = ({
const { resolvedTheme } = useTheme();
const editorTheme = resolvedTheme === 'light' ? SPOON_LIGHT : SPOON_DARK;
// Dispose the Monaco instance and clear the ref on unmount so a remount
// (e.g. the Phase-1 key={jobId} swap) doesn't leave a stale editor/vim
// binding leaked behind the new mount.
useEffect(() => {
return () => {
vimRef.current?.dispose();
vimRef.current = null;
editorRef.current?.dispose?.();
editorRef.current = null;
};
}, []);
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
@@ -102,7 +117,11 @@ export const CodeEditor = ({
Editor
</p>
<p className='truncate font-mono text-xs'>{path}</p>
{dirty ? (
{conflicted ? (
<p className='text-amber-500 text-xs'>
This file changed on disk your unsaved edits may conflict.
</p>
) : dirty ? (
<p className='text-muted-foreground text-xs'>Unsaved changes</p>
) : null}
</div>
@@ -0,0 +1,28 @@
export const scheduleTerminalFits = (deps: {
fit: () => void;
sendResize: () => void;
fontsReady: Promise<unknown>;
loadNerdFont: () => Promise<unknown>;
raf: (cb: () => void) => void;
isAborted: () => boolean;
}): void => {
const refit = () => {
if (deps.isAborted()) return;
deps.fit();
deps.sendResize();
};
const initialFit = new Promise<void>((resolve) => {
deps.raf(() => {
refit();
resolve();
});
});
void Promise.all([deps.fontsReady, initialFit])
.then(refit)
.catch(() => undefined);
void Promise.all([deps.loadNerdFont(), initialFit])
.then(refit)
.catch(() => undefined);
};
@@ -0,0 +1,54 @@
/**
* Small pure helpers extracted from the agent workspace so the tricky UX
* decisions (out-of-order response guarding, recovery thresholding, and
* near-bottom auto-scroll) can be unit tested in isolation.
*/
/**
* A monotonically increasing request sequencer. Each loader call takes a fresh
* token via {@link RequestSequencer.next} before firing its fetch, then checks
* {@link RequestSequencer.isLatest} once the response resolves. A slower earlier
* request whose token is no longer the latest is dropped so it can't overwrite
* newer data (the diff/tree load race).
*/
export type RequestSequencer = {
next: () => number;
isLatest: (token: number) => boolean;
};
export const createRequestSequencer = (): RequestSequencer => {
let current = 0;
return {
next: () => {
current += 1;
return current;
},
isLatest: (token: number) => token === current,
};
};
/**
* Number of consecutive workspace status/health failures required before the
* "needs recovery" panel is shown. A single transient
* `workspace is not active` blip (e.g. during a worker restart) must not trip
* it.
*/
export const RECOVERY_THRESHOLD = 3;
export const shouldShowRecovery = (
consecutiveFailures: number,
threshold: number = RECOVERY_THRESHOLD,
): boolean => consecutiveFailures >= threshold;
/**
* Distance from the bottom (px) within which the thread is considered "near the
* bottom" and safe to auto-scroll on new content. Outside this band the user
* has scrolled up to read and must not be yanked.
*/
export const NEAR_BOTTOM_THRESHOLD = 80;
export const isNearBottom = (
metrics: { scrollHeight: number; scrollTop: number; clientHeight: number },
threshold: number = NEAR_BOTTOM_THRESHOLD,
): boolean =>
metrics.scrollHeight - metrics.scrollTop - metrics.clientHeight < threshold;
@@ -1,254 +1,23 @@
'use client';
import type { ITheme, Terminal } from '@xterm/xterm';
import { useEffect, useRef, useState } from 'react';
import { useTheme } from 'next-themes';
import { Button } from '@spoon/ui';
import '@xterm/xterm/css/xterm.css';
const TERMINAL_FONT =
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, monospace";
type Status = 'connecting' | 'connected' | 'closed' | 'error' | 'unconfigured';
const darkTheme: ITheme = {
background: '#080e14',
foreground: '#eef3f5',
cursor: '#1fb895',
cursorAccent: '#080e14',
selectionBackground: '#1fb89544',
black: '#10171e',
red: '#f3625d',
green: '#8fd6b4',
yellow: '#e3b341',
blue: '#6aa6ff',
magenta: '#b692e8',
cyan: '#5fd0e0',
white: '#cdd6dc',
brightBlack: '#93a1a9',
brightRed: '#f3625d',
brightGreen: '#8fd6b4',
brightYellow: '#e3b341',
brightBlue: '#6aa6ff',
brightMagenta: '#b692e8',
brightCyan: '#5fd0e0',
brightWhite: '#eef3f5',
};
const lightTheme: ITheme = {
background: '#f7fbfa',
foreground: '#0d1218',
cursor: '#007560',
cursorAccent: '#f7fbfa',
selectionBackground: '#00756033',
black: '#0d1218',
red: '#d73337',
green: '#2f8f6e',
yellow: '#9a6b00',
blue: '#2f6bd8',
magenta: '#7c4dd1',
cyan: '#0f7d92',
white: '#26323c',
brightBlack: '#555f68',
brightRed: '#d73337',
brightGreen: '#2f8f6e',
brightYellow: '#9a6b00',
brightBlue: '#2f6bd8',
brightMagenta: '#7c4dd1',
brightCyan: '#0f7d92',
brightWhite: '#0d1218',
};
import { XtermSession } from './xterm-session';
export const WorkspaceTerminal = ({
jobId,
active,
visible = true,
waitingLabel,
}: {
jobId: string;
active: boolean;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const { resolvedTheme } = useTheme();
const themeIsLight = resolvedTheme === 'light';
const [status, setStatus] = useState<Status>('connecting');
const [errorText, setErrorText] = useState<string>();
const [reconnectKey, setReconnectKey] = useState(0);
// Update the live terminal's theme without tearing down the session.
useEffect(() => {
if (termRef.current) {
termRef.current.options.theme = themeIsLight ? lightTheme : darkTheme;
}
}, [themeIsLight]);
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
const abortController = new AbortController();
const signal = abortController.signal;
// Read through a function so TS doesn't narrow `aborted` to a constant after
// the first guard (it changes asynchronously, on cleanup).
const isAborted = () => signal.aborted;
let ws: WebSocket | undefined;
let resizeObserver: ResizeObserver | undefined;
const encoder = new TextEncoder();
const start = async () => {
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all(
[
import('@xterm/xterm'),
import('@xterm/addon-fit'),
import('@xterm/addon-web-links'),
],
visible?: boolean;
waitingLabel?: string;
}) => (
<XtermSession
active={active}
visible={visible}
waitingLabel={waitingLabel}
tokenUrl={`/api/agent-jobs/${jobId}/terminal-token`}
sessionKey={jobId}
/>
);
if (isAborted()) return;
setStatus('connecting');
setErrorText(undefined);
let response: Response;
try {
response = await fetch(`/api/agent-jobs/${jobId}/terminal-token`, {
signal,
});
} catch {
if (!isAborted()) setStatus('error');
return;
}
if (isAborted()) return;
if (response.status === 503) {
setStatus('unconfigured');
return;
}
if (!response.ok) {
setStatus('error');
setErrorText(await response.text().catch(() => undefined));
return;
}
const { url } = (await response.json()) as { url: string };
const term = new Terminal({
fontFamily: TERMINAL_FONT,
fontSize: 13,
lineHeight: 1.2,
cursorBlink: true,
theme: themeIsLight ? lightTheme : darkTheme,
allowProposedApi: true,
scrollback: 5000,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new WebLinksAddon());
term.open(container);
fit.fit();
termRef.current = term;
// Pull in the Nerd Font icon glyphs (loaded lazily by unicode-range) and
// repaint once ready so powerline/oh-my-posh/eza icons render.
void document.fonts
.load("16px 'Symbols Nerd Font Mono'", '\ue0b0')
.then(() => {
if (!isAborted()) term.refresh(0, term.rows - 1);
})
.catch(() => undefined);
const sendResize = () => {
if (ws?.readyState !== WebSocket.OPEN) return;
ws.send(
JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }),
);
};
ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
if (isAborted()) return;
setStatus('connected');
sendResize();
term.focus();
};
ws.onmessage = (event: MessageEvent<ArrayBuffer | string>) => {
if (typeof event.data === 'string') term.write(event.data);
else term.write(new Uint8Array(event.data));
};
ws.onclose = () => {
if (!isAborted()) setStatus('closed');
};
ws.onerror = () => {
if (!isAborted()) setStatus('error');
};
term.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data));
});
term.onResize(() => sendResize());
resizeObserver = new ResizeObserver(() => {
try {
fit.fit();
} catch {
// ignore transient layout errors
}
});
resizeObserver.observe(container);
};
void start();
return () => {
abortController.abort();
resizeObserver?.disconnect();
ws?.close();
termRef.current?.dispose();
termRef.current = null;
};
// resolvedTheme intentionally excluded: handled by the theme effect above.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, jobId, reconnectKey]);
return (
<div className='relative flex h-full min-h-0 flex-col'>
<div className='border-border flex h-10 flex-none items-center justify-between gap-3 border-b px-3'>
<p className='text-muted-foreground text-xs'>
{status === 'connected'
? 'Connected · workspace shell'
: status === 'connecting'
? 'Connecting…'
: status === 'closed'
? 'Session ended'
: status === 'unconfigured'
? 'Terminal not configured'
: 'Connection error'}
</p>
{status === 'closed' || status === 'error' ? (
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setReconnectKey((key) => key + 1)}
>
Reconnect
</Button>
) : null}
</div>
{status === 'unconfigured' ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
The terminal is not enabled on this deployment.
<br />
Set NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL to enable it.
</div>
) : (
<div className='min-h-0 flex-1 overflow-hidden bg-[#080e14] p-2'>
<div ref={containerRef} className='h-full w-full' />
{errorText ? (
<p className='text-destructive mt-2 px-1 text-xs break-all'>
{errorText}
</p>
) : null}
</div>
)}
</div>
);
};
@@ -0,0 +1,386 @@
'use client';
import type { ITheme, Terminal } from '@xterm/xterm';
import { useEffect, useRef, useState } from 'react';
import { useTheme } from 'next-themes';
import { Button } from '@spoon/ui';
import { scheduleTerminalFits } from './terminal-fit';
import '@xterm/xterm/css/xterm.css';
const TERMINAL_FONT =
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, monospace";
// Persisted user preference for copy-on-select. A stored value takes precedence
// over the `copyOnSelect` prop default so the toggle sticks across sessions.
const COPY_ON_SELECT_KEY = 'spoon.terminal.copyOnSelect';
const readStoredCopyOnSelect = (): boolean | undefined => {
try {
const stored = window.localStorage.getItem(COPY_ON_SELECT_KEY);
if (stored === 'true') return true;
if (stored === 'false') return false;
} catch {
// localStorage may be unavailable (private mode, SSR); fall back to prop.
}
return undefined;
};
export type XtermStatus =
| 'idle'
| 'connecting'
| 'connected'
| 'closed'
| 'error'
| 'unconfigured';
const darkTheme: ITheme = {
background: '#080e14',
foreground: '#eef3f5',
cursor: '#1fb895',
cursorAccent: '#080e14',
selectionBackground: '#1fb89544',
black: '#10171e',
red: '#f3625d',
green: '#8fd6b4',
yellow: '#e3b341',
blue: '#6aa6ff',
magenta: '#b692e8',
cyan: '#5fd0e0',
white: '#cdd6dc',
brightBlack: '#93a1a9',
brightRed: '#f3625d',
brightGreen: '#8fd6b4',
brightYellow: '#e3b341',
brightBlue: '#6aa6ff',
brightMagenta: '#b692e8',
brightCyan: '#5fd0e0',
brightWhite: '#eef3f5',
};
const lightTheme: ITheme = {
background: '#f7fbfa',
foreground: '#0d1218',
cursor: '#007560',
cursorAccent: '#f7fbfa',
selectionBackground: '#00756033',
black: '#0d1218',
red: '#d73337',
green: '#2f8f6e',
yellow: '#9a6b00',
blue: '#2f6bd8',
magenta: '#7c4dd1',
cyan: '#0f7d92',
white: '#26323c',
brightBlack: '#555f68',
brightRed: '#d73337',
brightGreen: '#2f8f6e',
brightYellow: '#9a6b00',
brightBlue: '#2f6bd8',
brightMagenta: '#7c4dd1',
brightCyan: '#0f7d92',
brightWhite: '#0d1218',
};
export const XtermSession = ({
active,
visible = true,
tokenUrl,
sessionKey,
waitingLabel,
copyOnSelect,
label = 'workspace shell',
}: {
active: boolean;
visible?: boolean;
tokenUrl: string;
sessionKey: string;
waitingLabel?: string;
copyOnSelect?: boolean;
label?: string;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<{ fit: () => void } | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const { resolvedTheme } = useTheme();
const themeIsLight = resolvedTheme === 'light';
const [status, setStatus] = useState<XtermStatus>('idle');
const [errorText, setErrorText] = useState<string>();
const [reconnectKey, setReconnectKey] = useState(0);
// localStorage overrides the prop default; the prop is only the initial value
// when nothing has been persisted yet.
const [copyEnabled, setCopyEnabled] = useState<boolean>(
() => readStoredCopyOnSelect() ?? copyOnSelect ?? false,
);
// The connect effect registers onSelectionChange once; it reads the current
// toggle through this ref so live flips take effect without a reconnect.
const copyEnabledRef = useRef(copyEnabled);
useEffect(() => {
copyEnabledRef.current = copyEnabled;
}, [copyEnabled]);
const toggleCopyOnSelect = () => {
setCopyEnabled((prev) => {
const next = !prev;
try {
window.localStorage.setItem(COPY_ON_SELECT_KEY, String(next));
} catch {
// Persistence is best-effort; ignore storage failures.
}
return next;
});
};
// Update the live terminal's theme without tearing down the session.
useEffect(() => {
if (termRef.current) {
termRef.current.options.theme = themeIsLight ? lightTheme : darkTheme;
}
}, [themeIsLight]);
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
const abortController = new AbortController();
const signal = abortController.signal;
// Read through a function so TS doesn't narrow `aborted` to a constant after
// the first guard (it changes asynchronously, on cleanup).
const isAborted = () => signal.aborted;
let ws: WebSocket | undefined;
let resizeObserver: ResizeObserver | undefined;
const encoder = new TextEncoder();
const start = async () => {
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all(
[
import('@xterm/xterm'),
import('@xterm/addon-fit'),
import('@xterm/addon-web-links'),
],
);
if (isAborted()) return;
setStatus('connecting');
setErrorText(undefined);
let response: Response;
try {
response = await fetch(tokenUrl, { signal });
} catch {
if (!isAborted()) setStatus('error');
return;
}
if (isAborted()) return;
if (response.status === 503) {
setStatus('unconfigured');
return;
}
if (!response.ok) {
setStatus('error');
setErrorText(await response.text().catch(() => undefined));
return;
}
const { url } = (await response.json()) as { url: string };
const term = new Terminal({
fontFamily: TERMINAL_FONT,
fontSize: 13,
lineHeight: 1.35,
cursorBlink: true,
theme: themeIsLight ? lightTheme : darkTheme,
allowProposedApi: true,
scrollback: 5000,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new WebLinksAddon());
term.open(container);
termRef.current = term;
fitRef.current = fit;
// Copy-on-select: when enabled, mirror the selection to the clipboard.
// Registered once; the ref lets live toggle flips apply without a
// reconnect. Clipboard access is guarded — it may be unavailable or
// denied, and empty selections are skipped.
term.onSelectionChange(() => {
if (!copyEnabledRef.current) return;
const selection = term.getSelection();
if (!selection) return;
try {
void navigator.clipboard.writeText(selection).catch(() => {
// clipboard write denied/unavailable; ignore
});
} catch {
// navigator.clipboard missing entirely; ignore
}
});
const sendResize = () => {
if (ws?.readyState !== WebSocket.OPEN) return;
ws.send(
JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }),
);
};
scheduleTerminalFits({
fit: () => {
try {
fit.fit();
} catch {
// ignore transient layout errors
}
},
sendResize,
fontsReady: document.fonts.ready,
loadNerdFont: () =>
document.fonts.load("16px 'Symbols Nerd Font Mono'", '').then(() => {
if (!isAborted()) term.refresh(0, term.rows - 1);
}),
raf: (cb) => requestAnimationFrame(cb),
isAborted,
});
ws = new WebSocket(url);
wsRef.current = ws;
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
if (isAborted()) return;
setStatus('connected');
sendResize();
term.focus();
};
ws.onmessage = (event: MessageEvent<ArrayBuffer | string>) => {
if (typeof event.data === 'string') term.write(event.data);
else term.write(new Uint8Array(event.data));
};
ws.onclose = (event: CloseEvent) => {
if (isAborted()) return;
setStatus('closed');
// The worker attaches a human-readable reason when the bridge fails
// (e.g. container runtime unreachable) — show it instead of a mute
// "Session ended".
if (event.reason) setErrorText(event.reason);
};
ws.onerror = () => {
if (!isAborted()) setStatus('error');
};
term.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data));
});
term.onResize(() => sendResize());
resizeObserver = new ResizeObserver(() => {
try {
fit.fit();
} catch {
// ignore transient layout errors
}
});
resizeObserver.observe(container);
};
void start();
return () => {
abortController.abort();
resizeObserver?.disconnect();
ws?.close();
termRef.current?.dispose();
termRef.current = null;
fitRef.current = null;
wsRef.current = null;
};
// resolvedTheme intentionally excluded: handled by the theme effect above.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, sessionKey, reconnectKey]);
// A hidden (display:none) container measures 0×0, so xterm mis-sizes while the
// tab is inactive. When the terminal becomes visible again, refit inside a
// frame (so layout has settled) and push the new dimensions to the PTY. The
// WebSocket stays open across visibility changes — this never reconnects.
useEffect(() => {
if (!visible || !active) return;
const frame = requestAnimationFrame(() => {
try {
fitRef.current?.fit();
} catch {
// ignore transient layout errors
}
const ws = wsRef.current;
const term = termRef.current;
if (ws?.readyState === WebSocket.OPEN && term) {
ws.send(
JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }),
);
}
});
return () => cancelAnimationFrame(frame);
}, [visible, active]);
return (
<div className='relative flex h-full min-h-0 flex-col'>
<div className='border-border flex h-10 flex-none items-center justify-between gap-3 border-b px-3'>
<p className='text-muted-foreground text-xs'>
{status === 'connected'
? `Connected · ${label}`
: status === 'idle'
? waitingLabel
? 'Waiting…'
: 'Idle'
: status === 'connecting'
? 'Connecting…'
: status === 'closed'
? 'Session ended'
: status === 'unconfigured'
? 'Terminal not configured'
: 'Connection error'}
</p>
<div className='flex items-center gap-3'>
<label className='text-muted-foreground flex cursor-pointer items-center gap-1.5 text-xs select-none'>
<input
type='checkbox'
className='accent-primary size-3.5'
checked={copyEnabled}
onChange={toggleCopyOnSelect}
/>
Copy on select
</label>
{status === 'closed' || status === 'error' ? (
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setReconnectKey((key) => key + 1)}
>
Reconnect
</Button>
) : null}
</div>
</div>
{status === 'unconfigured' ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
The terminal is not enabled on this deployment.
<br />
Set NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL to enable it.
</div>
) : !active && waitingLabel ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
{waitingLabel}
</div>
) : (
<div className='min-h-0 flex-1 overflow-hidden bg-[#080e14] p-2'>
<div ref={containerRef} className='h-full w-full' />
{errorText ? (
<p className='text-destructive mt-2 px-1 text-xs break-all'>
{errorText}
</p>
) : null}
</div>
)}
</div>
);
};
@@ -42,7 +42,11 @@ type Provider =
| 'cloudflare_ai_gateway'
| 'custom_openai_compatible'
| 'opencode_openai_login';
type AuthType = 'api_key' | 'opencode_auth_json' | 'none';
type AuthType =
| 'api_key'
| 'opencode_auth_json'
| 'anthropic_oauth_json'
| 'none';
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
const saveProfileRef = makeFunctionReference<
@@ -69,33 +73,75 @@ const setDefaultProfileRef = makeFunctionReference<
>('aiProviderProfiles:setDefault');
const providerOptions: {
key: string;
value: Provider;
label: string;
authType: AuthType;
}[] = [
{ value: 'openai', label: 'OpenAI API key', authType: 'api_key' },
{ value: 'anthropic', label: 'Anthropic API key', authType: 'api_key' },
{ value: 'google', label: 'Google Gemini API key', authType: 'api_key' },
{ value: 'openrouter', label: 'OpenRouter', authType: 'api_key' },
{ value: 'requesty', label: 'Requesty', authType: 'api_key' },
{ value: 'litellm', label: 'LiteLLM', authType: 'api_key' },
{
key: 'openai',
value: 'openai',
label: 'OpenAI API key',
authType: 'api_key',
},
{
key: 'anthropic',
value: 'anthropic',
label: 'Anthropic API key',
authType: 'api_key',
},
{
key: 'google',
value: 'google',
label: 'Google Gemini API key',
authType: 'api_key',
},
{
key: 'openrouter',
value: 'openrouter',
label: 'OpenRouter',
authType: 'api_key',
},
{
key: 'requesty',
value: 'requesty',
label: 'Requesty',
authType: 'api_key',
},
{ key: 'litellm', value: 'litellm', label: 'LiteLLM', authType: 'api_key' },
{
key: 'cloudflare_ai_gateway',
value: 'cloudflare_ai_gateway',
label: 'Cloudflare AI Gateway',
authType: 'api_key',
},
{
key: 'custom_openai_compatible',
value: 'custom_openai_compatible',
label: 'Custom OpenAI-compatible',
authType: 'api_key',
},
{
key: 'opencode_openai_login',
value: 'opencode_openai_login',
label: 'Codex ChatGPT login',
authType: 'opencode_auth_json',
},
{
key: 'anthropic_oauth_json',
value: 'anthropic',
label: 'Anthropic OAuth (paste credentials)',
authType: 'anthropic_oauth_json',
},
];
const defaultProviderOption: (typeof providerOptions)[number] = {
key: 'openai',
value: 'openai',
label: 'OpenAI API key',
authType: 'api_key',
};
const reasoningOptions: ReasoningEffort[] = [
'none',
'minimal',
@@ -112,17 +158,14 @@ export const AiProviderProfilesPanel = () => {
const removeProfile = useMutation(api.aiProviderProfiles.remove);
const [profileId, setProfileId] = useState<Id<'aiProviderProfiles'>>();
const [name, setName] = useState('OpenAI');
const [provider, setProvider] = useState<Provider>('openai');
const [providerKey, setProviderKey] = useState('openai');
const selectedProvider = useMemo(
() =>
providerOptions.find((option) => option.value === provider) ??
({
value: 'openai',
label: 'OpenAI API key',
authType: 'api_key',
} satisfies (typeof providerOptions)[number]),
[provider],
providerOptions.find((option) => option.key === providerKey) ??
defaultProviderOption,
[providerKey],
);
const provider = selectedProvider.value;
const [secret, setSecret] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [defaultModelValue, setDefaultModelValue] = useState(
@@ -146,7 +189,7 @@ export const AiProviderProfilesPanel = () => {
const reset = () => {
setProfileId(undefined);
setProvider('openai');
setProviderKey('openai');
setSecret('');
setBaseUrl('');
setDefaultModelValue('');
@@ -160,7 +203,16 @@ export const AiProviderProfilesPanel = () => {
const edit = (profile: (typeof profiles)[number]) => {
setProfileId(profile._id);
setName(profile.name);
setProvider(profile.provider as Provider);
setProviderKey(
providerOptions.find(
(option) =>
option.value === profile.provider &&
option.authType === profile.authType,
)?.key ??
providerOptions.find((option) => option.value === profile.provider)
?.key ??
'openai',
);
setSecret('');
setBaseUrl(profile.baseUrl ?? '');
setDefaultModelValue(profile.defaultModel);
@@ -233,10 +285,18 @@ export const AiProviderProfilesPanel = () => {
<Select
value={defaultProfile?._id ?? ''}
onValueChange={async (value) => {
try {
await setDefaultProfile({
profileId: value as Id<'aiProviderProfiles'>,
});
toast.success('Default AI provider updated.');
} catch (error) {
toast.error(
error instanceof Error
? error.message
: 'Could not update default AI provider.',
);
}
}}
>
<SelectTrigger>
@@ -280,8 +340,16 @@ export const AiProviderProfilesPanel = () => {
size='icon'
aria-label='Remove provider'
onClick={async () => {
try {
await removeProfile({ profileId: profile._id });
toast.success('AI provider removed.');
} catch (error) {
toast.error(
error instanceof Error
? error.message
: 'Could not remove AI provider.',
);
}
}}
>
<Trash2 className='size-4' />
@@ -314,16 +382,14 @@ export const AiProviderProfilesPanel = () => {
<div className='grid gap-2'>
<Label>Provider</Label>
<Select
value={provider}
value={providerKey}
onValueChange={(value) => {
const nextProvider = value as Provider;
setProvider(nextProvider);
resetModelOptions(nextProvider);
setName(
providerOptions
.find((option) => option.value === nextProvider)
?.label.replace(' API key', '') ?? 'AI provider',
);
const nextOption =
providerOptions.find((option) => option.key === value) ??
defaultProviderOption;
setProviderKey(nextOption.key);
resetModelOptions(nextOption.value);
setName(nextOption.label.replace(' API key', ''));
}}
>
<SelectTrigger>
@@ -331,7 +397,7 @@ export const AiProviderProfilesPanel = () => {
</SelectTrigger>
<SelectContent>
{providerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<SelectItem key={option.key} value={option.key}>
{option.label}
</SelectItem>
))}
@@ -342,6 +408,8 @@ export const AiProviderProfilesPanel = () => {
<Label>
{selectedProvider.authType === 'opencode_auth_json'
? 'Codex auth JSON'
: selectedProvider.authType === 'anthropic_oauth_json'
? 'Anthropic credentials JSON'
: 'API key'}
</Label>
{selectedProvider.authType === 'opencode_auth_json' ? (
@@ -361,6 +429,23 @@ export const AiProviderProfilesPanel = () => {
be treated like a password.
</p>
</>
) : selectedProvider.authType === 'anthropic_oauth_json' ? (
<>
<Textarea
value={secret}
placeholder='Paste the full .credentials.json contents.'
onChange={(event) => setSecret(event.target.value)}
/>
<p className='text-muted-foreground text-xs'>
Copy your Claude credentials file from{' '}
<code className='bg-muted rounded px-1 py-0.5'>
~/.claude/.credentials.json
</code>
. Spoon writes it into the isolated job workspace as
Claude&apos;s auth cache. It is stored encrypted and should
be treated like a password.
</p>
</>
) : (
<Input
type='password'
@@ -48,6 +48,36 @@ export const GithubIntegrationPanel = () => {
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
{connection && connection.status !== 'active' ? (
<div
className={
connection.status === 'revoked'
? 'rounded-md border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-600'
: 'rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-600'
}
>
<p className='font-medium'>
{connection.status === 'revoked'
? 'GitHub App installation removed'
: 'GitHub access needs re-authorization'}
</p>
<p className='mt-1'>
{connection.status === 'revoked'
? 'Reinstall the app to resume syncing.'
: 'Reconnect the app to resume syncing.'}
</p>
{installUrl ? (
<a
className='mt-2 inline-block underline'
href={installUrl}
target='_blank'
rel='noreferrer'
>
Reconnect GitHub App
</a>
) : null}
</div>
) : null}
{connection ? (
<div className='grid gap-2 text-sm'>
<div>
@@ -1,75 +0,0 @@
const techStack = [
{
category: 'Frontend',
technologies: [
{ name: 'Next.js 16', description: 'React framework with App Router' },
{ name: 'Expo 54', description: 'React Native framework' },
{ name: 'React 19', description: 'Latest React with Server Components' },
{
name: 'Tailwind CSS v4',
description: 'Utility-first CSS framework',
},
{ name: 'shadcn/ui', description: 'Beautiful component library' },
],
},
{
category: 'Backend',
technologies: [
{ name: 'Convex', description: 'Self-hosted reactive backend' },
{
name: '@convex-dev/auth',
description: 'Multi-provider authentication',
},
{ name: 'UseSend', description: 'Self-hosted email service' },
{ name: 'File Storage', description: 'Built-in file uploads' },
],
},
{
category: 'Developer Tools',
technologies: [
{ name: 'Turborepo', description: 'High-performance build system' },
{ name: 'TypeScript', description: 'Type-safe development' },
{ name: 'Bun', description: 'Fast package manager and runtime' },
{ name: 'ESLint + Prettier', description: 'Code quality tools' },
{ name: 'Docker', description: 'Containerized deployment' },
],
},
];
export const TechStack = () => (
<section id='tech-stack' className='border-border/40 bg-muted/30 border-t'>
<div className='container mx-auto px-4 py-24'>
<div className='mx-auto max-w-6xl'>
<div className='mb-16 text-center'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl'>
Modern Tech Stack
</h2>
<p className='text-muted-foreground mx-auto max-w-2xl text-lg'>
Built with the latest and greatest tools for maximum productivity
and performance.
</p>
</div>
<div className='grid gap-12 md:grid-cols-3'>
{techStack.map((stack) => (
<div key={stack.category}>
<h3 className='mb-6 text-xl font-semibold'>{stack.category}</h3>
<ul className='space-y-4'>
{stack.technologies.map((tech) => (
<li key={tech.name}>
<div className='text-foreground font-medium'>
{tech.name}
</div>
<div className='text-muted-foreground text-sm'>
{tech.description}
</div>
</li>
))}
</ul>
</div>
))}
</div>
</div>
</div>
</section>
);
@@ -29,6 +29,10 @@ export const AvatarDropdown = () => {
user?.image && !remoteImageUrl ? { storageId: user.image } : 'skip',
);
const avatarUrl = remoteImageUrl ?? currentImageUrl;
// Empty/whitespace names must fall through to the email, so intentionally
// treat a blank string as "no name" (nullish coalescing would keep it).
const trimmedName = user?.name?.trim();
const displayName = trimmedName?.length ? trimmedName : user?.email?.trim();
if (isLoading) {
return (
@@ -61,16 +65,16 @@ export const AvatarDropdown = () => {
/>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
{(user?.name ?? user?.email) && (
{displayName && (
<>
<DropdownMenuLabel className='text-center font-bold'>
{user.name?.trim() ?? user.email?.trim()}
{displayName}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild>
<Link href='/profile' className='w-full cursor-pointer'>
<Link href='/settings/profile' className='w-full cursor-pointer'>
Edit Profile
</Link>
</DropdownMenuItem>
@@ -4,6 +4,7 @@ import type { ThemeToggleProps } from '@spoon/ui';
import { ThemeToggle } from '@spoon/ui';
import { AvatarDropdown } from './AvatarDropdown';
import { NotificationBell } from './notification-bell';
export const Controls = (themeToggleProps?: ThemeToggleProps) => {
return (
@@ -16,6 +17,7 @@ export const Controls = (themeToggleProps?: ThemeToggleProps) => {
...themeToggleProps?.buttonProps,
}}
/>
<NotificationBell />
<AvatarDropdown />
</div>
);
@@ -0,0 +1,149 @@
'use client';
import Link from 'next/link';
import { Bell } from 'lucide-react';
import { toast } from 'sonner';
import { useConvexAuth, useMutation, useQuery } from 'convex/react';
import { api } from '@spoon/backend/convex/_generated/api.js';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@spoon/ui';
const RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
['year', 365 * 24 * 60 * 60 * 1000],
['month', 30 * 24 * 60 * 60 * 1000],
['day', 24 * 60 * 60 * 1000],
['hour', 60 * 60 * 1000],
['minute', 60 * 1000],
];
const relativeFormatter = new Intl.RelativeTimeFormat(undefined, {
numeric: 'auto',
});
const formatRelativeTime = (timestamp: number): string => {
const delta = timestamp - Date.now();
const magnitude = Math.abs(delta);
for (const [unit, ms] of RELATIVE_UNITS) {
if (magnitude >= ms) {
return relativeFormatter.format(Math.round(delta / ms), unit);
}
}
return 'just now';
};
export const NotificationBell = () => {
const { isAuthenticated } = useConvexAuth();
const unreadCount = useQuery(
api.notifications.unreadCount,
isAuthenticated ? {} : 'skip',
);
const notifications = useQuery(
api.notifications.listMine,
isAuthenticated ? { limit: 10 } : 'skip',
);
const markRead = useMutation(api.notifications.markRead);
const markAllRead = useMutation(api.notifications.markAllRead);
if (!isAuthenticated) {
return null;
}
const count = unreadCount ?? 0;
const badgeLabel = count > 9 ? '9+' : String(count);
const items = notifications ?? [];
// Fire-and-forget: never block navigation on the read write, but surface a
// toast if it fails so we don't silently assume success.
const handleMarkRead = async (notificationId: Id<'notifications'>) => {
try {
await markRead({ notificationId });
} catch {
toast.error('Could not mark the notification as read.');
}
};
const handleMarkAllRead = async () => {
try {
await markAllRead({});
} catch {
toast.error('Could not mark notifications as read.');
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className='relative'
aria-label='Notifications'
>
<Bell className='h-5 w-5' />
{count > 0 && (
<span className='bg-primary text-primary-foreground absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] leading-none font-semibold'>
{badgeLabel}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-80'>
<div className='flex items-center justify-between gap-2 px-2 py-1.5'>
<DropdownMenuLabel className='p-0'>Notifications</DropdownMenuLabel>
{count > 0 && (
<button
type='button'
onClick={() => void handleMarkAllRead()}
className='text-muted-foreground hover:text-foreground cursor-pointer text-xs'
>
Mark all read
</button>
)}
</div>
<DropdownMenuSeparator />
{items.length === 0 ? (
<p className='text-muted-foreground px-2 py-6 text-center text-sm'>
No notifications yet.
</p>
) : (
items.map((notification) => (
<DropdownMenuItem key={notification._id} asChild>
<Link
href={notification.link ?? '#'}
onClick={() => void handleMarkRead(notification._id)}
className='flex w-full cursor-pointer flex-col items-start gap-0.5'
>
<span className='flex w-full items-center gap-2'>
<span className='flex-1 truncate text-sm font-medium'>
{notification.title}
</span>
{notification.readAt === undefined && (
<span
className='bg-primary size-2 shrink-0 rounded-full'
aria-hidden
/>
)}
</span>
<span className='text-muted-foreground line-clamp-2 text-xs'>
{notification.body}
</span>
<span className='text-muted-foreground text-[11px]'>
{formatRelativeTime(notification.createdAt)}
</span>
</Link>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -8,6 +8,7 @@ import {
GitBranch,
LayoutDashboard,
MessagesSquare,
Server,
Settings,
ShieldCheck,
} from 'lucide-react';
@@ -37,6 +38,11 @@ const Header = (headerProps: ComponentProps<'header'>) => {
icon: MessagesSquare,
label: 'Threads',
},
{
href: '/machine',
icon: Server,
label: 'Machine',
},
{
href: '/settings/profile',
icon: Settings,
@@ -0,0 +1,201 @@
'use client';
import type { ReactNode } from 'react';
import { Loader2, Play, RotateCw, Server, Square } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
} from '@spoon/ui';
export type BoxStatus = {
running: boolean;
image: string | null;
startedAt: string | null;
memoryLimitBytes: number | null;
containerName: string;
};
export type LifecycleAction = 'start' | 'stop' | 'restart';
const formatUptime = (startedAt: string | null): string => {
if (!startedAt) return '—';
const started = new Date(startedAt).getTime();
if (Number.isNaN(started)) return '—';
const seconds = Math.max(0, Math.floor((Date.now() - started) / 1000));
const days = Math.floor(seconds / 86_400);
const hours = Math.floor((seconds % 86_400) / 3_600);
const minutes = Math.floor((seconds % 3_600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m`;
return `${seconds}s`;
};
const formatMemory = (bytes: number | null): string => {
if (bytes === null) return 'Unlimited';
const gib = bytes / 1024 ** 3;
return `${gib.toFixed(gib < 10 ? 1 : 0)} GiB`;
};
const Detail = ({ label, value }: { label: string; value: string }) => (
<div className='min-w-0'>
<p className='text-muted-foreground text-[11px] font-medium tracking-wide uppercase'>
{label}
</p>
<p className='truncate font-mono text-sm'>{value}</p>
</div>
);
// Stopping or restarting the box tears down the SAME per-user container that a
// running agent thread job executes in, so both actions are destructive and get
// a confirmation step. Start is safe and stays a plain button.
const ConfirmLifecycleButton = ({
icon,
label,
busy,
title,
description,
confirmLabel,
onConfirm,
}: {
icon: ReactNode;
label: string;
busy: boolean;
title: string;
description: string;
confirmLabel: string;
onConfirm: () => void;
}) => (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button type='button' variant='outline' size='sm' disabled={busy}>
{icon}
{label}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant='destructive' onClick={onConfirm}>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
export const BoxStatusCard = ({
status,
pendingAction,
onAction,
}: {
status: BoxStatus | null | undefined;
pendingAction: LifecycleAction | null;
onAction: (action: LifecycleAction) => void;
}) => {
const loading = status === undefined;
const running = Boolean(status?.running);
const busy = pendingAction !== null;
return (
<Card className='shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4 space-y-0'>
<CardTitle className='flex items-center gap-2 text-base'>
<Server className='size-4' />
My Machine
</CardTitle>
{loading ? (
<Badge variant='outline' className='gap-1'>
<Loader2 className='size-3 animate-spin' />
Checking
</Badge>
) : running ? (
<Badge className='gap-1'>Running</Badge>
) : (
<Badge variant='secondary'>Stopped</Badge>
)}
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid grid-cols-2 gap-4 sm:grid-cols-3'>
<Detail label='Image' value={status?.image ?? '—'} />
<Detail
label='Uptime'
value={running ? formatUptime(status?.startedAt ?? null) : '—'}
/>
<Detail
label='Memory'
value={formatMemory(status?.memoryLimitBytes ?? null)}
/>
</div>
<div className='flex flex-wrap gap-2'>
{running ? (
<>
<ConfirmLifecycleButton
label='Stop'
busy={busy}
icon={
pendingAction === 'stop' ? (
<Loader2 className='size-4 animate-spin' />
) : (
<Square className='size-4' />
)
}
title='Stop this machine?'
description='Stopping the machine shuts down the container it runs in. Any agent thread job currently running in this machine will be interrupted and lose its in-progress work.'
confirmLabel='Stop machine'
onConfirm={() => onAction('stop')}
/>
<ConfirmLifecycleButton
label='Restart'
busy={busy}
icon={
pendingAction === 'restart' ? (
<Loader2 className='size-4 animate-spin' />
) : (
<RotateCw className='size-4' />
)
}
title='Restart this machine?'
description='Restarting the machine recreates the container it runs in. Any agent thread job currently running in this machine will be interrupted and lose its in-progress work.'
confirmLabel='Restart machine'
onConfirm={() => onAction('restart')}
/>
</>
) : (
<Button
type='button'
size='sm'
disabled={busy || loading}
onClick={() => onAction('start')}
>
{pendingAction === 'start' ? (
<Loader2 className='size-4 animate-spin' />
) : (
<Play className='size-4' />
)}
Start
</Button>
)}
</div>
</CardContent>
</Card>
);
};
@@ -0,0 +1,187 @@
'use client';
import { useState } from 'react';
import { useQuery } from 'convex/react';
import { KeyRound, Loader2, UserRound } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
} from '@spoon/ui';
// Mirrors the worker's linuxUsername (runtime/box-user.ts) so the identity
// line shows the actual passwd entry.
const linuxUsername = (spoonUsername: string): string => {
const cleaned = spoonUsername
.toLowerCase()
.replaceAll(/[^a-z0-9_-]/g, '-')
.slice(0, 32);
if (!cleaned) return 'spoon';
return /^[a-z_]/.test(cleaned) ? cleaned : `u${cleaned}`.slice(0, 32);
};
/**
* The box's Linux account: identity (user@host) and the user-settable
* password. Password is write-only — the UI only ever knows whether one is
* set. Saving stores it (encrypted) and applies it to a running box live;
* clearing reverts sudo to passwordless.
*/
export const BoxUserCard = ({ username }: { username: string }) => {
const passwordState = useQuery(api.boxSettings.hasMine, {});
const hasPassword = passwordState?.hasPassword ?? false;
const user = linuxUsername(username || 'spoon');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [saving, setSaving] = useState(false);
const [pendingRestart, setPendingRestart] = useState(false);
const tooShort = password.length > 0 && password.length < 8;
const mismatch = confirm.length > 0 && confirm !== password;
const canSave =
!saving && password.length >= 8 && password.length <= 128 && !mismatch;
const submit = (next: string | null) => {
setSaving(true);
void (async () => {
try {
const response = await fetch('/api/box/set-password', {
method: 'POST',
body: JSON.stringify({ password: next }),
});
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as {
hasPassword: boolean;
applied: boolean;
};
setPassword('');
setConfirm('');
setPendingRestart(!data.applied);
toast.success(
next === null ? 'Password removed.' : 'Password updated.',
);
} catch (error) {
console.error(error);
toast.error('Could not update the box password.');
} finally {
setSaving(false);
}
})();
};
return (
<Card>
<CardHeader>
<CardTitle className='flex items-center gap-2 text-base'>
<UserRound className='size-4' />
Box user
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex flex-wrap items-center gap-x-6 gap-y-2'>
<div>
<p className='text-muted-foreground text-[11px] font-medium tracking-wide uppercase'>
Identity
</p>
<p className='font-mono text-sm'>
{user}@{user}-box
</p>
</div>
<div>
<p className='text-muted-foreground text-[11px] font-medium tracking-wide uppercase'>
Password
</p>
<p className='text-sm'>{hasPassword ? 'Set' : 'Not set'}</p>
</div>
</div>
<p className='text-muted-foreground text-xs'>
Without a password, <code className='font-mono'>sudo</code> works
without prompting. Once you set one, sudo asks for it like a normal
machine.
</p>
<form
className='flex flex-wrap items-end gap-2'
onSubmit={(event) => {
event.preventDefault();
if (canSave) submit(password);
}}
>
<div className='space-y-1'>
<label
className='text-muted-foreground text-xs'
htmlFor='box-password'
>
New password
</label>
<Input
id='box-password'
type='password'
autoComplete='new-password'
value={password}
onChange={(event) => setPassword(event.target.value)}
className='w-56'
/>
</div>
<div className='space-y-1'>
<label
className='text-muted-foreground text-xs'
htmlFor='box-password-confirm'
>
Confirm
</label>
<Input
id='box-password-confirm'
type='password'
autoComplete='new-password'
value={confirm}
onChange={(event) => setConfirm(event.target.value)}
className='w-56'
/>
</div>
<Button type='submit' size='sm' disabled={!canSave}>
{saving ? (
<Loader2 className='size-4 animate-spin' />
) : (
<KeyRound className='size-4' />
)}
{hasPassword ? 'Change password' : 'Set password'}
</Button>
{hasPassword ? (
<Button
type='button'
variant='outline'
size='sm'
disabled={saving}
onClick={() => submit(null)}
>
Remove password
</Button>
) : null}
</form>
{tooShort ? (
<p className='text-destructive text-xs'>
Password must be at least 8 characters.
</p>
) : null}
{mismatch ? (
<p className='text-destructive text-xs'>Passwords do not match.</p>
) : null}
{pendingRestart ? (
<p className='text-muted-foreground text-xs'>
Saved your machine is not running, so it takes effect the next
time it starts.
</p>
) : null}
</CardContent>
</Card>
);
};
@@ -0,0 +1,362 @@
'use client';
import type {
FileResponse,
FileTreeNode,
} from '@/components/agent-workspace/types';
import { useCallback, useEffect, useState } from 'react';
import { CodeEditor } from '@/components/agent-workspace/code-editor';
import { FileTabs } from '@/components/agent-workspace/file-tabs';
import { FileTree } from '@/components/agent-workspace/file-tree';
import { XtermSession } from '@/components/agent-workspace/xterm-session';
import { useQuery } from 'convex/react';
import { FolderTree, SquareTerminal } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
import type { BoxStatus, LifecycleAction } from './box-status-card';
import { BoxStatusCard } from './box-status-card';
import { BoxUserCard } from './box-user-card';
const STATUS_POLL_MS = 10_000;
type OpenFileState = {
path: string;
content: string;
savedContent: string;
loading: boolean;
saving: boolean;
};
export const MachineShell = () => {
const environment = useQuery(api.userEnvironment.getMine, {});
const username = environment?.username ?? '';
const firstName = environment?.firstName;
const [status, setStatus] = useState<BoxStatus | null | undefined>(undefined);
const [pendingAction, setPendingAction] = useState<LifecycleAction | null>(
null,
);
const [tree, setTree] = useState<FileTreeNode | null>(null);
const [files, setFiles] = useState<Record<string, OpenFileState>>({});
const [openFilePaths, setOpenFilePaths] = useState<string[]>([]);
const [activeFilePath, setActiveFilePath] = useState<string>();
const [expandedDirectoryPaths, setExpandedDirectoryPaths] = useState<
string[]
>([]);
const [vimEnabled, setVimEnabled] = useState(false);
const [activeTab, setActiveTab] = useState<'files' | 'terminal'>('files');
const refreshStatus = useCallback(async () => {
try {
const response = await fetch('/api/box/status');
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as { status: BoxStatus };
setStatus(data.status);
} catch (error) {
console.error(error);
setStatus((current) => current ?? null);
}
}, []);
const loadTree = useCallback(async () => {
try {
const response = await fetch('/api/box/tree');
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as { tree: FileTreeNode | null };
setTree(data.tree);
} catch (error) {
console.error(error);
}
}, []);
useEffect(() => {
void refreshStatus();
void loadTree();
const interval = window.setInterval(() => {
void refreshStatus();
}, STATUS_POLL_MS);
return () => window.clearInterval(interval);
}, [refreshStatus, loadTree]);
const runLifecycle = useCallback(
(action: LifecycleAction) => {
if (pendingAction) return;
setPendingAction(action);
void (async () => {
try {
const response = await fetch('/api/box/lifecycle', {
method: 'POST',
body: JSON.stringify({ action }),
});
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as { status: BoxStatus };
setStatus(data.status);
toast.success(
action === 'stop'
? 'Machine stopped.'
: action === 'restart'
? 'Machine restarted.'
: 'Machine started.',
);
void loadTree();
} catch (error) {
console.error(error);
toast.error(`Could not ${action} your machine.`);
void refreshStatus();
} finally {
setPendingAction(null);
}
})();
},
[pendingAction, loadTree, refreshStatus],
);
const loadFile = useCallback(async (path: string) => {
setFiles((current) => ({
...current,
[path]: current[path] ?? {
path,
content: '',
savedContent: '',
loading: true,
saving: false,
},
}));
const response = await fetch(
`/api/box/file?path=${encodeURIComponent(path)}`,
);
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as FileResponse;
setFiles((current) => ({
...current,
[data.path]: {
path: data.path,
content: data.content,
savedContent: data.content,
loading: false,
saving: false,
},
}));
}, []);
const openFile = useCallback(
(path: string) => {
setOpenFilePaths((current) =>
current.includes(path) ? current : [...current, path],
);
setActiveFilePath(path);
if (!files[path]) {
void loadFile(path).catch((error) => {
console.error(error);
setFiles((current) => {
const next = { ...current };
delete next[path];
return next;
});
setOpenFilePaths((current) =>
current.filter((filePath) => filePath !== path),
);
toast.error('Could not load file.');
});
}
},
[files, loadFile],
);
const writeFileContent = async (path: string, content: string) => {
setFiles((current) => ({
...current,
[path]: {
...(current[path] ?? { path, savedContent: '', loading: false }),
content,
saving: true,
},
}));
const response = await fetch('/api/box/file', {
method: 'PUT',
body: JSON.stringify({ path, content }),
});
if (!response.ok) {
setFiles((current) => ({
...current,
[path]: {
...(current[path] ?? {
path,
content,
savedContent: '',
loading: false,
}),
saving: false,
},
}));
toast.error('Could not save file.');
throw new Error(await response.text());
}
setFiles((current) => ({
...current,
[path]: {
...(current[path] ?? { path, loading: false }),
content,
savedContent: content,
saving: false,
},
}));
toast.success('File saved.');
};
const saveFile = async (content: string) => {
if (!activeFilePath) return;
await writeFileContent(activeFilePath, content);
};
const closeFile = (path: string) => {
const index = openFilePaths.indexOf(path);
const nextOpen = openFilePaths.filter((filePath) => filePath !== path);
setOpenFilePaths(nextOpen);
setFiles((current) => {
const next = { ...current };
delete next[path];
return next;
});
if (activeFilePath === path) {
setActiveFilePath(nextOpen[index - 1] ?? nextOpen[index] ?? undefined);
}
};
const toggleDirectory = (path: string) => {
setExpandedDirectoryPaths((current) =>
current.includes(path)
? current.filter((directoryPath) => directoryPath !== path)
: [...current, path],
);
};
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
const running = Boolean(status?.running);
const starting = pendingAction === 'start' || pendingAction === 'restart';
// The terminal only shows this while the box is not running. Distinguish a box
// that is starting/restarting from one that is simply stopped so the label is
// accurate instead of always claiming "Starting…".
const terminalWaitingLabel = starting
? 'Starting your machine…'
: 'Machine is stopped — start it to open a terminal.';
return (
<main className='space-y-6'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>
{firstName ? `${firstName}'s Machine` : 'My Machine'}
</h1>
<p className='text-muted-foreground mt-2'>
Your always-available dev box a persistent home, a terminal, and a
file browser rooted at <code className='font-mono'>~</code>.
</p>
</div>
<BoxStatusCard
status={status}
pendingAction={pendingAction}
onAction={runLifecycle}
/>
<BoxUserCard username={username} />
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as 'files' | 'terminal')}
className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[600px] flex-col overflow-hidden rounded-md border'
>
<TabsList className='border-border bg-muted/30 h-12 flex-none justify-start rounded-none border-b px-3'>
<TabsTrigger
value='files'
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
>
<FolderTree className='size-4' />
Files
</TabsTrigger>
<TabsTrigger
value='terminal'
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
>
<SquareTerminal className='size-4' />
Terminal
</TabsTrigger>
</TabsList>
<TabsContent
value='files'
className='m-0 grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)]'
>
<aside className='border-border bg-background min-h-0 border-r'>
<div className='border-border border-b p-3'>
<h2 className='text-sm font-semibold'>Home</h2>
<p className='text-muted-foreground text-xs'>
Files in your persistent home
</p>
</div>
<FileTree
tree={tree}
selectedPath={activeFilePath}
expandedPaths={expandedDirectoryPaths}
onSelect={openFile}
onToggleDirectory={toggleDirectory}
/>
</aside>
<section className='bg-background flex min-w-0 flex-col overflow-hidden'>
<FileTabs
tabs={openFilePaths.map((path) => ({
path,
dirty: files[path]
? files[path].content !== files[path].savedContent
: false,
}))}
activePath={activeFilePath}
onActivate={setActiveFilePath}
onClose={closeFile}
/>
<CodeEditor
path={activeFilePath}
content={activeFile?.content ?? ''}
savedContent={activeFile?.savedContent ?? ''}
readOnly={false}
vimEnabled={vimEnabled}
onSave={saveFile}
onVimEnabledChange={setVimEnabled}
onChange={(content) => {
if (!activeFilePath) return;
setFiles((current) => ({
...current,
[activeFilePath]: {
...(current[activeFilePath] ?? {
path: activeFilePath,
savedContent: '',
loading: false,
saving: false,
}),
content,
},
}));
}}
/>
</section>
</TabsContent>
<TabsContent
value='terminal'
forceMount
className='m-0 min-h-0 flex-1 overflow-hidden data-[state=inactive]:hidden'
>
<XtermSession
active={running}
visible={activeTab === 'terminal'}
tokenUrl='/api/box/terminal-token'
sessionKey={username}
waitingLabel={terminalWaitingLabel}
label='your machine'
/>
</TabsContent>
</Tabs>
</main>
);
};
@@ -160,6 +160,7 @@ export const DotfilesManager = () => {
const saveSelected = async (next: string) => {
if (!selected) return;
try {
await putFile({
path: selected.path,
content: next,
@@ -167,6 +168,9 @@ export const DotfilesManager = () => {
});
setSavedContent(next);
toast.success('Saved.');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Save failed.');
}
};
const importAll = async (incoming: UploadFile[]) => {
@@ -231,11 +235,15 @@ export const DotfilesManager = () => {
const deleteSelected = async () => {
if (!selected) return;
try {
await removeFile({ fileId: selected._id as never });
setSelected(undefined);
setContent('');
setSavedContent('');
toast.success('Deleted.');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Delete failed.');
}
};
return (
@@ -345,8 +353,12 @@ export const DotfilesManager = () => {
<RepoPanel
settings={settings}
onSave={async (values) => {
try {
await updateEnv(values);
toast.success('Saved.');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Save failed.');
}
}}
/>
@@ -0,0 +1,115 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { toast } from 'sonner';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Card, Label, Switch } from '@spoon/ui';
type PreferenceField =
| 'emailEnabled'
| 'emailMaintenance'
| 'emailAgentTurnFinished'
| 'emailAgentNeedsInput'
| 'emailSyncFailed'
| 'emailConnectionReauth';
const perKindToggles: {
field: Exclude<PreferenceField, 'emailEnabled'>;
label: string;
description: string;
}[] = [
{
field: 'emailMaintenance',
label: 'Maintenance threads',
description: 'A maintenance review thread opens for one of your Spoons.',
},
{
field: 'emailAgentTurnFinished',
label: 'Agent finished a turn',
description: 'An agent finishes a turn and changes are ready to review.',
},
{
field: 'emailAgentNeedsInput',
label: 'Agent needs your input',
description: 'An agent pauses and needs your input to continue.',
},
{
field: 'emailSyncFailed',
label: 'A sync fails',
description: 'A scheduled sync with upstream fails for one of your Spoons.',
},
{
field: 'emailConnectionReauth',
label: 'A connection needs re-authentication',
description: 'A git connection is revoked and needs to be reconnected.',
},
];
export const NotificationPreferencesPanel = () => {
// Render directly from the reactive query: after a save resolves, the query
// re-emits the stored value, so there is no local copy to fall out of sync.
const prefs = useQuery(api.notifications.getPreferences);
const updatePreferences = useMutation(api.notifications.updatePreferences);
const [saving, setSaving] = useState(false);
const onToggle = async (field: PreferenceField, next: boolean) => {
setSaving(true);
try {
await updatePreferences({ [field]: next });
toast.success('Preferences saved.');
} catch {
toast.error('Could not save preferences.');
} finally {
setSaving(false);
}
};
if (prefs === undefined) {
return (
<Card className='p-4 shadow-none'>
<p className='text-muted-foreground text-sm'>Loading preferences</p>
</Card>
);
}
const perKindDisabled = saving || !prefs.emailEnabled;
return (
<Card className='divide-border divide-y p-0 shadow-none'>
<div className='flex items-center justify-between gap-4 p-4'>
<div className='space-y-0.5'>
<Label className='text-sm font-medium'>Email notifications</Label>
<p className='text-muted-foreground text-sm'>
Send email for your notifications. Turn this off to silence every
kind below.
</p>
</div>
<Switch
aria-label='Email notifications'
checked={prefs.emailEnabled}
disabled={saving}
onCheckedChange={(next) => void onToggle('emailEnabled', next)}
/>
</div>
{perKindToggles.map(({ field, label, description }) => (
<div
key={field}
className='flex items-center justify-between gap-4 p-4'
>
<div className='space-y-0.5'>
<Label className='text-sm font-medium'>{label}</Label>
<p className='text-muted-foreground text-sm'>{description}</p>
</div>
<Switch
aria-label={label}
checked={prefs.emailEnabled && prefs[field]}
disabled={perKindDisabled}
onCheckedChange={(next) => void onToggle(field, next)}
/>
</div>
))}
</Card>
);
};

Some files were not shown because too many files have changed in this diff Show More