From 192f547be5bc4234a5ca589e214d13769de5eb64 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sat, 11 Jul 2026 12:10:38 -0400 Subject: [PATCH] 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"} --- apps/agent-worker/src/server.ts | 75 ++++++++++++++++++- .../agent-worker/tests/unit/box-route.test.ts | 32 ++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 apps/agent-worker/tests/unit/box-route.test.ts diff --git a/apps/agent-worker/src/server.ts b/apps/agent-worker/src/server.ts index 89574dc..a65ea78 100644 --- a/apps/agent-worker/src/server.ts +++ b/apps/agent-worker/src/server.ts @@ -3,6 +3,15 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; +import { + getBoxStatus, + listBoxTree, + readBoxFile, + restartBox, + startBox, + stopBox, + writeBoxFile, +} from './box'; import { env } from './env'; import { attachTerminalServer } from './terminal'; import { @@ -56,6 +65,11 @@ const jobRoute = (pathname: string) => { return { jobId: decodeURIComponent(match[1]), action: match[2] }; }; +export const boxRoute = (pathname: string) => { + const match = /^\/box\/(status|lifecycle|tree|file)$/.exec(pathname); + return match?.[1] ? { action: match[1] } : null; +}; + export const startWorkerServer = () => { const server = createServer((request, response) => { void (async () => { @@ -73,6 +87,59 @@ 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; + } + sendJson(response, 404, { error: 'Not found' }); + return; + } + const route = jobRoute(url.pathname); if (!route) { sendJson(response, 404, { error: 'Not found' }); @@ -175,9 +242,11 @@ export const startWorkerServer = () => { const status = message === 'Unauthorized' ? 401 - : message.includes('not supported') - ? 409 - : 500; + : message.startsWith('Refusing to') + ? 400 + : message.includes('not supported') + ? 409 + : 500; sendJson(response, status, { error: message, }); diff --git a/apps/agent-worker/tests/unit/box-route.test.ts b/apps/agent-worker/tests/unit/box-route.test.ts new file mode 100644 index 0000000..09c1a9e --- /dev/null +++ b/apps/agent-worker/tests/unit/box-route.test.ts @@ -0,0 +1,32 @@ +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 four 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' }); + }); + + 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(); + }); +});