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:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user