mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 08:23:59 +01:00
* fix(browse): /health status is daemon liveness; active tab reported apart (#3247) /health answered `status` from a page.evaluate('1') race on the active tab, so one page stuck in a long script made the whole daemon read as unhealthy. The CLI gates every command on that answer, and its 2000 ms fetch timeout equalled the server's 2000 ms race, so it could not even read the reply: status, tabs, tab, newtab and stop all failed with "Daemon busy" until --force-restart threw the session away. - BrowserManager.isHealthy() is liveness only (Chromium connected). - probeActiveTab() reports { id, responsive }, bounded by TAB_PROBE_TIMEOUT_MS (1s); /health returns it as `activeTab`. - `status` adds a line naming a not-responding active tab and the commands that leave it. - The CLI reads /health through one helper whose timeout, HEALTH_REQUEST_TIMEOUT_MS (2s), stays above the probe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wb6Wntntmwau19fSuSbuaL (cherry picked from commit322d255354) * fix(browse): bound per-tab reads in `tabs` and `state save` (#3247) A renderer stuck in a long script never answers page.title() or page.evaluate(). `tabs` awaited each title in turn and `state save` read each tab's storage unbounded, so both hung on one busy tab even though neither needs it. - getTabListWithTitles() reads titles in parallel, each bounded by TAB_PROBE_TIMEOUT_MS; a tab that misses it is listed as "(not responding)". - saveState() skips the storage of a tab that does not answer within 5s, the same outcome as a tab whose read fails. busy-active-tab.test.ts holds a real Chromium tab in a busy loop next to an idle one and checks /health, tabs, status, saveState and closetab against it. On main, /health takes 2004 ms and says unhealthy, `tabs` hangs past the test timeout and `status` never names the tab. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wb6Wntntmwau19fSuSbuaL (cherry picked from commitd010d7b243) * fix(browse): name the busy tab when a command times out (#3247) A command that needs a stuck tab still waits out the CLI's 30s reply timeout, and the report should say why. It never ran: Bun rejects an AbortSignal.timeout() fetch with TimeoutError, the generic branch only matched AbortError, so a timeout fell through to "[browse] The operation timed out." - isFetchTimeout() accepts both names; the cookie-import branch uses it too. - commandTimeoutReport() reads /health: when `activeTab.responsive` is false it names the tab and points at `browse tab <id>` and `browse closetab <id>`; a live daemon otherwise keeps the busy-not-restarting line and a dead one the plain timeout line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wb6Wntntmwau19fSuSbuaL (cherry picked from commitef70b7d880) * docs(browse): /health liveness vs active-tab responsiveness (#3247) BROWSER.md's busy-vs-dead lifecycle note, ARCHITECTURE.md's daemon model and the REMOTE_BROWSER_ACCESS /health reference now say that `status` is daemon liveness, that `activeTab` reports the active tab separately, and which commands keep working while a tab is busy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wb6Wntntmwau19fSuSbuaL (cherry picked from commit3dbb256796) * fix(browse): one pending probe per stuck tab, not one per poll (#3247) Review on #3250: the 1s race stops waiting but leaves page.evaluate() queued in the stuck renderer, so every /health poll (the extension polls every 10s), `status` and `tabs` call added another pending read, all drained at once when the page recovered. Playwright cannot cancel an in-flight evaluate, so the active-tab probe and the `tabs` title read now join the page's pending read of the same kind instead of starting a new one. busy-active-tab.test.ts counts page.evaluate calls across three probes of the stuck tab: 3 before this change, at most 1 after. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wb6Wntntmwau19fSuSbuaL (cherry picked from commit19fc9b9709) * fix(browse): keep a busy tab's last good storage in saved state (#3247) Review on #3250: with BROWSE_PERSIST_STATE=1 the periodic snapshot used to hang on a stuck tab, so session-state.json kept its last good copy. The 5s storage bound let the snapshot finish and write `storage: null` for that tab over it, so a restart lost its localStorage and sessionStorage even with persistence on. saveState() now records the storage each page answers with, together with its origin. A read that times out returns that last good storage when the origin still matches, and null otherwise. A failed read stays null, as before. The storage read also joins the page's pending read instead of queueing a new one on every persist tick. busy-active-tab.test.ts takes a good snapshot, hangs the tab, and checks that persistSessionState() still writes its localStorage. Live, with BROWSE_PERSIST_STATE=1 and a 2s interval, session-state.json kept {"probe":"kept"} for the busy tab, and its savedAt stayed current through 12s of busy page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wb6Wntntmwau19fSuSbuaL (cherry picked from commit60cbaebee5) * docs(changelog): busy-tab lockup fix from #3250 under [Unreleased] Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: Andrei Vershinin <vershinin@intelas.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
263 lines
13 KiB
TypeScript
263 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: () => true, probeActiveTab: async () => ({ id: 1, responsive: 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(['activeTab', '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('allowedExtensionOrigin()');
|
|
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));
|
|
});
|
|
});
|