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
+4
View File
@@ -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:",
+13
View File
@@ -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(),
+6
View File
@@ -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 ??
+2
View File
@@ -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}`,
+29
View File
@@ -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)
);
};
+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.');
};
+11
View File
@@ -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);
});
});