diff --git a/apps/agent-worker/src/terminal-token.ts b/apps/agent-worker/src/terminal-token.ts index 98aeace..90ae713 100644 --- a/apps/agent-worker/src/terminal-token.ts +++ b/apps/agent-worker/src/terminal-token.ts @@ -28,6 +28,18 @@ export const verifyTerminalToken = ( ); }; +// Extract the username a box token authorizes without verifying its signature. +// The token itself authorizes the connection, so the upgrade handler reads the +// username from it (rather than a query param) and then verifies the whole +// token against that username. Returns null unless the format matches +// `${expiresAtMs}.box.${username}.${hmacSha256Hex}`. +export const parseBoxTokenUsername = (token: string): string | null => { + const parts = token.split('.'); + if (parts.length !== 4 || parts[1] !== 'box') return null; + const username = parts[2]; + return username ? username : null; +}; + // User-scoped variant authorizing a terminal connection to a user's box. // Embeds a literal `box` segment so its four parts never collide with the // three-part job token above. Format: diff --git a/apps/agent-worker/src/terminal.ts b/apps/agent-worker/src/terminal.ts index b9d4c62..b3e9a19 100644 --- a/apps/agent-worker/src/terminal.ts +++ b/apps/agent-worker/src/terminal.ts @@ -4,10 +4,16 @@ import type { WebSocket } from 'ws'; import { WebSocketServer } from 'ws'; import type { BoxHandle } from './user-container'; +import { boxHomePaths } from './box'; import { env } from './env'; import { getDockerClient } from './runtime/docker'; -import { verifyTerminalToken } from './terminal-token'; +import { + parseBoxTokenUsername, + verifyBoxTerminalToken, + verifyTerminalToken, +} from './terminal-token'; import { acquireUserBox } from './user-container'; +import { ensureBashProfile } from './user-environment'; import { getTerminalWorkspace } from './worker'; const clampDimension = (value: unknown) => { @@ -37,16 +43,36 @@ export const parseResizeMessage = ( } }; -const bridge = async (ws: WebSocket, jobId: string) => { - const workspace = getTerminalWorkspace(jobId); - if (!workspace) { - ws.close(1011, 'Workspace is not active.'); - return; - } +// The login-shell command shared by the job and box terminals: prefer a +// resumable tmux session, else fall back to a plain interactive login shell. +const SHELL_CMD = [ + '/bin/bash', + '-lc', + 'if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s spoon; else exec bash -il; fi', +]; - // Register the message handler immediately and buffer input/size until the exec - // is ready (acquiring the box can take seconds on first connect), so the initial - // resize and early keystrokes aren't dropped. +type ShellBridgeOptions = { + // Acquire the per-user box (reference-counted); its handle is released on + // cleanup. Called after the input handler is wired so early keystrokes buffer. + acquire: () => Promise; + // Host-side preparation before the shell starts (e.g. seed .bash_profile). + prepare?: () => Promise; + // Directory the login shell starts in (container path). + cwd: string; + // Env entries appended after TERM (e.g. HOME, and job secrets for job terms). + envFlags: string[]; +}; + +/** + * Shared PTY bridge: wire a WebSocket to an interactive login shell running via + * a real TTY (dockerode `exec` with `Tty:true` + `exec.resize`) inside the + * user's box. Registers the input handler immediately and buffers input/size + * until the exec is ready (acquiring the box can take seconds on first connect), + * so the initial resize and early keystrokes aren't dropped. Used by both the + * job terminal (`bridge`) and the box terminal (`bridgeBox`); only the box to + * acquire, the working directory, and the env differ. + */ +const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => { const execHolder: { current?: { exec: import('dockerode').Exec; stream: Duplex }; } = {}; @@ -87,11 +113,8 @@ const bridge = async (ws: WebSocket, jobId: string) => { // the terminal share the exact same container (Phase 2). let boxName: string; try { - const handle = await acquireUserBox({ - username: workspace.username, - workdir: workspace.workdir, - containerHome: workspace.containerHome, - }); + if (opts.prepare) await opts.prepare(); + const handle = await opts.acquire(); handleHolder.current = handle; boxName = handle.boxName; } catch (error) { @@ -115,17 +138,9 @@ const bridge = async (ws: WebSocket, jobId: string) => { AttachStdout: true, AttachStderr: true, Tty: true, - Cmd: [ - '/bin/bash', - '-lc', - 'if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s spoon; else exec bash -il; fi', - ], - Env: [ - 'TERM=xterm-256color', - `HOME=${workspace.containerHome}`, - ...workspace.secrets.map((secret) => `${secret.name}=${secret.value}`), - ], - WorkingDir: workspace.containerRepo, + Cmd: SHELL_CMD, + Env: ['TERM=xterm-256color', ...opts.envFlags], + WorkingDir: opts.cwd, }); const stream = await exec.start({ hijack: true, stdin: true, Tty: true }); execHolder.current = { exec, stream }; @@ -153,10 +168,47 @@ const bridge = async (ws: WebSocket, jobId: string) => { }); }; +// Job terminal: opens the shell at the repo checkout, with the job's selected +// secrets in the environment (it is scoped to that job's active workspace). +const bridge = async (ws: WebSocket, jobId: string) => { + const workspace = getTerminalWorkspace(jobId); + if (!workspace) { + ws.close(1011, 'Workspace is not active.'); + return; + } + await runShellBridge(ws, { + acquire: () => + acquireUserBox({ + username: workspace.username, + workdir: workspace.workdir, + containerHome: workspace.containerHome, + }), + cwd: workspace.containerRepo, + envFlags: [ + `HOME=${workspace.containerHome}`, + ...workspace.secrets.map((secret) => `${secret.name}=${secret.value}`), + ], + }); +}; + +// Box terminal: opens a login shell rooted at the user's home (`~`), with no +// per-job secrets — it is user-scoped, not tied to any job's workspace. +const bridgeBox = async (ws: WebSocket, username: string) => { + const { homeDir, containerHome } = boxHomePaths(username); + await runShellBridge(ws, { + prepare: () => ensureBashProfile(homeDir), + acquire: () => + acquireUserBox({ username, workdir: homeDir, containerHome }), + cwd: containerHome, + envFlags: [`HOME=${containerHome}`], + }); +}; + /** - * 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). + * Attaches the interactive-terminal WebSocket endpoints to the worker's HTTP + * server. Two routes, each authorized by a short-lived token minted by the Next + * app: `/jobs/:jobId/terminal?token=…` (job-scoped, after ownership check) and + * `/box/terminal?token=…` (user-scoped, username read from the token itself). */ export const attachTerminalServer = (server: Server) => { if (env.runtime !== 'docker') return; @@ -164,13 +216,34 @@ export const attachTerminalServer = (server: Server) => { server.on('upgrade', (request, socket, head) => { const url = new URL(request.url ?? '', `http://localhost:${env.httpPort}`); + const token = url.searchParams.get('token') ?? ''; + + // User-scoped box terminal: the token authorizes the connection, so the + // username is read FROM the token, then verified against the whole token. + if (url.pathname === '/box/terminal') { + const username = parseBoxTokenUsername(token); + if ( + !username || + !verifyBoxTerminalToken(token, username, env.terminalSecret) + ) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; + } + wss.handleUpgrade(request, socket, head, (ws) => { + void bridgeBox(ws, username).catch(() => { + if (ws.readyState === ws.OPEN) ws.close(); + }); + }); + return; + } + 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(); diff --git a/apps/agent-worker/src/user-environment.ts b/apps/agent-worker/src/user-environment.ts index da81712..921fb36 100644 --- a/apps/agent-worker/src/user-environment.ts +++ b/apps/agent-worker/src/user-environment.ts @@ -32,6 +32,23 @@ export const fetchUserEnvironment = async ( const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; +/** + * A mounted home has no /etc/skel, so login shells wouldn't source ~/.bashrc. + * Write a minimal `.bash_profile` (only if absent) so an interactive login + * shell — the agent's, or the user's box terminal — loads their environment. + * Shared by `materializeUserHome` and the box terminal bridge. + */ +export const ensureBashProfile = async (homeDir: string): Promise => { + await mkdir(homeDir, { recursive: true }); + const bashProfile = path.join(homeDir, '.bash_profile'); + await readFile(bashProfile, 'utf8').catch(async () => { + await writeFile( + bashProfile, + '# Spoon: load ~/.bashrc for login shells.\n[ -f ~/.bashrc ] && . ~/.bashrc\n', + ); + }); +}; + /** * Materializes the persistent per-user home: a `.bash_profile` so login shells * load `~/.bashrc`; (when configured and changed) a clone of the public dotfiles @@ -47,16 +64,7 @@ export const materializeUserHome = async (args: { redact: (value: string) => string; }): Promise => { const { homeDir, containerHome, boxName, userEnv, redact } = args; - await mkdir(homeDir, { recursive: true }); - - // A mounted home has no /etc/skel, so ensure login shells source ~/.bashrc. - const bashProfile = path.join(homeDir, '.bash_profile'); - await readFile(bashProfile, 'utf8').catch(async () => { - await writeFile( - bashProfile, - '# Spoon: load ~/.bashrc for login shells.\n[ -f ~/.bashrc ] && . ~/.bashrc\n', - ); - }); + await ensureBashProfile(homeDir); if (!userEnv.enabled) return; diff --git a/apps/agent-worker/tests/unit/box-terminal-token.test.ts b/apps/agent-worker/tests/unit/box-terminal-token.test.ts index 147aeaf..2f1b48c 100644 --- a/apps/agent-worker/tests/unit/box-terminal-token.test.ts +++ b/apps/agent-worker/tests/unit/box-terminal-token.test.ts @@ -1,7 +1,10 @@ import { createHmac } from 'node:crypto'; import { describe, expect, test } from 'vitest'; -import { verifyBoxTerminalToken } from '../../src/terminal-token'; +import { + parseBoxTokenUsername, + verifyBoxTerminalToken, +} from '../../src/terminal-token'; const mintBox = (username: string, expiresAt: number, secret: string) => { const payload = `${expiresAt}.box.${username}`; @@ -47,3 +50,27 @@ describe('verifyBoxTerminalToken', () => { expect(verifyBoxTerminalToken(jobToken, 'alice', secret)).toBe(false); }); }); + +describe('parseBoxTokenUsername', () => { + test('extracts the username from a well-formed box token', () => { + const token = mintBox('alice', Date.now() + 60_000, 'test-secret'); + expect(parseBoxTokenUsername(token)).toBe('alice'); + }); + + test('returns null for a 3-part job token', () => { + expect(parseBoxTokenUsername('123.alice.sig')).toBeNull(); + }); + + test('returns null when the literal box segment is missing', () => { + expect(parseBoxTokenUsername('123.notbox.alice.sig')).toBeNull(); + }); + + test('returns null for an empty username segment', () => { + expect(parseBoxTokenUsername('123.box..sig')).toBeNull(); + }); + + test('returns null for garbage input', () => { + expect(parseBoxTokenUsername('garbage')).toBeNull(); + expect(parseBoxTokenUsername('')).toBeNull(); + }); +});