Files
spoon/apps/agent-worker/src/server.ts
T
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

263 lines
8.6 KiB
TypeScript

import { createServer } from 'node:http';
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 {
abortWorkspaceAgent,
cleanupOrphanedWorkspaces,
getWorkerHealth,
getWorkspaceAgentStatus,
getWorkspaceDiff,
listWorkspaceTree,
openWorkspacePullRequest,
readWorkspaceFile,
replyToInteraction,
runWorkspaceCommand,
sendWorkspaceMessage,
stopWorkspace,
writeWorkspaceFile,
} from './worker';
const sendJson = (response: ServerResponse, status: number, body: unknown) => {
response.writeHead(status, { 'content-type': 'application/json' });
response.end(JSON.stringify(body));
};
const readBody = async (request: IncomingMessage) =>
await new Promise<string>((resolve, reject) => {
let body = '';
request.on('data', (chunk: Buffer) => {
body += chunk.toString('utf8');
});
request.on('end', () => resolve(body));
request.on('error', reject);
});
const parseJson = async <T>(request: IncomingMessage) => {
const body = await readBody(request);
if (!body.trim()) return {} as T;
return JSON.parse(body) as T;
};
const requireAuth = (request: IncomingMessage) => {
const header = request.headers.authorization;
const token = header?.startsWith('Bearer ') ? header.slice(7) : '';
if (!env.internalToken || token !== env.internalToken) {
throw new Error('Unauthorized');
}
};
const jobRoute = (pathname: string) => {
const match = /^\/jobs\/([^/]+)\/(.+)$/.exec(pathname);
if (!match?.[1] || !match[2]) return null;
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 () => {
try {
requireAuth(request);
const url = new URL(
request.url ?? '/',
`http://localhost:${env.httpPort}`,
);
if (url.pathname === '/health' && request.method === 'GET') {
sendJson(response, 200, await getWorkerHealth());
return;
}
if (url.pathname === '/cleanup' && request.method === 'POST') {
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' });
return;
}
if (request.method === 'GET' && route.action === 'tree') {
sendJson(response, 200, {
tree: await listWorkspaceTree(route.jobId),
});
return;
}
if (request.method === 'GET' && route.action === 'file') {
const filePath = url.searchParams.get('path') ?? '';
sendJson(response, 200, {
path: filePath,
content: await readWorkspaceFile(route.jobId, filePath),
});
return;
}
if (request.method === 'PUT' && route.action === 'file') {
const body = await parseJson<{ path?: string; content?: string }>(
request,
);
sendJson(
response,
200,
await writeWorkspaceFile(
route.jobId,
body.path ?? '',
body.content ?? '',
),
);
return;
}
if (request.method === 'GET' && route.action === 'diff') {
sendJson(response, 200, {
diff: await getWorkspaceDiff(route.jobId),
});
return;
}
if (request.method === 'POST' && route.action === 'message') {
const body = await parseJson<{ content?: string }>(request);
await sendWorkspaceMessage(route.jobId, body.content ?? '');
sendJson(response, 200, { success: true });
return;
}
if (request.method === 'GET' && route.action === 'agent/status') {
sendJson(response, 200, getWorkspaceAgentStatus(route.jobId));
return;
}
if (request.method === 'POST' && route.action === 'agent/abort') {
sendJson(response, 200, await abortWorkspaceAgent(route.jobId));
return;
}
const interactionMatch = /^interactions\/([^/]+)\/reply$/.exec(
route.action,
);
if (request.method === 'POST' && interactionMatch?.[1]) {
const body = await parseJson<{
externalRequestId?: string;
response?: string;
}>(request);
sendJson(
response,
200,
await replyToInteraction(route.jobId, {
interactionId: decodeURIComponent(
interactionMatch[1],
) as Id<'agentInteractionRequests'>,
externalRequestId: body.externalRequestId ?? '',
response: body.response ?? 'once',
}),
);
return;
}
if (request.method === 'POST' && route.action === 'run-command') {
const body = await parseJson<{ command?: string }>(request);
sendJson(
response,
200,
await runWorkspaceCommand(route.jobId, body.command ?? ''),
);
return;
}
if (request.method === 'POST' && route.action === 'open-pr') {
sendJson(response, 200, await openWorkspacePullRequest(route.jobId));
return;
}
if (request.method === 'POST' && route.action === 'stop') {
sendJson(response, 200, await stopWorkspace(route.jobId));
return;
}
sendJson(response, 404, { error: 'Not found' });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(
`Worker HTTP ${request.method ?? 'UNKNOWN'} ${request.url ?? '/'} failed: ${message}`,
);
const status =
message === 'Unauthorized'
? 401
: message.startsWith('Refusing to')
? 400
: message.includes('not supported')
? 409
: 500;
sendJson(response, status, {
error: message,
});
}
})();
});
attachTerminalServer(server);
server.listen(env.httpPort, () => {
console.log(
`Spoon agent worker HTTP server listening on port ${env.httpPort}`,
);
});
};