Files
gstack/browse/test/terminal-agent.test.ts
0c87a553c7 v1.91.155.0 fix(browse): a busy tab no longer blocks every command with "Daemon busy" (port of #3250) (#3301)
* 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 commit 322d255354)

* 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 commit d010d7b243)

* 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 commit ef70b7d880)

* 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 commit 3dbb256796)

* 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 commit 19fc9b9709)

* 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 commit 60cbaebee5)

* 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>
2026-10-19 21:30:33 +00:00

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));
});
});