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:
Gabriel Brown
2026-06-24 08:16:39 -04:00
parent 1072cf10cd
commit c1263b2e69
10 changed files with 388 additions and 7 deletions
+180
View File
@@ -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.');
};