Files
gstack/browse/test/terminal-agent.test.ts
garrytan 3b31ea6838 refactor(browse): declared route table replaces the buildFetchHandler if-chain
The ~1,300-line if-chain in buildFetchHandler becomes a route table:
each entry declares method, path, auth kind and surfaces, and one auth
gate in browse/src/routes/table.ts returns the per-kind denial (root-bearer,
scoped, root-or-sse-cookie: 401 Unauthorized; root-token: 403 Root token
required; extension-origin: 403 Forbidden). Unmatched requests take the
declared fallthrough (root-bearer check, then plain-text 404). Handlers move
to browse/src/routes/{core,pairing,pty,tokens,tunnel,activity,commands,files,
inspector}.ts and receive a RouteContext with auth checks as functions
instead of closing over factory locals. Dispatch order is unchanged:
tunnel filter, beforeRoute overlay, gate, handler. TUNNEL_PATHS stays a
literal in server.ts.

Behavior-preserving: the black-box auth matrix from the previous commit
passes unchanged. /memory and /inspector/events are declared root-bearer
because the blanket check always ran before their SSE-cookie branch.

Source-text route tests are rewritten as behavioral tests through
buildFetchHandler or a route's real handler with a stub RouteContext
(browse/test/route-test-harness.ts). Checks with no runtime seam are
re-pointed to the route modules: Surface type, /inspector/events SSE
helper, sanitizeReplacer imports, /pty-inject-scan sidecar-client import,
and the ngrok config lookup and startTunnel wiring that stay in server.ts.
2026-09-30 06:11:51 +00:00

260 lines
13 KiB
TypeScript

