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.
40 KiB
Spoon Phase 3 — Dev Box as a First-Class Surface: Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Give every user a standalone "My Machine" page at /machine that exposes their persistent per-user Fedora box directly: live status + start/stop/restart controls, a full-page terminal rooted at ~, and a home-scoped file browser (reuse FileTree/CodeEditor). Make the workspace terminal survive tab switches, and add terminal QoL (web-links, copy-on-select). The box is USER-scoped (there is no jobId): the browser reaches it only through Next routes that mint short-lived HMAC tokens after Convex auth resolves the caller's own box username server-side — the browser never sees a worker secret and can only ever reach its OWN box.
Architecture: Same ownership-proxy pattern already used for /jobs/:id/*, replicated for a user-scoped /box/* surface:
- Worker (Bun/dockerode/ws): new user-scoped HMAC terminal token (
terminal-token.ts), a box helper module (box.ts) for home-path derivation + home-scoped file access + status, HTTP routesGET /box/status,POST /box/lifecycle,GET /box/tree,GET|PUT /box/file, and a WS routeGET /box/terminal. All authed by the internal bearer token (HTTP) or the signed user token (WS), which embeds the username. - Next (App Router):
agent-worker-proxy.tsgainsmintBoxTerminalToken(username),resolveBoxUsername(), andproxyBox(...);/api/box/*route handlers derive the caller's username from Convex auth and proxy to the worker; a client/machinepage reuses an extractedXtermSessioncomponent plusFileTree+CodeEditor.
Tech Stack: Next.js 16 (React 19), Convex, agent-worker (Bun, dockerode, ws, xterm).
Depends on: Phase 1 (terminal sizing fix + box handle/mutex lifecycle). Phase 3 assumes the Phase-1 acquireUserBox handle API (see "Phase-1 assumptions" below) and the Phase-1 realpath-based path containment. It benefits from Phase 2 (one-box-three-agents) but does not require it: the /box surface only needs the persistent box to exist, not any particular agent runtime.
Phase-1 assumptions (read before starting)
Phase 1 reworks apps/agent-worker/src/user-container.ts to a per-username mutex + handle API. This plan is written against:
// apps/agent-worker/src/user-container.ts (Phase-1 shape)
export type BoxHandle = { boxName: string; release: () => void }; // release is idempotent
export const acquireUserBox: (args: {
username: string; workdir: string; containerHome: string;
}) => Promise<BoxHandle>;
If Phase 1 kept the pre-Phase-1 signature (acquireUserBox(...) => Promise<string> + releaseUserBox(username), as in commit b092955), adapt every handle.release() in this plan to releaseUserBox(username) and treat the returned string as boxName. Check the real signature in user-container.ts before writing Task 5, and follow it.
Global Constraints
- Next tests: jsdom + @testing-library/react. Component tests live in
apps/next/tests/component/*.test.tsx, unit tests inapps/next/tests/unit/*.test.ts. Runcd apps/next && bun run test:componentandbun run test:unit. Mockconvex/react,next/navigation, andsonnerexactly asapps/next/tests/component/render.test.tsx:10-38does. Monaco (CodeEditor) and xterm do not render under jsdom — mock those child components in page tests (see Task 9). - Worker tests:
apps/agent-worker/tests/unit/*.test.ts(vitest). Runcd apps/agent-worker && bun run test:unit. Matchapps/agent-worker/tests/unit/terminal-token.test.tsfor token tests. Docker-dependent code (status/lifecycle/exec) is not unit-testable without a daemon — document manual verification instead; unit-test only the pure logic (path containment, token parsing, tree building against a real tmpdir). - Conventional commits, one per task (e.g.
feat(worker): user-scoped box terminal token). Commit only after the task's tests pass. - Browser NEVER receives a worker token. Tokens are minted server-side in Next route handlers after Convex auth. The username is derived server-side from the authed user (
api.userEnvironment.getMine), never taken from the request body/query — so a user can only ever reach their own box. - Reuse, don't duplicate. Extract the xterm wiring from
workspace-terminal.tsxinto a sharedXtermSession(Task 8) before building the box terminal; reuseFileTreeandCodeEditorverbatim on/machine. ReusesafeHomeJoin-style realpath containment for/box/file.
Reference facts (verified against the codebase)
- Job terminal token (
apps/agent-worker/src/terminal-token.ts): format${expiresAtMs}.${jobId}.${hmacHex}, HMAC over${expiresAtMs}.${jobId}. Minted inapps/next/src/lib/agent-worker-proxy.ts:20(mintTerminalToken). Secret precedence (both sides):SPOON_AGENT_TERMINAL_SECRET→SPOON_AGENT_WORKER_INTERNAL_TOKEN→SPOON_WORKER_TOKEN. Worker reads it asenv.terminalSecret(apps/agent-worker/src/env.ts:43-47); Next builds it interminalSecret()(agent-worker-proxy.ts:12-15). - Worker HTTP auth:
requireAuth(apps/agent-worker/src/server.ts:45-51) compares theAuthorization: Bearerheader toenv.internalToken. Routes are dispatched instartWorkerServer(server.ts:59-186); only/health,/cleanup, and/jobs/:id/*exist today. ThejobRouteregex (server.ts:53-57) is the pattern to mirror for/box/*. - Worker WS:
attachTerminalServer(terminal.ts:157-181) handlesserver.on('upgrade'), matches/^\/jobs\/([^/]+)\/terminal$/, verifies the token, then callsbridge(ws, jobId). Only enabled whenenv.runtime === 'docker'. bridge()(terminal.ts:21-150): resolves the workspace viagetTerminalWorkspace(jobId), acquires the box viaacquireUserBox, then spawns<runtime> exec -i -e TERM=... -e HOME=... -w <containerRepo> <box> /bin/bash -lc 'exec script -qfc <launcher> /dev/null'. The launcher doesstty rows.. cols..thenexec tmux new-session -A -s spoon(orbash -il). Input/resize buffered until the exec is ready;cleanup()kills the proc and releases the box.- Box home layout (
apps/agent-worker/src/worker.ts:1315-1334):username = userEnv?.username ?? 'user';homeDir = path.resolve(env.workdir, 'homes', username);containerHome = path.posix.join('/home', username). The box is acquired with{ username, workdir: homeDir, containerHome }. - Box container (
apps/agent-worker/src/runtime/docker.ts):userContainerName(username)=spoon-box-<sanitized>(docker.ts:338-339);ensureUserContainer({username,workdir,containerHome})startsspoon-box-*with--memory 4g --cpus 2mounting the home (docker.ts:341-383);stopWorkspaceContainer(name)=rm -f(docker.ts:438-442);inspectWorkspaceContainer(name)runsinspect(docker.ts:444-453). Container runtime binary =containerRuntime(). - Username resolution (server):
api.userEnvironment.getMine(packages/backend/convex/userEnvironment.ts:15-34) returns{ enabled, username, firstName, dotfilesRepoUrl, dotfilesRepoRef, setupCommand }whereusername = settings?.homeUsername ?? deriveHomeUsername(user?.name)— the exact value the worker uses. This is the authoritative source for the caller's box username on the Next side. - Existing file/tree worker fns (
worker.ts):listWorkspaceTree(1440),readWorkspaceFile(1477),writeWorkspaceFile(1483) — model the box variants on these but root athomeDirand use realpath containment. - Reusable UI:
FileTree(apps/next/src/components/agent-workspace/file-tree.tsx) props{ tree: FileTreeNode|null; selectedPath?; expandedPaths: string[]; onSelect; onToggleDirectory }.CodeEditor(code-editor.tsx:33-51) props{ path?; content; savedContent; readOnly; vimEnabled; onSave; onChange; onVimEnabledChange }.FileTreeNode,FileResponse,DiffResponselive inagent-workspace/types.ts. Nav items are declared inapps/next/src/components/layout/header/index.tsx:23-45(NavItem={ href; icon; label; external? }).
Task 1: Worker — user-scoped box terminal token (mint helper is Next-side; verify is worker-side)
Add a user-scoped token alongside the existing job-scoped one. It must be unambiguous from the 3-part job token, so it embeds a literal box segment.
Files:
apps/agent-worker/src/terminal-token.ts— addverifyBoxTerminalToken; keepverifyTerminalTokenunchanged.apps/agent-worker/tests/unit/box-terminal-token.test.ts— new test.
Interfaces:
- Token format (Produces/Consumes):
${expiresAtMs}.box.${username}.${hmacSha256Hex}, HMAC over the string${expiresAtMs}.box.${username}. Four dot-separated parts (job token has three), so the two can never collide. Username is[a-z0-9_-]+(already sanitized byderiveHomeUsername) and therefore never contains.. - Produces:
Returns
export const verifyBoxTerminalToken = ( token: string, username: string, secret: string, ): boolean;trueonly when: token has exactly 4 parts, part[1] ==='box', part[2] ===username, expiry not passed, and the HMAC (over${parts[0]}.box.${parts[2]}) matches viatimingSafeEqualon equal-length buffers. Empty token or empty secret →false.
Steps:
- Write
apps/agent-worker/tests/unit/box-terminal-token.test.tsmirroringterminal-token.test.ts: a localmintBox(username, expiresAt, secret)helper producing the 4-part token; cases: accepts valid unexpired username-matched token; rejects expired; rejects token minted for another username; rejects wrong secret; rejects malformed/empty; rejects a 3-part job token passed toverifyBoxTerminalToken(cross-scheme confusion guard). - Run
cd apps/agent-worker && bun run test:unit→ FAIL (function missing). - Implement
verifyBoxTerminalTokeninterminal-token.tsreusing the existingsignature()helper. Split on., requireparts.length === 4 && parts[1] === 'box', checkparts[2] === username, parse+check expiry,timingSafeEqual. - Run tests → PASS.
- Commit:
feat(worker): user-scoped box terminal token verification.
Task 2: Worker — box helper module (home paths, status, lifecycle by username)
Centralize per-user box path derivation, status inspection, and lifecycle so both HTTP routes and the WS bridge share one source of truth.
Files:
apps/agent-worker/src/box.ts— new module.apps/agent-worker/src/runtime/docker.ts— addinspectUserBoxStatus.apps/agent-worker/tests/unit/box-paths.test.ts— new test (pure path logic only).
Interfaces:
- Produces (
box.ts):export const boxHomePaths = (username: string) => ({ homeDir: string, // path.resolve(env.workdir, 'homes', username) containerHome: string, // path.posix.join('/home', username) }); export type BoxStatus = { running: boolean; image: string | null; startedAt: string | null; // ISO from container State.StartedAt, null if stopped/absent memoryLimitBytes: number | null; containerName: string; // userContainerName(username) }; export const getBoxStatus = (username: string) => Promise<BoxStatus>; export const startBox = (username: string) => Promise<void>; // ensureUserContainer(boxHomePaths) export const stopBox = (username: string) => Promise<void>; // stop container + drop registry entry export const restartBox = (username: string) => Promise<void>; // stopBox then startBox - Produces (
docker.ts):Implementation:export const inspectUserBoxStatus = (username: string) => Promise<{ running: boolean; image: string | null; startedAt: string | null; memoryLimitBytes: number | null; }>;execa(containerRuntime(), ['inspect','--format','{{.State.Running}}|{{.Config.Image}}|{{.State.StartedAt}}|{{.HostConfig.Memory}}', userContainerName(username)], { reject:false, stdin:'ignore' }). On non-zero exit (no such container) return{ running:false, image:null, startedAt:null, memoryLimitBytes:null }. Parse the pipe-split line;running = field==='true';memoryLimitBytes = Number(field) || null(0 means unlimited → null);startedAtnull when not running. - Consumes:
ensureUserContainer,stopWorkspaceContainer,userContainerName(docker.ts);env.workdir(env.ts).stopBoxmust also clear the in-memory registry so a stopped box isn't considered "held" — addexport const resetBox = (username: string) => voidtouser-container.ts(clears the idle timer and deletes the map entry) and call it fromstopBox.
Steps:
- Add
resetBox(username)touser-container.ts(clearbox.idleTimer,boxes.delete(username)). - Write
apps/agent-worker/tests/unit/box-paths.test.ts: assertboxHomePaths('gib')returnshomeDirending inhomes/gibandcontainerHome === '/home/gib'; assert two usernames produce distinct homeDirs. (Do NOT test docker-touching fns here.) - Run
cd apps/agent-worker && bun run test:unit→ FAIL. - Implement
inspectUserBoxStatusindocker.ts, thenbox.ts(boxHomePaths,getBoxStatuswrappinginspectUserBoxStatus+userContainerName,startBox/stopBox/restartBox). - Run tests → PASS.
- Manual verification (docker required), record in the commit body: with the worker running and a box up,
curl -H "Authorization: Bearer $TOKEN" localhost:3921/box/status?user=<you>(after Task 4) showsrunning:true+ image + startedAt; afterPOST /box/lifecycle {"action":"stop"}status flips torunning:false;startbrings it back. - Commit:
feat(worker): per-user box status and lifecycle helpers.
Task 3: Worker — home-scoped file access (tree/read/write with realpath containment)
Home-scoped equivalents of listWorkspaceTree/readWorkspaceFile/writeWorkspaceFile, rooted at the user's homeDir, hardened against symlink escape (reuse the Phase-1 containment approach: resolve fs.realpath of the parent and re-check containment; reject symlinked write targets).
Files:
apps/agent-worker/src/box.ts— add tree/read/write.apps/agent-worker/tests/unit/box-files.test.ts— new test against a real tmpdir.
Interfaces:
- Produces:
export const listBoxTree = (username: string) => Promise<FileTreeNode>; export const readBoxFile = (username: string, relPath: string) => Promise<string>; export const writeBoxFile = (username: string, relPath: string, content: string) => Promise<{ success: true }>; // internal: safeHomePath(homeDir, relPath) => Promise<string> (realpath-checked absolute path)listBoxTree: walkhomeDir(root nodename: '~',path: ''), skipping a small ignore set:.git,node_modules,.cache,.local/share/Trash,.npm,.bun,.cargo,dist,build,.next. Cap traversal (e.g. skip directories once total node count exceeds ~5000) so a huge home can't hang the request. Node shape matchesFileTreeNodefromagent-workspace/types.ts({ name; path; type: 'file'|'directory'; children? }).readBoxFile/writeBoxFile: resolve viasafeHomePath.writeBoxFilemkdir -pthe parent, then write. No job/diff/event recording (there is no job) — this is the key difference fromwriteWorkspaceFile.safeHomePath: lexical resolve, thenrealpaththe existing ancestor and re-checkstartsWith(homeDir + sep); throwRefusing to access path outside home: <relPath>on escape. For writes, if the target itself exists and is a symlink, reject.
- Consumes:
boxHomePaths,node:fs/promises(stat,readdir,readFile,writeFile,mkdir,realpath),FileTreeNodetype (import from where the worker declares it — checkworker.tsfor the localFileTreeNodetype; if not exported, define a matching local type inbox.ts).
Steps:
- Write
apps/agent-worker/tests/unit/box-files.test.ts: create a tmp home dir (os.tmpdir()+mkdtemp), stubenv.workdirsoboxHomePaths('t').homeDirpoints at it (either setSPOON_AGENT_WORKDIRbefore importing, or testsafeHomePathdirectly by exporting it). Cases:writeBoxFilethenreadBoxFileround-trips;readBoxFile('../../etc/passwd')rejects; a symlink inside home pointing outside is rejected on read AND write;listBoxTreeincludes a created file and excludesnode_modules. - Run
cd apps/agent-worker && bun run test:unit→ FAIL. - Implement
safeHomePath,listBoxTree,readBoxFile,writeBoxFileinbox.ts. - Run tests → PASS.
- Commit:
feat(worker): home-scoped box file tree/read/write with realpath containment.
Task 4: Worker — /box/* HTTP routes
Wire the box helpers into the HTTP server, authed by the internal bearer token like every other route.
Files:
apps/agent-worker/src/server.ts— add box route dispatch (import from./box).
Interfaces (route → response shape):
GET /box/status?user=<username>→200 { status: BoxStatus }.POST /box/lifecycle?user=<username>body{ action: 'start' | 'stop' | 'restart' }→200 { status: BoxStatus }(return the post-action status; unknown action →400 { error }).GET /box/tree?user=<username>→200 { tree: FileTreeNode }.GET /box/file?user=<username>&path=<rel>→200 { path, content }.PUT /box/file?user=<username>body{ path, content }→200 { success: true }.- Missing/empty
user→400 { error: 'Missing user' }.
Notes: the user query param is trusted here because this route is only reachable with the internal bearer token, and the only caller (Next, Task 7) derives it from Convex auth. Add a boxRoute(pathname) matcher (mirror jobRoute, server.ts:53-57) matching /^\/box\/(status|lifecycle|tree|file)$/, and dispatch inside the existing try/catch in startWorkerServer before the jobRoute block. Read user via url.searchParams.get('user').
Steps:
- (No new unit test — this is HTTP glue over already-tested helpers; verify manually.) Add the
boxRoutematcher and the five handlers inserver.ts, importinggetBoxStatus, startBox, stopBox, restartBox, listBoxTree, readBoxFile, writeBoxFilefrom./box. ReusesendJson/parseJson. cd apps/agent-worker && bun run build(orbun run typecheck) → passes.- Manual verification (record in commit body): with a box for your username,
curl -H "Authorization: Bearer $SPOON_WORKER_TOKEN" 'localhost:3921/box/status?user=<you>'returns status JSON;curl -X PUT ... 'localhost:3921/box/file?user=<you>' -d '{"path":"spoon-test.txt","content":"hi"}'thenGET .../box/file?...&path=spoon-test.txtround-trips;GET .../box/tree?user=<you>lists the home. - Commit:
feat(worker): /box status, lifecycle, tree, and file HTTP routes.
Task 5: Worker — /box/terminal WebSocket bridge
Add a user-scoped terminal WS that acquires the box and opens a login shell rooted at ~. Extract the shared PTY-bridging logic so the job and box bridges don't duplicate the exec/buffer/cleanup dance.
Files:
apps/agent-worker/src/terminal.ts— addbridgeBox, refactor shared exec bridging into a helper, add the/box/terminalupgrade match.
Interfaces:
- WS route:
GET /box/terminal?token=<boxToken>. Verify withverifyBoxTerminalToken(token, username, env.terminalSecret)— but the username comes from the token itself (parse part[2] after a successful format check), since the token is what authorizes the connection. Concretely: parse the token, extract the candidate username, thenif (!verifyBoxTerminalToken(token, candidateUsername, env.terminalSecret)) reject. On success callbridgeBox(ws, candidateUsername). bridgeBox(ws, username): derive{ homeDir, containerHome } = boxHomePaths(username);const handle = await acquireUserBox({ username, workdir: homeDir, containerHome }); ensure a login shell works even for a brand-new home by writing a minimal.bash_profileif absent (reuse the snippet fromuser-environment.ts:62-68, or extract that into a sharedensureBashProfile(homeDir)and call it here); spawn the sameexec -ishell asbridge()but with-w <containerHome>(root at~, not a repo) and envTERM+HOME=<containerHome>only (no per-job secrets — the box terminal is not job-scoped). Reuse the buffered-input + resize + cleanup logic;cleanup()callshandle.release()(orreleaseUserBox(username)per the Phase-1 assumption).
Refactor detail: extract from the current bridge() (terminal.ts:21-150) a helper like:
const runShellBridge = (ws, boxName, opts: { cwd: string; envFlags: string[]; getSize: () => {cols:number;rows:number} }) => { /* buffered input, spawn, forward, exit */ };
so both bridge(ws, jobId) and bridgeBox(ws, username) call it. Keep behavior identical for the job path (regression-guard by leaving its manual smoke test intact).
Steps:
- In
attachTerminalServer(terminal.ts:157-181), add a second upgrade match/^\/box\/terminal$/: readtoken, parse username, verify withverifyBoxTerminalToken, on failure401+ destroy, on successwss.handleUpgrade(... => bridgeBox(ws, username)). Keep the existing/jobs/:id/terminalbranch working. - Implement
bridgeBoxand the sharedrunShellBridge(refactorbridgeto use it). AddensureBashProfile(shared withuser-environment.tsor duplicated minimal). cd apps/agent-worker && bun run test:unit→ existing tests still PASS;bun run build/typecheck passes.- Manual verification (record in commit body): after Task 8/9 the browser flow covers this; for now, mint a box token by hand and connect a
websocat/wscat client tows://localhost:3921/box/terminal?token=..., confirm you land in~(pwdshows/home/<you>), typing works, resize works (stty sizereflects the client), and disconnect releases the box (status idle-reaps afterboxIdleMs). - Commit:
feat(worker): user-scoped /box/terminal websocket bridge.
Task 6: Next — box proxy helpers (mint token, resolve username, proxy)
Server-only helpers that mint the box token and proxy /box/* to the worker, deriving the username from Convex auth so the caller can only reach their own box.
Files:
apps/next/src/lib/agent-worker-proxy.ts— addmintBoxTerminalToken,resolveBoxUsername,proxyBox,withBox.apps/next/tests/unit/box-terminal-token.test.ts— new unit test for the mint format.
Interfaces:
- Produces:
// Mirrors mintTerminalToken (agent-worker-proxy.ts:20). Returns null if secret/WS base unset. export const mintBoxTerminalToken = (username: string): { url: string; expiresAt: number } | null; // payload = `${expiresAt}.box.${username}`; token = `${payload}.${hmacHex}`; // url = `${wsBase}/box/terminal?token=${encodeURIComponent(token)}` (NOTE: /box/terminal, no jobId) // Resolves the authed caller's own box username, or an unauthorized/again response. export const resolveBoxUsername = (): Promise< | { ok: true; username: string } | { ok: false; response: NextResponse } >; // uses convexAuthNextjsToken() then fetchQuery(api.userEnvironment.getMine, {}, { token }) -> .username // Proxies to the worker with the internal token, always appending user=<derived username>. export const proxyBox = ( username: string, action: 'status'|'lifecycle'|'tree'|'file', init?: RequestInit, search?: URLSearchParams, ): Promise<NextResponse>; // Convenience wrapper: resolve username -> run handler, 401/500 on failure (mirror withOwnedJob). export const withBox = ( handler: (username: string) => Promise<Response>, ): Promise<Response>; - Consumes:
terminalSecret()andworkerToken()(existing,agent-worker-proxy.ts:12-15,41-42),env.NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL,env.SPOON_AGENT_WORKER_URL,convexAuthNextjsToken,fetchQuery,api.userEnvironment.getMine.
Steps:
- Write
apps/next/tests/unit/box-terminal-token.test.ts: importcreateHmac, replicate the expected token string for a fixed secret+username+expiry, and assertmintBoxTerminalToken'surlcontains/box/terminal?token=and the token has 4 dot-parts withparts[1]==='box'. To make the secret deterministic, setprocess.env.SPOON_AGENT_TERMINAL_SECRETandNEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URLbefore import (match howapps/next/tests/unit/environment.test.tshandles env). If importingagent-worker-proxy.tspulls inserver-only/Convex and fails under vitest, extract the pure token-building into a tinybuildBoxToken(username, secret, wsBase, now)and unit-test that instead (keepmintBoxTerminalTokena thin wrapper). Prefer the extraction — it keeps the test hermetic. - Run
cd apps/next && bun run test:unit→ FAIL. - Implement the four helpers (+
buildBoxTokenif extracted).resolveBoxUsernamereturns{ ok:false, response: 401 }when no auth token;proxyBoxmirrorsproxyWorker(agent-worker-proxy.ts:95-131) but targets/box/${action}and force-setsuserin the search params from the derived username. - Run tests → PASS.
- Commit:
feat(web): box terminal token mint + ownership proxy helpers.
Task 7: Next — /api/box/* route handlers
App-Router handlers that derive the caller's username and proxy to the worker.
Files (all new):
apps/next/src/app/api/box/status/route.ts—GET.apps/next/src/app/api/box/lifecycle/route.ts—POST.apps/next/src/app/api/box/tree/route.ts—GET.apps/next/src/app/api/box/file/route.ts—GET+PUT.apps/next/src/app/api/box/terminal-token/route.ts—GET.
Interfaces (client contract):
GET /api/box/status→{ status: BoxStatus }.POST /api/box/lifecyclebody{ action:'start'|'stop'|'restart' }→{ status: BoxStatus }.GET /api/box/tree→{ tree: FileTreeNode }.GET /api/box/file?path=<rel>→{ path, content };PUT /api/box/filebody{ path, content }→{ success:true }.GET /api/box/terminal-token→{ url, expiresAt }or503 { error }when unconfigured (mirroragent-jobs/[jobId]/terminal-token/route.ts).- All return
401when unauthenticated (viaresolveBoxUsername/withBox).
Implementation: each handler uses withBox/resolveBoxUsername. Example (tree):
export const GET = async () => await withBox(async (username) => await proxyBox(username, 'tree', { method: 'GET' }));
terminal-token uses resolveBoxUsername then mintBoxTerminalToken(username) (503 if null). file forwards path and request body like agent-jobs/[jobId]/file/route.ts:1-28. lifecycle forwards the JSON body.
Steps:
- Create the five route files following the
agent-jobs/[jobId]/*handlers as templates but withwithBox/resolveBoxUsernameinstead ofwithOwnedJob. cd apps/next && bun run typecheck(or the repo's lint/build) → passes.- Manual verification (record in commit body): signed in, hit
/api/box/statusin the browser/devtools →200with your box status; signed out →401.PUT+GET /api/box/fileround-trips a file into your home. - Commit:
feat(web): /api/box status, lifecycle, tree, file, terminal-token routes.
Task 8: Next — extract a reusable XtermSession component
Factor the xterm wiring out of workspace-terminal.tsx so /machine and the workspace both use one implementation (DRY the WS/fit/fonts/theme logic). This also sets up Task 10's persistence work.
Files:
apps/next/src/components/agent-workspace/xterm-session.tsx— new shared component (move the themes + effect fromworkspace-terminal.tsx:16-209).apps/next/src/components/agent-workspace/workspace-terminal.tsx— becomes a thin wrapper overXtermSession.apps/next/tests/component/xterm-session.test.tsx— new (behavioral, jsdom-limited).
Interfaces:
- Produces:
Preserve current behavior: lazy-import
type XtermStatus = 'idle' | 'connecting' | 'connected' | 'closed' | 'error' | 'unconfigured'; export const XtermSession = (props: { active: boolean; // mount/connect gate tokenUrl: string; // e.g. `/api/box/terminal-token` or `/api/agent-jobs/${jobId}/terminal-token` sessionKey: string; // identity for the connection effect deps (jobId or username) waitingLabel?: string; // shown when active is false-but-expected (see Task 10) copyOnSelect?: boolean; // Task 11 }) => JSX.Element;@xterm/xterm,@xterm/addon-fit,@xterm/addon-web-links; fetchtokenUrlfor{ url }; open WS;fit()afterterm.open(); refit ondocument.fonts.ready/ Nerd-Font load (Phase-1 sizing fix — keep it); theme swap without teardown; reconnect button. Move the twoIThemeobjects into this file. WorkspaceTerminalkeeps its{ jobId, active }signature and renders<XtermSession active={active} tokenUrl={/api/agent-jobs/${jobId}/terminal-token} sessionKey={jobId} />.
Steps:
- Write
apps/next/tests/component/xterm-session.test.tsx. Since xterm can't render in jsdom, mock the three@xterm/*dynamic imports (vi.mock('@xterm/xterm', ...)returning a fakeTerminalclass withopen/loadAddon/onData/onResize/dispose/refreshspies, etc.) and stubglobal.WebSocket. Assert: withactive:falseit renders the waiting label and does NOT construct a WebSocket; withactive:trueit fetchestokenUrland opens a WebSocket to the returnedurl. (Mockfetchto return{ url: 'ws://x' }.) Keep assertions coarse — this is a smoke/behavior guard, not pixel-level. - Run
cd apps/next && bun run test:component→ FAIL. - Extract
XtermSession, rewriteWorkspaceTerminalas the wrapper. Ensure the existing terminal tab still works (agent-workspace-shell.tsx:664-667passesactive). - Run tests → PASS.
- Commit:
refactor(web): extract reusable XtermSession from WorkspaceTerminal.
Task 9: Next — /machine page + nav entry ("My Machine")
The standalone dev-box surface: status card + lifecycle controls, home file browser (FileTree + CodeEditor), and a full-page ~-rooted terminal.
Files (new unless noted):
apps/next/src/app/(app)/machine/page.tsx— the page.apps/next/src/components/machine/machine-shell.tsx— client shell (status card, actions, split file-browser/editor + terminal). Mirrors the load/save/tree logic fromagent-workspace-shell.tsxbut against/api/box/*and with no job concepts.apps/next/src/components/machine/box-status-card.tsx— status + start/stop/restart buttons.apps/next/src/components/layout/header/index.tsx— add the/machinenav item (edit, not new).apps/next/tests/component/machine-shell.test.tsx— new component test.
Interfaces / behavior:
- Nav: add
{ href: '/machine', icon: <lucide icon, e.g.ServerorHardDrive>, label: 'Machine' }to the authenticatednavItemsarray (index.tsx:23-45), placed after Threads. - Status card: fetch
GET /api/box/statuson mount + after each lifecycle action; poll every ~10s. Show running/stopped badge, image, uptime (derive fromstartedAt), memory cap (formatmemoryLimitBytes→ GiB, or "unlimited" when null). Buttons callPOST /api/box/lifecyclewithstart/stop/restart; disable while a request is in flight;toast.erroron failure and onlytoast.successafter the awaited response resolves (per the repo's error-handling principle). Refresh status from the response. - File browser: reuse
FileTree(props exactly asagent-workspace-shell.tsx:574-580) fed byGET /api/box/tree; open files viaGET /api/box/file?path=; edit inCodeEditor; save viaPUT /api/box/file. Reuse theOpenFileState/openFile/loadFile/writeFileContentshapes fromagent-workspace-shell.tsx(copy the minimal subset — no diff/UI-state persistence needed).readOnly={false},vimEnabledlocal state. - Terminal:
<XtermSession active tokenUrl="/api/box/terminal-token" sessionKey={username} waitingLabel="Starting your machine…" />, laid out full-page (own tab or a resizable pane — a RadixTabswith "Files" and "Terminal", or a two-pane layout). The page is~-rooted by construction (workerbridgeBoxusescontainerHome). - Username: the page reads
api.userEnvironment.getMineviauseQueryfor display (firstName/username), but all box access is server-derived — the client never sends the username.
Steps:
- Write
apps/next/tests/component/machine-shell.test.tsx: mockconvex/react(useQuery→{ username:'gib', firstName:'Gib', enabled:true }),next/navigation,sonner, and@/components/agent-workspace/xterm-session(render<div>terminal</div>) and@/components/agent-workspace/code-editor(Monaco won't run in jsdom). Mockglobal.fetchso/api/box/statusreturns a stopped box and/api/box/treereturns a small tree. Assert: the status card renders "Stopped" and a "Start" button; clicking "Start" POSTs/api/box/lifecyclewith{action:'start'}(assert on thefetchmock); the file tree renders a known file; the terminal placeholder renders. - Run
cd apps/next && bun run test:component→ FAIL. - Build
box-status-card.tsx,machine-shell.tsx,machine/page.tsx, and add the nav item. The page is a server component that renders the clientMachineShell(like other(app)pages); auth-gating follows the existing(app)layout — no special guard needed beyond what the layout provides. - Run tests → PASS.
- Manual verification (record in commit body): sign in, open
/machine. (1) Status card shows correct running/stopped + image + uptime + mem cap; Start/Stop/Restart each change the status within a few seconds. (2) Terminal drops you into~(pwd=/home/<you>), typing + resize work, output survives idle. (3) File browser lists your home; open+edit+save a file, confirm it persists (re-open shows the change;catin the terminal confirms). - Commit:
feat(web): /machine dev-box page with status, terminal, and home file browser.
Task 10: Next — persistent workspace terminal (survive tab switches)
Make the workspace Terminal tab keep its PTY/scrollback when switching Radix tabs, refit + resize-send when it becomes visible, and distinguish "waiting for workspace" from "connecting".
Files:
apps/next/src/components/agent-workspace/agent-workspace-shell.tsx— the TerminalTabsContent(currently660-668).apps/next/src/components/agent-workspace/xterm-session.tsx— add refit-on-visible +waitinghandling.apps/next/tests/component/xterm-session.test.tsx— extend from Task 8.
Behavior / interfaces:
- Persistence: give the Terminal
TabsContentforceMountand hide it with CSS when inactive instead of unmounting (RadixTabsContentunmounts by default — that destroys the PTY, audit item #15). Pattern:<TabsContent value='terminal' forceMount className={cn('m-0 min-h-0 flex-1 overflow-hidden', activeWorkspaceTab !== 'terminal' && 'hidden')}>. The other tabs stay as-is. Keep<XtermSession active={workspaceReady} ... />mounted regardless of tab so the WS/PTY persists; use a separatevisibleprop for fit behavior. - Refit on visible: add a
visible: booleanprop toXtermSession. When it transitions totrue, callfitAddon.fit()insiderequestAnimationFrameand send a resize frame (the Phase-1 pattern). Keepactiveas the connect gate (workspaceReady),visibleas the CSS-visibility gate (activeWorkspaceTab === 'terminal'). In the shell, passactive={workspaceReady}andvisible={activeWorkspaceTab === 'terminal'}. - Waiting vs connecting:
XtermSessionstatus starts'idle'; whenactiveis false but the workspace is expected (job pending), showwaitingLabel("Waiting for workspace…") — distinct from'connecting'(token fetched, WS opening). The shell already computesworkspacePending/workspaceReady(agent-workspace-shell.tsx:109-122); pass an appropriatewaitingLabel.
Steps:
- Extend
xterm-session.test.tsx: assert that togglingvisiblefalse→true triggers afit()call on the mocked fit addon and sends a resize frame over the mocked WebSocket; assert that withactive:false+ awaitingLabel, the waiting label renders and no WebSocket is constructed; assert the component stays mounted (WS not closed) whenvisiblegoes true→false whileactivestays true. - Run
cd apps/next && bun run test:component→ FAIL. - Add the
visibleprop + refit effect toXtermSession; updateWorkspaceTerminalto forwardvisible; change the TerminalTabsContentinagent-workspace-shell.tsxtoforceMount+ CSS-hide, and mount<WorkspaceTerminal active={workspaceReady} visible={activeWorkspaceTab==='terminal'} .../>(updateWorkspaceTerminalprops to acceptvisible). - Run tests → PASS.
- Manual verification (record in commit body): open a running thread workspace, go to Terminal, run
top(or type a long-running output), switch to Editor then back — the session and scrollback are intact (not a fresh shell), and the terminal is correctly sized (not quarter-size) on return. Before a worker claims the job, the Terminal tab shows "Waiting for workspace…", not "Connecting…". - Commit:
feat(web): persistent workspace terminal across tab switches.
Task 11: Next — terminal QoL (copy-on-select; confirm web-links)
Web-links is already loaded (workspace-terminal.tsx:144 / carried into XtermSession). Add a copy-on-select toggle available in both terminals.
Files:
apps/next/src/components/agent-workspace/xterm-session.tsx— copy-on-select viaterm.onSelectionChange.apps/next/tests/component/xterm-session.test.tsx— extend.
Interfaces / behavior:
- Add a
copyOnSelect?: booleanprop (already declared in Task 8) with a small toggle control in the terminal header (a button/checkbox). When enabled, onterm.onSelectionChangecopyterm.getSelection()to the clipboard (navigator.clipboard.writeText, guarded — clipboard may be unavailable/denied; swallow errors). Persist the toggle inlocalStorage(spoon.terminal.copyOnSelect) so it sticks across sessions. ConfirmWebLinksAddonis loaded inXtermSession(it is — keep it; it makes URLs clickable).
Steps:
- Extend
xterm-session.test.tsx: mocknavigator.clipboard.writeText; withcopyOnSelecton, simulate the mocked terminal's selection-change callback with a non-empty selection and assertwriteTextwas called with it; with it off, assert not called. Assert the header toggle renders and flipping it updates behavior. - Run
cd apps/next && bun run test:component→ FAIL. - Implement the toggle +
onSelectionChangehandler +localStoragepersistence inXtermSession; ensureWorkspaceTerminaland the/machineterminal both expose it. - Run tests → PASS.
- Manual verification (record in commit body): in
/machineand a workspace terminal, enable copy-on-select, select text → it's on the clipboard; a printed URL is clickable (web-links) and opens in a new tab. - Commit:
feat(web): terminal copy-on-select toggle and web-links polish.
Final verification (run before declaring the phase done)
cd apps/agent-worker && bun run test:unit— all green (token, box-paths, box-files).cd apps/next && bun run test:unit && bun run test:component— all green.- Typecheck/lint per repo scripts for both apps.
- End-to-end manual smoke on a docker-enabled deployment:
/machinestatus+lifecycle+terminal+file-edit all work; workspace terminal persists across tab switches and is correctly sized; a user signed in as A cannot reach user B's box (all/api/box/*derive the username server-side — confirm by inspecting that no request carries a username).
Self-review against the Phase 3 spec
- ✅
/machinepage: status (running/stopped, image, uptime, mem cap), start/stop/restart, full-page~-rooted terminal, home file browser reusing FileTree/CodeEditor — Tasks 9 (+2,3,4 backing). - ✅ Worker user-scoped HMAC tokens +
/box/tree|file|terminal+ status/lifecycle endpoints — Tasks 1–5. - ✅ Same ownership-proxy pattern (Next mints after Convex auth, browser never sees worker secret) — Tasks 6–7; username derived server-side (a user can only reach their own box).
- ✅ Persistent workspace terminal (forceMount + CSS hide, refit-on-visible, waiting-vs-connecting) — Task 10.
- ✅ Terminal QoL (web-links, copy-on-select) — Task 11.
- ✅ Secret precedence + payload around username+expiry — Task 1/6.