Worker: interactive terminal WebSocket bridge (PTY in workspace container)
- attachTerminalServer() upgrades /jobs/:id/terminal WS connections, verifying a short-lived job-scoped HMAC token (verifyTerminalToken) so the browser never holds the worker secret - Bridges the socket to a bash PTY via dockerode exec (Tty) in a persistent per-job shell container (spoon-agent-term-<id>) mounting the workspace; binary frames = stdin, JSON text frames = resize; idle containers reaped after 30m - New env: SPOON_AGENT_TERMINAL_IMAGE/SECRET/IDLE_MS (secret falls back to the shared worker internal token)
This commit is contained in:
@@ -19,14 +19,18 @@
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@opencode-ai/sdk": "latest",
|
||||
"convex": "catalog:convex",
|
||||
"dockerode": "^4.0.7",
|
||||
"execa": "latest",
|
||||
"ws": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@spoon/eslint-config": "workspace:*",
|
||||
"@spoon/prettier-config": "workspace:*",
|
||||
"@spoon/tsconfig": "workspace:*",
|
||||
"@types/dockerode": "^3.3.42",
|
||||
"@types/node": "catalog:",
|
||||
"@types/ws": "^8.18.1",
|
||||
"eslint": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
|
||||
@@ -32,6 +32,19 @@ export const env = {
|
||||
: 'network',
|
||||
jobImage:
|
||||
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
|
||||
// Interactive terminal: image for the persistent shell container (defaults to
|
||||
// the job image), the secret shared with the Next app for verifying terminal
|
||||
// tokens, and how long an idle terminal container survives before cleanup.
|
||||
terminalImage:
|
||||
process.env.SPOON_AGENT_TERMINAL_IMAGE?.trim() ??
|
||||
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ??
|
||||
'spoon-agent-job:latest',
|
||||
terminalSecret:
|
||||
process.env.SPOON_AGENT_TERMINAL_SECRET?.trim() ??
|
||||
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN?.trim() ??
|
||||
process.env.SPOON_WORKER_TOKEN?.trim() ??
|
||||
'',
|
||||
terminalIdleMs: intEnv('SPOON_AGENT_TERMINAL_IDLE_MS', 1_800_000),
|
||||
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
|
||||
hostWorkdir: process.env.SPOON_AGENT_HOST_WORKDIR?.trim(),
|
||||
network: process.env.SPOON_AGENT_NETWORK?.trim(),
|
||||
|
||||
@@ -74,6 +74,12 @@ const hostWorkspacePath = (workdir: string) => {
|
||||
return path.join(env.hostWorkdir, relative);
|
||||
};
|
||||
|
||||
export const containerVolumeSuffix = () =>
|
||||
env.containerVolumeOptions ??
|
||||
(containerRuntime().endsWith('podman') ? 'Z' : undefined);
|
||||
|
||||
export { hostWorkspacePath };
|
||||
|
||||
export const jobWorkspaceVolumeSpec = (workdir: string) => {
|
||||
const volumeOptions =
|
||||
env.containerVolumeOptions ??
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
|
||||
import { env } from './env';
|
||||
import { attachTerminalServer } from './terminal';
|
||||
import {
|
||||
abortWorkspaceAgent,
|
||||
cleanupOrphanedWorkspaces,
|
||||
@@ -182,6 +183,7 @@ export const startWorkerServer = () => {
|
||||
}
|
||||
})();
|
||||
});
|
||||
attachTerminalServer(server);
|
||||
server.listen(env.httpPort, () => {
|
||||
console.log(
|
||||
`Spoon agent worker HTTP server listening on port ${env.httpPort}`,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
// Short-lived, job-scoped token authorizing a browser terminal connection.
|
||||
// Minted server-side by the Next app (which has verified job ownership) and
|
||||
// verified here so the browser never sees the shared worker secret. Format:
|
||||
// `${expiresAtMs}.${jobId}.${hmacSha256Hex}`
|
||||
const signature = (payload: string, secret: string) =>
|
||||
createHmac('sha256', secret).update(payload).digest('hex');
|
||||
|
||||
export const verifyTerminalToken = (
|
||||
token: string,
|
||||
jobId: string,
|
||||
secret: string,
|
||||
): boolean => {
|
||||
if (!token || !secret) return false;
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return false;
|
||||
const [expRaw, tokenJobId, provided] = parts;
|
||||
if (tokenJobId !== jobId) return false;
|
||||
const exp = Number.parseInt(expRaw ?? '', 10);
|
||||
if (!Number.isFinite(exp) || Date.now() > exp) return false;
|
||||
const expected = signature(`${expRaw}.${tokenJobId}`, secret);
|
||||
const providedBuf = Buffer.from(provided ?? '', 'hex');
|
||||
const expectedBuf = Buffer.from(expected, 'hex');
|
||||
return (
|
||||
providedBuf.length === expectedBuf.length &&
|
||||
timingSafeEqual(providedBuf, expectedBuf)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { Server } from 'node:http';
|
||||
import type { Duplex } from 'node:stream';
|
||||
import Docker from 'dockerode';
|
||||
import { WebSocketServer, type WebSocket } from 'ws';
|
||||
|
||||
import { env } from './env';
|
||||
import { containerVolumeSuffix, hostWorkspacePath } from './runtime/docker';
|
||||
import { verifyTerminalToken } from './terminal-token';
|
||||
import { getTerminalWorkspace } from './worker';
|
||||
|
||||
const TERMINAL_IMAGE = env.terminalImage;
|
||||
const IDLE_STOP_MS = env.terminalIdleMs;
|
||||
const CONTAINER_WORKDIR = '/workspace/repo';
|
||||
|
||||
const docker = new Docker();
|
||||
|
||||
const containerName = (jobId: string) =>
|
||||
`spoon-agent-term-${jobId.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
|
||||
|
||||
type Session = { connections: number; idleTimer?: NodeJS.Timeout };
|
||||
const sessions = new Map<string, Session>();
|
||||
|
||||
const ensureTerminalContainer = async (jobId: string, workdir: string) => {
|
||||
const name = containerName(jobId);
|
||||
const container = docker.getContainer(name);
|
||||
const info = await container.inspect().catch(() => null);
|
||||
if (info?.State.Running) return container;
|
||||
if (info && !info.State.Running) {
|
||||
await container.remove({ force: true }).catch(() => undefined);
|
||||
}
|
||||
const suffix = containerVolumeSuffix();
|
||||
const source = hostWorkspacePath(workdir);
|
||||
const created = await docker.createContainer({
|
||||
name,
|
||||
Image: TERMINAL_IMAGE,
|
||||
Cmd: ['sleep', 'infinity'],
|
||||
WorkingDir: CONTAINER_WORKDIR,
|
||||
Tty: false,
|
||||
Labels: { 'spoon.agent.terminal': jobId },
|
||||
HostConfig: {
|
||||
Binds: [`${source}:/workspace${suffix ? `:${suffix}` : ''}`],
|
||||
NetworkMode: env.network,
|
||||
Memory: 4 * 1024 * 1024 * 1024,
|
||||
AutoRemove: false,
|
||||
},
|
||||
});
|
||||
await created.start();
|
||||
return created;
|
||||
};
|
||||
|
||||
const stopTerminalContainer = async (jobId: string) => {
|
||||
await docker
|
||||
.getContainer(containerName(jobId))
|
||||
.remove({ force: true })
|
||||
.catch(() => undefined);
|
||||
sessions.delete(jobId);
|
||||
};
|
||||
|
||||
const scheduleIdleStop = (jobId: string) => {
|
||||
const session = sessions.get(jobId);
|
||||
if (!session || session.connections > 0) return;
|
||||
session.idleTimer = setTimeout(() => {
|
||||
void stopTerminalContainer(jobId);
|
||||
}, IDLE_STOP_MS);
|
||||
};
|
||||
|
||||
const bridge = async (ws: WebSocket, jobId: string) => {
|
||||
const workspace = getTerminalWorkspace(jobId);
|
||||
if (!workspace) {
|
||||
ws.close(1011, 'Workspace is not active.');
|
||||
return;
|
||||
}
|
||||
|
||||
const session = sessions.get(jobId) ?? { connections: 0 };
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
session.idleTimer = undefined;
|
||||
session.connections += 1;
|
||||
sessions.set(jobId, session);
|
||||
|
||||
let stream: Duplex | undefined;
|
||||
let exec: Docker.Exec | undefined;
|
||||
try {
|
||||
const container = await ensureTerminalContainer(jobId, workspace.workdir);
|
||||
exec = await container.exec({
|
||||
Cmd: ['/bin/bash', '-l'],
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Tty: true,
|
||||
WorkingDir: CONTAINER_WORKDIR,
|
||||
Env: [
|
||||
'TERM=xterm-256color',
|
||||
...workspace.secrets.map((s) => `${s.name}=${s.value}`),
|
||||
],
|
||||
});
|
||||
stream = await exec.start({ hijack: true, stdin: true, Tty: true });
|
||||
} catch (error) {
|
||||
ws.close(
|
||||
1011,
|
||||
`Failed to start terminal: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||
);
|
||||
session.connections -= 1;
|
||||
scheduleIdleStop(jobId);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeStream = stream;
|
||||
const activeExec = exec;
|
||||
|
||||
activeStream.on('data', (chunk: Buffer) => {
|
||||
if (ws.readyState === ws.OPEN) ws.send(chunk, { binary: true });
|
||||
});
|
||||
activeStream.on('end', () => ws.close());
|
||||
activeStream.on('error', () => ws.close());
|
||||
|
||||
ws.on('message', (data: Buffer, isBinary: boolean) => {
|
||||
if (isBinary) {
|
||||
activeStream.write(data);
|
||||
return;
|
||||
}
|
||||
// Text frames are control messages (resize); anything else is treated as
|
||||
// input for resilience.
|
||||
try {
|
||||
const message = JSON.parse(data.toString('utf8')) as {
|
||||
type?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
};
|
||||
if (message.type === 'resize' && message.cols && message.rows) {
|
||||
void activeExec.resize({ w: message.cols, h: message.rows });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through: treat as raw input
|
||||
}
|
||||
activeStream.write(data);
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
activeStream.end();
|
||||
const current = sessions.get(jobId);
|
||||
if (current) {
|
||||
current.connections = Math.max(0, current.connections - 1);
|
||||
scheduleIdleStop(jobId);
|
||||
}
|
||||
};
|
||||
ws.on('close', cleanup);
|
||||
ws.on('error', cleanup);
|
||||
};
|
||||
|
||||
/**
|
||||
* Attaches the interactive-terminal WebSocket endpoint to the worker's HTTP
|
||||
* server. Browser connects to `/jobs/:jobId/terminal?token=…` with a short-lived
|
||||
* token minted by the Next app (which has already verified job ownership).
|
||||
*/
|
||||
export const attachTerminalServer = (server: Server) => {
|
||||
if (env.runtime !== 'docker') return;
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
const url = new URL(request.url ?? '', `http://localhost:${env.httpPort}`);
|
||||
const match = /^\/jobs\/([^/]+)\/terminal$/.exec(url.pathname);
|
||||
if (!match?.[1]) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const jobId = decodeURIComponent(match[1]);
|
||||
const token = url.searchParams.get('token') ?? '';
|
||||
if (!verifyTerminalToken(token, jobId, env.terminalSecret)) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
void bridge(ws, jobId);
|
||||
});
|
||||
});
|
||||
|
||||
console.log('Spoon agent worker terminal WebSocket endpoint enabled.');
|
||||
};
|
||||
@@ -1462,6 +1462,17 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => {
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
// Non-throwing accessor for the interactive terminal: returns the active
|
||||
// workspace's mount info, or null when the workspace is not active here.
|
||||
export const getTerminalWorkspace = (jobId: string) => {
|
||||
const workspace = activeWorkspaces.get(jobId);
|
||||
if (!workspace) return null;
|
||||
return {
|
||||
workdir: workspace.workdir,
|
||||
secrets: workspace.claim.secrets,
|
||||
};
|
||||
};
|
||||
|
||||
export const getWorkspaceAgentStatus = (jobId: string) => {
|
||||
const workspace = resolveWorkspace(jobId);
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { verifyTerminalToken } from '../../src/terminal-token';
|
||||
|
||||
const mint = (jobId: string, expiresAt: number, secret: string) => {
|
||||
const payload = `${expiresAt}.${jobId}`;
|
||||
const sig = createHmac('sha256', secret).update(payload).digest('hex');
|
||||
return `${payload}.${sig}`;
|
||||
};
|
||||
|
||||
describe('verifyTerminalToken', () => {
|
||||
const secret = 'test-secret';
|
||||
|
||||
test('accepts a valid, unexpired, job-matched token', () => {
|
||||
const token = mint('job1', Date.now() + 60_000, secret);
|
||||
expect(verifyTerminalToken(token, 'job1', secret)).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects an expired token', () => {
|
||||
const token = mint('job1', Date.now() - 1, secret);
|
||||
expect(verifyTerminalToken(token, 'job1', secret)).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects a token minted for another job', () => {
|
||||
const token = mint('job1', Date.now() + 60_000, secret);
|
||||
expect(verifyTerminalToken(token, 'job2', secret)).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects a token signed with a different secret', () => {
|
||||
const token = mint('job1', Date.now() + 60_000, 'other-secret');
|
||||
expect(verifyTerminalToken(token, 'job1', secret)).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects malformed input and an empty secret', () => {
|
||||
expect(verifyTerminalToken('garbage', 'job1', secret)).toBe(false);
|
||||
expect(verifyTerminalToken('', 'job1', secret)).toBe(false);
|
||||
expect(
|
||||
verifyTerminalToken(mint('job1', Date.now() + 1000, ''), 'job1', ''),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user