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"}
This commit is contained in:
Gabriel Brown
2026-07-11 12:13:50 -04:00
parent bf0276b1f3
commit 192f547be5
2 changed files with 104 additions and 3 deletions
+72 -3
View File
@@ -3,6 +3,15 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; 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 { env } from './env';
import { attachTerminalServer } from './terminal'; import { attachTerminalServer } from './terminal';
import { import {
@@ -56,6 +65,11 @@ const jobRoute = (pathname: string) => {
return { jobId: decodeURIComponent(match[1]), action: match[2] }; 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 = () => { export const startWorkerServer = () => {
const server = createServer((request, response) => { const server = createServer((request, response) => {
void (async () => { void (async () => {
@@ -73,6 +87,59 @@ export const startWorkerServer = () => {
sendJson(response, 200, await cleanupOrphanedWorkspaces()); sendJson(response, 200, await cleanupOrphanedWorkspaces());
return; 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); const route = jobRoute(url.pathname);
if (!route) { if (!route) {
sendJson(response, 404, { error: 'Not found' }); sendJson(response, 404, { error: 'Not found' });
@@ -175,9 +242,11 @@ export const startWorkerServer = () => {
const status = const status =
message === 'Unauthorized' message === 'Unauthorized'
? 401 ? 401
: message.includes('not supported') : message.startsWith('Refusing to')
? 409 ? 400
: 500; : message.includes('not supported')
? 409
: 500;
sendJson(response, status, { sendJson(response, status, {
error: message, error: message,
}); });
@@ -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();
});
});