feat(worker): user-scoped /box/terminal websocket bridge
Add a USER-scoped `/box/terminal` WebSocket that acquires the user's box and opens a login shell rooted at `~`. Extract the shared real-TTY PTY bridging (buffered input, dockerode exec/start, resize, forward, cleanup) out of the job `bridge()` into `runShellBridge`, parameterized by how the box is acquired, the working directory, and the env; `bridge()` and the new `bridgeBox()` both call it so the job path is unchanged. The `/box/terminal` upgrade reads the username FROM the token (`parseBoxTokenUsername`, part[2] of the 4-part token), then verifies the whole token with `verifyBoxTerminalToken`; on success `bridgeBox` derives `boxHomePaths(username)`, seeds a minimal `.bash_profile` via a shared `ensureBashProfile` (extracted from `materializeUserHome`), acquires the box, and opens the shell at `containerHome` with `TERM`+`HOME` only (no per-job secrets). Cleanup releases the box handle. Manual verification (dockerode PTY I/O is daemon-dependent): - `bun run test:unit` 105 passed (incl. new `parseBoxTokenUsername` tests), `bun run typecheck` clean. - Raw upgrade against a live server: valid box token -> `101 Switching Protocols`; bad token -> refused (no upgrade). Job `/jobs/:id/terminal` branch unchanged. - `bridgeBox` path exercised: `acquireUserBox` created `spoon-box-<user>` and `ensureBashProfile` seeded `~/.bash_profile`. Interactive typing/ resize could not be observed on this host because dockerode cannot reach the podman API socket (same mechanism the job terminal uses, so no regression) — full browser flow is covered by later tasks. Claude-Session: https://claude.ai/code/session_01ScyZ1NhfN84LAnHpwhfEQk
This commit is contained in:
@@ -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.
|
// 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
|
// Embeds a literal `box` segment so its four parts never collide with the
|
||||||
// three-part job token above. Format:
|
// three-part job token above. Format:
|
||||||
|
|||||||
@@ -4,10 +4,16 @@ import type { WebSocket } from 'ws';
|
|||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
import type { BoxHandle } from './user-container';
|
import type { BoxHandle } from './user-container';
|
||||||
|
import { boxHomePaths } from './box';
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import { getDockerClient } from './runtime/docker';
|
import { getDockerClient } from './runtime/docker';
|
||||||
import { verifyTerminalToken } from './terminal-token';
|
import {
|
||||||
|
parseBoxTokenUsername,
|
||||||
|
verifyBoxTerminalToken,
|
||||||
|
verifyTerminalToken,
|
||||||
|
} from './terminal-token';
|
||||||
import { acquireUserBox } from './user-container';
|
import { acquireUserBox } from './user-container';
|
||||||
|
import { ensureBashProfile } from './user-environment';
|
||||||
import { getTerminalWorkspace } from './worker';
|
import { getTerminalWorkspace } from './worker';
|
||||||
|
|
||||||
const clampDimension = (value: unknown) => {
|
const clampDimension = (value: unknown) => {
|
||||||
@@ -37,16 +43,36 @@ export const parseResizeMessage = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const bridge = async (ws: WebSocket, jobId: string) => {
|
// The login-shell command shared by the job and box terminals: prefer a
|
||||||
const workspace = getTerminalWorkspace(jobId);
|
// resumable tmux session, else fall back to a plain interactive login shell.
|
||||||
if (!workspace) {
|
const SHELL_CMD = [
|
||||||
ws.close(1011, 'Workspace is not active.');
|
'/bin/bash',
|
||||||
return;
|
'-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
|
type ShellBridgeOptions = {
|
||||||
// is ready (acquiring the box can take seconds on first connect), so the initial
|
// Acquire the per-user box (reference-counted); its handle is released on
|
||||||
// resize and early keystrokes aren't dropped.
|
// cleanup. Called after the input handler is wired so early keystrokes buffer.
|
||||||
|
acquire: () => Promise<BoxHandle>;
|
||||||
|
// Host-side preparation before the shell starts (e.g. seed .bash_profile).
|
||||||
|
prepare?: () => Promise<void>;
|
||||||
|
// 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: {
|
const execHolder: {
|
||||||
current?: { exec: import('dockerode').Exec; stream: Duplex };
|
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).
|
// the terminal share the exact same container (Phase 2).
|
||||||
let boxName: string;
|
let boxName: string;
|
||||||
try {
|
try {
|
||||||
const handle = await acquireUserBox({
|
if (opts.prepare) await opts.prepare();
|
||||||
username: workspace.username,
|
const handle = await opts.acquire();
|
||||||
workdir: workspace.workdir,
|
|
||||||
containerHome: workspace.containerHome,
|
|
||||||
});
|
|
||||||
handleHolder.current = handle;
|
handleHolder.current = handle;
|
||||||
boxName = handle.boxName;
|
boxName = handle.boxName;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -115,17 +138,9 @@ const bridge = async (ws: WebSocket, jobId: string) => {
|
|||||||
AttachStdout: true,
|
AttachStdout: true,
|
||||||
AttachStderr: true,
|
AttachStderr: true,
|
||||||
Tty: true,
|
Tty: true,
|
||||||
Cmd: [
|
Cmd: SHELL_CMD,
|
||||||
'/bin/bash',
|
Env: ['TERM=xterm-256color', ...opts.envFlags],
|
||||||
'-lc',
|
WorkingDir: opts.cwd,
|
||||||
'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,
|
|
||||||
});
|
});
|
||||||
const stream = await exec.start({ hijack: true, stdin: true, Tty: true });
|
const stream = await exec.start({ hijack: true, stdin: true, Tty: true });
|
||||||
execHolder.current = { exec, stream };
|
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
|
* Attaches the interactive-terminal WebSocket endpoints to the worker's HTTP
|
||||||
* server. Browser connects to `/jobs/:jobId/terminal?token=…` with a short-lived
|
* server. Two routes, each authorized by a short-lived token minted by the Next
|
||||||
* token minted by the Next app (which has already verified job ownership).
|
* 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) => {
|
export const attachTerminalServer = (server: Server) => {
|
||||||
if (env.runtime !== 'docker') return;
|
if (env.runtime !== 'docker') return;
|
||||||
@@ -164,13 +216,34 @@ export const attachTerminalServer = (server: Server) => {
|
|||||||
|
|
||||||
server.on('upgrade', (request, socket, head) => {
|
server.on('upgrade', (request, socket, head) => {
|
||||||
const url = new URL(request.url ?? '', `http://localhost:${env.httpPort}`);
|
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);
|
const match = /^\/jobs\/([^/]+)\/terminal$/.exec(url.pathname);
|
||||||
if (!match?.[1]) {
|
if (!match?.[1]) {
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const jobId = decodeURIComponent(match[1]);
|
const jobId = decodeURIComponent(match[1]);
|
||||||
const token = url.searchParams.get('token') ?? '';
|
|
||||||
if (!verifyTerminalToken(token, jobId, env.terminalSecret)) {
|
if (!verifyTerminalToken(token, jobId, env.terminalSecret)) {
|
||||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
|
|||||||
@@ -32,6 +32,23 @@ export const fetchUserEnvironment = async (
|
|||||||
|
|
||||||
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
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<void> => {
|
||||||
|
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
|
* Materializes the persistent per-user home: a `.bash_profile` so login shells
|
||||||
* load `~/.bashrc`; (when configured and changed) a clone of the public dotfiles
|
* 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;
|
redact: (value: string) => string;
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
const { homeDir, containerHome, boxName, userEnv, redact } = args;
|
const { homeDir, containerHome, boxName, userEnv, redact } = args;
|
||||||
await mkdir(homeDir, { recursive: true });
|
await ensureBashProfile(homeDir);
|
||||||
|
|
||||||
// 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',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!userEnv.enabled) return;
|
if (!userEnv.enabled) return;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { createHmac } from 'node:crypto';
|
import { createHmac } from 'node:crypto';
|
||||||
import { describe, expect, test } from 'vitest';
|
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 mintBox = (username: string, expiresAt: number, secret: string) => {
|
||||||
const payload = `${expiresAt}.box.${username}`;
|
const payload = `${expiresAt}.box.${username}`;
|
||||||
@@ -47,3 +50,27 @@ describe('verifyBoxTerminalToken', () => {
|
|||||||
expect(verifyBoxTerminalToken(jobToken, 'alice', secret)).toBe(false);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user