/**
* Unit tests for the Terminal-tab PTY agent and its server-side glue.
*
* Coverage:
* - pty-session-cookie module: mint / validate / revoke / TTL pruning.
* - source-level guard: /pty-session and /terminal/* are NOT in TUNNEL_PATHS.
* - source-level guard: /health does not surface ptyToken.
* - source-level guard: terminal-agent binds 127.0.0.1 only.
* - source-level guard: terminal-agent enforces Origin AND cookie on /ws.
*
* These are read-only checks against source — they prevent silent surface
* widening during a routine refactor (matches the dual-listener.test.ts
* pattern). End-to-end behavior (real /bin/bash PTY round-trip,
* tunnel-surface 404 + denial-log) lives in
* `browse/test/terminal-agent-integration.test.ts`.
*/
import { describe, test, expect, beforeEach } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import {
mintPtySessionToken, validatePtySessionToken, revokePtySessionToken,
extractPtyCookie, buildPtySetCookie,
PTY_COOKIE_NAME, __resetPtySessions,
} from '../src/pty-session-cookie';
import { makeServer, stubRouteContext, callRoute, routeEntry } from './route-test-harness';
const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8');
const AGENT_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/terminal-agent.ts'), 'utf-8');
describe('pty-session-cookie: mint/validate/revoke', () => {
beforeEach(() => __resetPtySessions());
test('a freshly minted token validates', () => {
const { token } = mintPtySessionToken();
expect(validatePtySessionToken(token)).toBe(true);
});
test('null and unknown tokens fail validation', () => {
expect(validatePtySessionToken(null)).toBe(false);
expect(validatePtySessionToken(undefined)).toBe(false);
expect(validatePtySessionToken('')).toBe(false);
expect(validatePtySessionToken('not-a-real-token')).toBe(false);
});
test('revoke makes a token invalid', () => {
const { token } = mintPtySessionToken();
expect(validatePtySessionToken(token)).toBe(true);
revokePtySessionToken(token);
expect(validatePtySessionToken(token)).toBe(false);
});
test('Set-Cookie has HttpOnly + SameSite=Strict + Path=/ + Max-Age', () => {
const { token } = mintPtySessionToken();
const cookie = buildPtySetCookie(token);
expect(cookie).toContain(`${PTY_COOKIE_NAME}=${token}`);
expect(cookie).toContain('HttpOnly');
expect(cookie).toContain('SameSite=Strict');
expect(cookie).toContain('Path=/');
expect(cookie).toMatch(/Max-Age=\d+/);
// Secure is intentionally omitted — daemon binds 127.0.0.1 over HTTP.
expect(cookie).not.toContain('Secure');
});
test('extractPtyCookie reads gstack_pty from a Cookie header', () => {
const { token } = mintPtySessionToken();
const req = new Request('http://127.0.0.1/ws', {
headers: { 'cookie': `othercookie=foo; gstack_pty=${token}; baz=qux` },
});
expect(extractPtyCookie(req)).toBe(token);
});
test('extractPtyCookie returns null when the cookie is missing', () => {
const req = new Request('http://127.0.0.1/ws', {
headers: { 'cookie': 'unrelated=value' },
});
expect(extractPtyCookie(req)).toBe(null);
});
});
describe('Source-level guard: /pty-session is not on the tunnel surface', () => {
test('TUNNEL_PATHS does not include /pty-session or /terminal/*', () => {
const start = SERVER_SRC.indexOf('const TUNNEL_PATHS = new Set<string>([');
expect(start).toBeGreaterThan(-1);
const end = SERVER_SRC.indexOf(']);', start);
const body = SERVER_SRC.slice(start, end);
expect(body).not.toContain('/pty-session');
expect(body).not.toContain('/terminal/');
expect(body).not.toContain('/terminal-');
});
});
describe('/health does NOT surface ptyToken', () => {
test('/health response body does not include ptyToken', async () => {
// It's allowed to include `terminalPort` (a port number, not auth).
const ctx = stubRouteContext({
browserManager: { isHealthy: async () => true, getConnectionMode: () => 'launched', getTabCount: () => 1 } as any,
terminal: { readPort: () => 4242, grantToken: async () => true, restartSession: async () => true },
});
const text = await (await callRoute('GET', '/health', ctx)).text();
const body = JSON.parse(text);
expect(body.terminalPort).toBe(4242);
expect(Object.keys(body).sort()).toEqual(['mode', 'status', 'tabs', 'terminalPort', 'uptime']);
expect(text).not.toContain('ptyToken');
expect(text).not.toContain('gstack_pty');
});
});
describe('Source-level guard: terminal-agent', () => {
test('binds 127.0.0.1 only, never 0.0.0.0', () => {
expect(AGENT_SRC).toContain("hostname: '127.0.0.1'");
expect(AGENT_SRC).not.toContain("hostname: '0.0.0.0'");
});
test('rejects /ws upgrades without chrome-extension:// Origin', () => {
// The Origin check must run BEFORE the cookie check — otherwise a
// missing-origin attempt would surface the 401 cookie message and
// signal to attackers that they need to forge a cookie.
const wsHandler = AGENT_SRC.slice(AGENT_SRC.indexOf("if (url.pathname === '/ws')"));
expect(wsHandler).toContain('chrome-extension://');
expect(wsHandler).toContain('forbidden origin');
});
test('validates the session token against an in-memory token set', () => {
const wsHandler = AGENT_SRC.slice(AGENT_SRC.indexOf("if (url.pathname === '/ws')"));
// Two transports: Sec-WebSocket-Protocol (preferred for browsers) and
// the gstack_pty cookie fallback — parsing shared via extractPtyCookie
// (the hand-rolled parse here had drifted from the server's), validation
// still against the agent's own validTokens map.
expect(wsHandler).toContain('sec-websocket-protocol');
expect(wsHandler).toContain('extractPtyCookie');
expect(wsHandler).toContain('validTokens.has');
});
test('Sec-WebSocket-Protocol auth: strips gstack-pty. prefix, no manual echo', () => {
const wsHandler = AGENT_SRC.slice(AGENT_SRC.indexOf("if (url.pathname === '/ws')"));
// Browsers send `Sec-WebSocket-Protocol: gstack-pty.<token>`. The agent
// must strip the prefix before checking validTokens. The protocol echo
// is Bun's job: Bun >= 1.3 auto-echoes the first offered protocol in the
// 101 response. A manual echo on top produced a DUPLICATE
// Sec-WebSocket-Protocol header, which strict clients (Chromium, python
// websockets) reject per RFC 6455 — the sidebar terminal could never
// connect. Pin the invariant: no manual echo in the upgrade call.
expect(wsHandler).toContain("'gstack-pty.'");
expect(wsHandler).toContain('sec-websocket-protocol');
expect(wsHandler).not.toContain("headers: { 'Sec-WebSocket-Protocol'");
});
test('lazy spawn: claude PTY is spawned in message handler, not on upgrade', () => {
// The whole point of lazy-spawn (codex finding #8) is that the WS
// upgrade itself does NOT spawn claude. Spawn happens on first
// message frame (binary input or the v1.44 explicit `start` frame),
// routed through the maybeSpawnPty helper, which is the only caller
// of spawnClaude.
const upgradeBlock = AGENT_SRC.slice(
AGENT_SRC.indexOf("if (url.pathname === '/ws')"),
AGENT_SRC.indexOf("websocket: {"),
);
// v1.44 renamed spawnClaude -> maybeSpawnPty (explicit `start` frame +
// lazy first-byte spawn share one helper). Pin was stale from then until
// the free suite got a CI job.
expect(upgradeBlock).not.toContain('spawnClaude(');
expect(upgradeBlock).not.toContain('maybeSpawnPty(');
// Spawn must be invoked from the message handler (lazy on first byte).
// v1.44 routes both spawn triggers (explicit {type:"start"} text frame
// and the lazy binary-frame path) through the maybeSpawnPty helper.
const messageHandler = AGENT_SRC.slice(AGENT_SRC.indexOf('message(ws, raw)'));
expect(messageHandler).toContain('maybeSpawnPty(');
expect(messageHandler).toContain('!session.spawned');
// The open() upgrade handler must not spawn — it only creates the
// (spawned: false) session record or re-attaches a detached one.
const openBlock = AGENT_SRC.slice(
AGENT_SRC.indexOf('open(ws)'),
AGENT_SRC.indexOf('message(ws, raw)'),
);
expect(openBlock).not.toContain('spawnClaude(');
expect(openBlock).not.toContain('maybeSpawnPty(');
// And the helper itself is where spawnClaude actually happens, gated
// on session.spawned so it stays a single-shot lazy spawn.
const helperBlock = AGENT_SRC.slice(AGENT_SRC.indexOf('function maybeSpawnPty'));
expect(helperBlock).toContain('spawnClaude(');
expect(helperBlock).toContain('if (session.spawned) return true;');
});
test('process.on uncaughtException + unhandledRejection handlers exist', () => {
expect(AGENT_SRC).toContain("process.on('uncaughtException'");
expect(AGENT_SRC).toContain("process.on('unhandledRejection'");
});
test('cleanup escalates SIGINT to SIGKILL after 3s on close', () => {
// disposeSession must be idempotent and use a SIGINT-then-SIGKILL pattern.
const dispose = AGENT_SRC.slice(AGENT_SRC.indexOf('function disposeSession'), AGENT_SRC.indexOf('function sendPtyCompletion'));
const lifecycle = fs.readFileSync(path.join(import.meta.dir, '../src/terminal-pty-lifecycle.ts'), 'utf-8');
expect(dispose).toContain('disposePtyProcess(proc)');
expect(lifecycle).toContain("'SIGINT'");
expect(lifecycle).toContain("'SIGKILL'");
expect(lifecycle).toContain('PTY_SHUTDOWN_GRACE_MS = 3000');
});
test('tabState frames write tabs.json + active-tab.json', () => {
expect(AGENT_SRC).toContain("msg?.type === 'tabState'");
expect(AGENT_SRC).toContain('function handleTabState');
const fn = AGENT_SRC.slice(AGENT_SRC.indexOf('function handleTabState'));
// Atomic write for both files (so claude never reads a half-written
// JSON document) — via the shared lib/fs-atomic helper, which owns the
// tmp + rename dance. Quiet variant: state-file writes are
// fire-and-forget and must never take down the agent.
expect(fn).toContain("'tabs.json'");
expect(fn).toContain("'active-tab.json'");
expect(fn).toContain('atomicWriteQuiet');
// Skip chrome:// and chrome-extension:// pages — they're not useful
// targets for browse commands.
expect(fn).toContain("startsWith('chrome://')");
expect(fn).toContain("startsWith('chrome-extension://')");
});
test('claude is spawned with --append-system-prompt tab-awareness hint', () => {
expect(AGENT_SRC).toContain('function buildTabAwarenessHint');
const hint = AGENT_SRC.slice(AGENT_SRC.indexOf('function buildTabAwarenessHint'));
// The hint must mention the live state files and the fanout command —
// those are the two affordances that distinguish a gstack-PTY claude
// from a plain `claude` session.
expect(hint).toContain('tabs.json');
expect(hint).toContain('active-tab.json');
expect(hint).toContain('tab-each');
// And it must be passed via --append-system-prompt at spawn time
// (NOT written into the PTY as user input — that would pollute the
// visible transcript).
const spawn = AGENT_SRC.slice(AGENT_SRC.indexOf('function spawnClaude'));
expect(spawn).toContain("'--append-system-prompt'");
expect(spawn).toContain('tabHint');
});
});
describe('/pty-session route', () => {
test('validates AUTH_TOKEN, grants over loopback, returns token + Set-Cookie', async () => {
// Must check auth before minting.
expect(routeEntry('POST', '/pty-session').auth).toBe('root-bearer');
const server = makeServer();
try {
const denied = await server.local('/pty-session', { method: 'POST' });
expect(denied.status).toBe(401);
} finally { server.cleanup(); }
// Must call the loopback grant before responding (otherwise the agent's
// validTokens Set never sees the token and /ws would 401), return the
// token in the JSON body for the Sec-WebSocket-Protocol auth path
// (cross-port cookies don't survive SameSite=Strict from a
// chrome-extension origin), and keep Set-Cookie as a fallback for
// non-browser callers.
const granted: string[] = [];
const ctx = stubRouteContext({
terminal: { readPort: () => 4242, grantToken: async (token) => { granted.push(token); return true; }, restartSession: async () => true },
});
const resp = await callRoute('POST', '/pty-session', ctx);
const body = await resp.json() as any;
expect(granted).toEqual([body.ptySessionToken]);
expect(resp.headers.get('set-cookie')).toBe(buildPtySetCookie(body.ptySessionToken));
});
});