refactor: remove in-browser PTY terminal

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sinabina
2026-07-21 14:40:31 -07:00
co-authored by Claude Opus 4.8
parent ab4e9ae520
commit d596232248
32 changed files with 11 additions and 5652 deletions
+2 -2
View File
@@ -48,9 +48,9 @@ describe('Dual-listener surface types', () => {
});
describe('Tunnel path allowlist', () => {
test('TUNNEL_PATHS is a closed set containing exactly /connect, /command, /sidebar-chat', () => {
test('TUNNEL_PATHS is a closed set containing exactly /connect, /command', () => {
const paths = extractSetContents(SERVER_SRC, 'TUNNEL_PATHS');
expect(paths).toEqual(new Set(['/connect', '/command', '/sidebar-chat']));
expect(paths).toEqual(new Set(['/connect', '/command']));
});
test('TUNNEL_PATHS does NOT contain bootstrap or admin paths', () => {
-98
View File
@@ -1,98 +0,0 @@
import { describe, test, expect, beforeEach } from 'bun:test';
// pty-session-lease registers a sessionId space distinct from the pre-v1.44
// attach-token space (browse/src/pty-session-cookie.ts). These tests pin
// the validate-first contract that codex outside-voice flagged as critical:
// refreshLease MUST NOT resurrect expired leases, otherwise the 30-min TTL
// stops bounding leaked-token blast radius.
import {
mintLease,
validateLease,
refreshLease,
revokeLease,
leaseCount,
__resetLeases,
} from '../src/pty-session-lease';
beforeEach(() => {
__resetLeases();
});
describe('pty-session-lease: mint/validate/revoke', () => {
test('mintLease returns a fresh non-secret sessionId + future expiresAt', () => {
const a = mintLease();
const b = mintLease();
expect(a.sessionId).toBeTruthy();
expect(b.sessionId).toBeTruthy();
expect(a.sessionId).not.toBe(b.sessionId);
expect(a.expiresAt).toBeGreaterThan(Date.now());
// base64url alphabet: characters in [A-Za-z0-9_-].
expect(a.sessionId).toMatch(/^[A-Za-z0-9_-]+$/);
expect(leaseCount()).toBe(2);
});
test('validateLease ok for fresh lease, false for unknown', () => {
const { sessionId } = mintLease();
const ok = validateLease(sessionId);
expect(ok.ok).toBe(true);
if (ok.ok) expect(ok.expiresAt).toBeGreaterThan(Date.now());
expect(validateLease('not-a-real-session-id').ok).toBe(false);
expect(validateLease(null).ok).toBe(false);
expect(validateLease(undefined).ok).toBe(false);
});
test('revokeLease removes the lease; subsequent validate returns false', () => {
const { sessionId } = mintLease();
expect(validateLease(sessionId).ok).toBe(true);
revokeLease(sessionId);
expect(validateLease(sessionId).ok).toBe(false);
expect(leaseCount()).toBe(0);
});
test('revokeLease tolerates unknown sessionId without throwing', () => {
expect(() => revokeLease('phantom')).not.toThrow();
expect(() => revokeLease(null)).not.toThrow();
});
});
describe('pty-session-lease: refresh contract (validate-first)', () => {
test('refreshLease extends expiresAt for a valid lease', () => {
const { sessionId, expiresAt: initial } = mintLease();
// Sleep micro-tick — Date.now() is ms-grain so a synchronous extend
// may not move the integer. Use a tight async wait instead.
return new Promise<void>((resolve) => {
setTimeout(() => {
const r = refreshLease(sessionId);
expect(r.ok).toBe(true);
if (r.ok) expect(r.expiresAt).toBeGreaterThan(initial);
resolve();
}, 5);
});
});
test('refreshLease rejects unknown sessionId (validate-first invariant)', () => {
const r = refreshLease('never-minted');
expect(r.ok).toBe(false);
});
test('refreshLease never resurrects an expired lease', async () => {
// Force TTL down to 5ms for this assertion by minting + waiting past expiry.
// Lease internals use Date.now() so the easiest way to expire one is
// to artificially backdate via revoke+remint cycle. Simpler: mint, then
// wait for the registry's own expiry check to trip.
//
// We can't backdate without breaking encapsulation, so this test exercises
// the negative-validate path: minted lease, then prove that refresh after
// explicit revoke still returns ok:false (same as expired-and-pruned).
const { sessionId } = mintLease();
revokeLease(sessionId);
const r = refreshLease(sessionId);
expect(r.ok).toBe(false);
});
test('refreshLease tolerates null / undefined sessionId', () => {
expect(refreshLease(null).ok).toBe(false);
expect(refreshLease(undefined).ok).toBe(false);
});
});
-265
View File
@@ -1,265 +0,0 @@
/**
* Real-Chromium regression coverage for the sidepanel's current security UI.
*
* The classifier-backed chat queue was removed when the primary surface
* became a terminal PTY. Until classifier status is wired to that surface,
* the honest contract is deliberately negative:
*
* - /health.security.status must not light the hidden SEC shield.
* - retired /sidebar-chat security_event data must not render a banner or
* leak attacker-controlled text into the terminal surface.
*
* Every HTTP, SSE, WebSocket, and beacon primitive is replaced before the
* sidepanel scripts load, so this test never reaches a real browse server.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import { chromium, type Browser, type Page } from 'playwright';
const EXTENSION_DIR = path.resolve(import.meta.dir, '..', '..', 'extension');
const SIDEPANEL_URL = `file://${EXTENSION_DIR}/sidepanel.html`;
const CHROMIUM_AVAILABLE = (() => {
try {
const executable = chromium.executablePath();
return Boolean(executable && fs.existsSync(executable));
} catch {
return false;
}
})();
type Scenario = {
healthSecurity: {
status: 'protected' | 'degraded' | 'inactive';
layers?: Record<string, string>;
};
securityEntries?: unknown[];
};
async function installStubsBeforeLoad(page: Page, scenario: Scenario): Promise<void> {
await page.addInitScript((params: Scenario) => {
const requests: Array<{ url: string; method: string }> = [];
(window as any).__gstackTestRequests = requests;
(window as any).chrome = {
runtime: {
sendMessage: (_request: unknown, callback?: (value: unknown) => void) => {
// Omit a token so sidepanel.js exercises the direct /health
// bootstrap path whose security payload is under test.
const payload = { connected: true, port: 34567 };
if (typeof callback === 'function') {
setTimeout(() => callback(payload), 0);
return undefined;
}
return Promise.resolve(payload);
},
lastError: null,
onMessage: { addListener: () => {} },
},
tabs: {
query: (_query: unknown, callback: (tabs: unknown[]) => void) =>
setTimeout(() => callback([{ id: 1, url: 'https://example.com' }]), 0),
onActivated: { addListener: () => {} },
onUpdated: { addListener: () => {} },
},
};
(window as any).EventSource = class StubEventSource {
static CONNECTING = 0;
static OPEN = 1;
static CLOSED = 2;
readyState = 1;
constructor(url: string) {
requests.push({ url: String(url), method: 'EVENTSOURCE' });
}
addEventListener() {}
close() { this.readyState = 2; }
};
(window as any).WebSocket = class StubWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readyState = 0;
constructor(url: string) {
requests.push({ url: String(url), method: 'WEBSOCKET' });
}
addEventListener() {}
send() {}
close() { this.readyState = 3; }
};
Object.defineProperty(navigator, 'sendBeacon', {
configurable: true,
value: (url: string) => {
requests.push({ url: String(url), method: 'BEACON' });
return true;
},
});
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
requests.push({ url, method: init?.method ?? 'GET' });
if (url.endsWith('/health')) {
return new Response(JSON.stringify({
status: 'healthy',
token: 'test-token',
AUTH_TOKEN: 'test-token',
mode: 'headed',
agent: { status: 'idle', runningFor: null, queueLength: 0 },
session: null,
security: params.healthSecurity,
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (url.endsWith('/sse-session')) {
return new Response(null, { status: 204 });
}
if (url.endsWith('/memory')) {
return new Response(JSON.stringify({ bunServer: { rss: 0 }, tabs: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.endsWith('/pty-session')) {
// Keep the terminal bootstrap deterministic and prevent a WebSocket
// attempt; this test concerns the pre-session terminal surface.
return new Response('terminal disabled in DOM test', { status: 503 });
}
if (url.includes('/sidebar-chat')) {
return new Response(JSON.stringify({
entries: params.securityEntries ?? [],
total: (params.securityEntries ?? []).length,
agentStatus: 'idle',
security: params.healthSecurity,
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (url.endsWith('/refs')) {
return new Response(JSON.stringify({ refs: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// Fail closed inside the stub rather than falling through to the real
// network. Recording the URL above keeps unexpected bootstrap calls
// diagnosable in assertion output.
return new Response(JSON.stringify({ error: 'unstubbed test endpoint' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
};
}, scenario);
}
async function openStubbedSidepanel(
scenario: Scenario,
assertion: (page: Page) => Promise<void>,
): Promise<void> {
const context = await browser!.newContext();
try {
const page = await context.newPage();
await installStubsBeforeLoad(page, scenario);
await page.goto(SIDEPANEL_URL);
await page.waitForFunction(() =>
(window as any).gstackAuthToken === 'test-token' &&
document.getElementById('footer-dot')?.classList.contains('connected'),
);
await assertion(page);
} finally {
await context.close();
}
}
let browser: Browser | null = null;
beforeAll(async () => {
if (!CHROMIUM_AVAILABLE) return;
browser = await chromium.launch({ headless: true });
}, 30_000);
afterAll(async () => {
if (!browser) return;
try {
await browser.close();
} catch {}
browser = null;
});
describe('sidepanel security DOM', () => {
test.skipIf(!CHROMIUM_AVAILABLE)(
'protected health metadata does not expose an unwired SEC claim',
async () => {
await openStubbedSidepanel({
healthSecurity: {
status: 'protected',
layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' },
},
}, async (page) => {
const shield = page.locator('#security-shield');
expect(await shield.count()).toBe(1);
expect(await shield.isVisible()).toBe(false);
expect(await shield.getAttribute('data-status')).toBeNull();
expect(await shield.getAttribute('aria-label')).toBe('Security status: unknown');
const visibleText = await page.locator('body').innerText();
expect(visibleText).not.toContain('SEC');
expect(visibleText.toLowerCase()).not.toContain('protected');
const requests = await page.evaluate(() => (window as any).__gstackTestRequests);
expect(requests.some((request: { url: string }) => request.url.endsWith('/health'))).toBe(true);
expect(requests.some((request: { url: string }) => request.url.endsWith('/sse-session'))).toBe(true);
});
},
15_000,
);
test.skipIf(!CHROMIUM_AVAILABLE)(
'retired security_event data is neither polled nor rendered into the terminal',
async () => {
const attackerMarker = 'ATTACKER-CONTROLLED-TERMINAL-MARKER';
const attackerDomain = 'retired-chat.attacker.example';
await openStubbedSidepanel({
healthSecurity: {
status: 'protected',
layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' },
},
securityEntries: [{
id: 1,
ts: '2026-04-20T00:00:00Z',
role: 'agent',
type: 'security_event',
verdict: 'block',
reason: attackerMarker,
layer: 'canary',
confidence: 1,
domain: attackerDomain,
}],
}, async (page) => {
// Let immediate connection work and the first memory poll settle;
// neither may reintroduce the retired chat polling path.
await page.waitForTimeout(650);
const requests = await page.evaluate(() => (window as any).__gstackTestRequests);
expect(requests.some((request: { url: string }) => request.url.includes('/sidebar-chat'))).toBe(false);
expect(requests.some((request: { url: string }) => request.url.endsWith('/memory'))).toBe(true);
expect(requests.some((request: { url: string }) => request.url.startsWith('https://'))).toBe(false);
expect(await page.locator('#security-banner').count()).toBe(0);
expect(await page.locator('.security-banner').count()).toBe(0);
const terminalText = await page.locator('#tab-terminal').innerText();
expect(terminalText).not.toContain(attackerMarker);
expect(terminalText).not.toContain(attackerDomain);
expect(await page.locator('#security-shield').isVisible()).toBe(false);
});
},
15_000,
);
});
+2 -2
View File
@@ -314,7 +314,7 @@ describe('Server auth security', () => {
// Regression: connect command crashed with "domains is not defined" because
// a stray `domains,` variable was in the status fetch body (cli.ts:852).
test('connect command status fetch body has no undefined variable references', () => {
const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Terminal agent started');
const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Connect failed');
// The status fetch should use a clean JSON body
expect(connectBlock).toContain("command: 'status'");
// Must NOT contain a bare `domains` reference in the fetch body
@@ -341,7 +341,7 @@ describe('Server auth security', () => {
// assigned via object-literal syntax (`BROWSE_PARENT_PID: '0'`)
// inside the `const serverEnv: Record<string, string> = { ... }`
// declaration. Assert both pieces appear in the connect block.
const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Terminal agent started');
const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Connect failed');
expect(connectBlock).toContain("const serverEnv");
expect(connectBlock).toContain("BROWSE_PARENT_PID: '0'");
});
@@ -1,232 +0,0 @@
import { describe, test, expect, beforeEach, beforeAll, afterAll } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import {
buildFetchHandler,
__resetShuttingDown,
type ServerConfig,
} from '../src/server';
import { __resetRegistry } from '../src/token-registry';
import { BrowserManager } from '../src/browser-manager';
import { resolveConfig } from '../src/config';
// Tests for the v1.41+ ownsTerminalAgent flag.
//
// Embedders (gbrowser phoenix overlay) that run their own PTY server and write
// terminal-port / terminal-internal-token / terminal-agent-pid themselves were
// getting those files clobbered by gstack's shutdown(). The flag (default true)
// gates four side effects (v1.44+):
// 1. identity-based kill of the PID in <stateDir>/terminal-agent-pid
// 2. unlink terminal-port
// 3. unlink terminal-internal-token
// 4. unlink terminal-agent-pid
// False = embedder owns them, gstack stays hands-off.
//
// Pre-v1.44 used `pkill -f terminal-agent\.ts` which matched sibling gstack
// sessions on the same host — see browse/src/terminal-agent-control.ts header.
//
// CRITICAL: each test stubs process.exit (so shutdown's exit doesn't kill
// the test runner). The PID in the test agent-record is a guaranteed-dead
// PID (1 = init / launchd — exists but cannot be killed by an unprivileged
// process, so safeKill returns ESRCH-equivalent without affecting anything).
// Use isProcessAlive's false branch by also testing with a PID that does
// not exist (negative PID rejected by the OS).
const stateDir = resolveConfig().stateDir;
const PORT_FILE = path.join(stateDir, 'terminal-port');
const TOKEN_FILE = path.join(stateDir, 'terminal-internal-token');
const AGENT_RECORD_FILE = path.join(stateDir, 'terminal-agent-pid');
const SENTINEL_PORT = 'sentinel-port-65432';
const SENTINEL_TOKEN = 'sentinel-token-abcdef1234567890';
// PID 2^31-1 is the Linux PID_MAX_LIMIT; macOS uses 99998. Either way, no
// real process will ever hold this PID on a developer machine. isProcessAlive
// returns false → killAgentByRecord no-ops without sending any signal.
const SENTINEL_DEAD_PID = 2147483646;
function makeMinimalConfig(overrides: Partial<ServerConfig> = {}): ServerConfig {
const token = 'embedder-test-' + crypto.randomBytes(16).toString('hex');
return {
authToken: token,
browsePort: 34568,
idleTimeoutMs: 1_800_000,
config: resolveConfig(),
browserManager: new BrowserManager(),
startTime: Date.now(),
...overrides,
};
}
function writeSentinels(): void {
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(PORT_FILE, SENTINEL_PORT);
fs.writeFileSync(TOKEN_FILE, SENTINEL_TOKEN);
fs.writeFileSync(
AGENT_RECORD_FILE,
JSON.stringify({ pid: SENTINEL_DEAD_PID, gen: 'sentinel-gen', startedAt: Date.now() }),
);
}
function readIfExists(p: string): string | null {
try { return fs.readFileSync(p, 'utf-8'); } catch { return null; }
}
/**
* Stubs process.exit so shutdown()'s process.exit(0) throws an __exit:N
* marker the test can swallow instead of killing the runner. Also stubs
* process.kill so an accidental kill (regression in killAgentByRecord
* that bypassed isProcessAlive) cannot reach a real PID on the developer
* machine. Returns the captured kill calls so tests can assert kill
* scope.
*/
async function withStubs(
cb: (killCalls: Array<[number, NodeJS.Signals | number]>) => Promise<void>
): Promise<Array<[number, NodeJS.Signals | number]>> {
const origExit = process.exit;
const origKill = process.kill;
const killCalls: Array<[number, NodeJS.Signals | number]> = [];
(process as any).exit = ((code: number) => {
throw new Error(`__exit:${code}`);
}) as any;
(process as any).kill = ((pid: number, signal: NodeJS.Signals | number) => {
killCalls.push([pid, signal ?? 'SIGTERM']);
// signal 0 is a liveness probe — keep the existing 'process is dead'
// semantics so isProcessAlive(SENTINEL_DEAD_PID) returns false.
if (signal === 0) {
const err: any = new Error('No such process');
err.code = 'ESRCH';
throw err;
}
return true;
}) as any;
try {
await cb(killCalls);
} finally {
(process as any).exit = origExit;
(process as any).kill = origKill;
}
return killCalls;
}
async function runShutdown(handle: { shutdown: (code?: number) => Promise<void> }): Promise<void> {
try {
await handle.shutdown(0);
} catch (err: any) {
if (typeof err?.message !== 'string' || !err.message.startsWith('__exit:')) throw err;
}
}
// Filter out the signal=0 liveness probes; only count actual termination signals.
function terminationCalls(
calls: Array<[number, NodeJS.Signals | number]>,
): Array<[number, NodeJS.Signals | number]> {
return calls.filter(([, sig]) => sig !== 0);
}
describe('buildFetchHandler ownsTerminalAgent gate', () => {
// shutdown() reads `path.dirname(config.stateFile)` from module-level config
// (composition gap — see TODOS T9). So unlinks target the real state dir,
// not a per-test temp dir. If a real gstack daemon is running on this host,
// its terminal-port + terminal-internal-token + terminal-agent-pid live
// where this test writes. Save + restore real-daemon file contents around
// the whole suite so the test never clobbers a developer's running session.
let realPortBackup: string | null = null;
let realTokenBackup: string | null = null;
let realAgentRecordBackup: string | null = null;
beforeAll(() => {
realPortBackup = readIfExists(PORT_FILE);
realTokenBackup = readIfExists(TOKEN_FILE);
realAgentRecordBackup = readIfExists(AGENT_RECORD_FILE);
});
afterAll(() => {
if (realPortBackup !== null) {
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(PORT_FILE, realPortBackup);
} else {
try { fs.unlinkSync(PORT_FILE); } catch {}
}
if (realTokenBackup !== null) {
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(TOKEN_FILE, realTokenBackup);
} else {
try { fs.unlinkSync(TOKEN_FILE); } catch {}
}
if (realAgentRecordBackup !== null) {
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(AGENT_RECORD_FILE, realAgentRecordBackup);
} else {
try { fs.unlinkSync(AGENT_RECORD_FILE); } catch {}
}
});
beforeEach(() => {
__resetRegistry();
__resetShuttingDown();
// Clean any leftover sentinels from a prior failed run so the "preserved"
// assertion can't pass spuriously off a stale file.
try { fs.unlinkSync(PORT_FILE); } catch {}
try { fs.unlinkSync(TOKEN_FILE); } catch {}
try { fs.unlinkSync(AGENT_RECORD_FILE); } catch {}
});
test('1. ownsTerminalAgent:false preserves all three files and sends no signal', async () => {
writeSentinels();
const handle = buildFetchHandler(makeMinimalConfig({ ownsTerminalAgent: false }));
const calls = await withStubs(async () => {
await runShutdown(handle);
});
expect(readIfExists(PORT_FILE)).toBe(SENTINEL_PORT);
expect(readIfExists(TOKEN_FILE)).toBe(SENTINEL_TOKEN);
expect(readIfExists(AGENT_RECORD_FILE)).not.toBeNull();
expect(terminationCalls(calls).length).toBe(0);
});
test('2. ownsTerminalAgent:true deletes all three files; identity-based kill probes the recorded PID', async () => {
writeSentinels();
const handle = buildFetchHandler(makeMinimalConfig({ ownsTerminalAgent: true }));
const calls = await withStubs(async () => {
await runShutdown(handle);
});
expect(readIfExists(PORT_FILE)).toBeNull();
expect(readIfExists(TOKEN_FILE)).toBeNull();
expect(readIfExists(AGENT_RECORD_FILE)).toBeNull();
// isProcessAlive sends signal 0; PID is the sentinel-dead PID, so the
// probe returns false and no SIGTERM is sent.
const probes = calls.filter(([pid, sig]) => pid === SENTINEL_DEAD_PID && sig === 0);
expect(probes.length).toBeGreaterThan(0);
expect(terminationCalls(calls).length).toBe(0);
});
test('3. ownsTerminalAgent unset defaults to true (deletes all three; probes recorded PID)', async () => {
writeSentinels();
// Note: no ownsTerminalAgent in the overrides — uses the `?? true` default.
const handle = buildFetchHandler(makeMinimalConfig());
const calls = await withStubs(async () => {
await runShutdown(handle);
});
expect(readIfExists(PORT_FILE)).toBeNull();
expect(readIfExists(TOKEN_FILE)).toBeNull();
expect(readIfExists(AGENT_RECORD_FILE)).toBeNull();
const probes = calls.filter(([pid, sig]) => pid === SENTINEL_DEAD_PID && sig === 0);
expect(probes.length).toBeGreaterThan(0);
});
test('4. CLI start() call site passes ownsTerminalAgent: true literally (static grep)', () => {
// Resolves browse/src/server.ts relative to this test file so the test
// works regardless of cwd. import.meta.url is the test file's URL.
const serverTsPath = path.resolve(
new URL(import.meta.url).pathname,
'..',
'..',
'src',
'server.ts',
);
const source = fs.readFileSync(serverTsPath, 'utf-8');
// Match the call site inside start()'s buildFetchHandler({...}) literal.
// The pattern looks for the trailing comma and trailing context so the
// match cannot be satisfied by the JSDoc reference earlier in the file.
expect(source).toMatch(/ownsTerminalAgent:\s*true,\s*\/\/\s*CLI spawns terminal-agent\.ts/);
});
});
@@ -1,94 +0,0 @@
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
// Server-side route shape for the v1.44 lease + restart + dispose +
// lease-refresh wiring. Live route exercises require the terminal-agent
// loopback to be live (e2e-tier); these static-grep tripwires pin the
// load-bearing protocol invariants.
const SERVER_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'server.ts');
describe('server: PTY lease routes (v1.44+ Commit 2)', () => {
test('1. /pty-session returns the 4-tuple shape (sessionId, attachToken, leaseExpiresAt)', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
const block = sliceBetween(src, "url.pathname === '/pty-session' &&", "url.pathname === '/pty-session/reattach'");
expect(block).toContain('mintLease()');
expect(block).toContain('grantPtyToken(minted.token, lease.sessionId)');
expect(block).toContain('sessionId: lease.sessionId');
expect(block).toContain('attachToken: minted.token');
expect(block).toContain('leaseExpiresAt: lease.expiresAt');
// Backward compat: legacy ptySessionToken alias preserved for one release.
expect(block).toContain('ptySessionToken: minted.token');
});
test('2. /pty-session/reattach validates lease + mints fresh attachToken', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
const block = sliceBetween(src, "url.pathname === '/pty-session/reattach'", "url.pathname === '/pty-restart'");
// Validate-first: rejects unknown/expired sessionId with 410 Gone so
// the client knows to fall back to a fresh /pty-session.
expect(block).toContain('validateLease(sessionId)');
expect(block).toContain('status: 410');
// Mint fresh token bound to SAME sessionId.
expect(block).toContain('grantPtyToken(minted.token, sessionId!)');
});
test('3. /pty-restart is one transaction — dispose + revoke + fresh mint', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
const block = sliceBetween(src, "url.pathname === '/pty-restart'", "url.pathname === '/pty-dispose'");
// Disposes old session (best-effort — missing sessionId is non-fatal).
expect(block).toContain('restartPtySession(oldSessionId)');
expect(block).toContain('revokeLease(oldSessionId)');
// Then mints fresh sessionId + lease + attachToken in the same handler.
expect(block).toContain('mintLease()');
expect(block).toContain('grantPtyToken(minted.token, lease.sessionId)');
// Returns the same 4-tuple shape so the client doesn't need a
// separate /pty-session round-trip.
expect(block).toContain('attachToken: minted.token');
expect(block).toContain('leaseExpiresAt: lease.expiresAt');
});
test('4. /pty-dispose accepts body-token (sendBeacon-compatible)', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
const block = sliceBetween(src, "url.pathname === '/pty-dispose'", "url.pathname === '/internal/lease-refresh'");
// sendBeacon can't set custom headers, so the route MUST accept the
// auth token in the request body. Otherwise pagehide cleanup fails
// silently every time the user closes the browser.
expect(block).toContain('body?.authToken');
expect(block).toContain('authedByBody');
// Both auth paths must validate against authToken — never just trust
// a body-supplied token without the equality check.
expect(block).toContain('authTokenFromBody === authToken');
});
test('5. /internal/lease-refresh resets the daemon idle timer (T6)', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
const block = sliceBetween(src, "url.pathname === '/internal/lease-refresh'", '─── /pty-inject-scan');
expect(block).toContain('refreshLease(sessionId)');
expect(block).toContain('resetIdleTimer()');
// Refresh failure (unknown / expired) MUST 410, not 200, so the
// agent knows to close the WS and force a clean re-auth.
expect(block).toContain('status: 410');
});
test('6. grantPtyToken loopback carries sessionId binding', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
expect(src).toMatch(/grantPtyToken\(token: string, sessionId\?: string\)/);
expect(src).toContain('sessionId ? { token, sessionId } : { token }');
});
test('7. restartPtySession helper exists and POSTs the agent /internal/restart', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
expect(src).toMatch(/async function restartPtySession\(sessionId: string\)/);
expect(src).toContain('/internal/restart');
expect(src).toContain('JSON.stringify({ sessionId })');
});
});
function sliceBetween(source: string, start: string, end: string): string {
const i = source.indexOf(start);
if (i === -1) throw new Error(`marker not found: ${start}`);
const j = source.indexOf(end, i + start.length);
if (j === -1) throw new Error(`end marker not found: ${end}`);
return source.slice(i, j);
}
-122
View File
@@ -1,122 +0,0 @@
/**
* HTTP regression for the terminal-first sidepanel architecture.
*
* The legacy one-shot sidebar-agent/chat queue was removed in v1.44. These
* routes must stay unavailable: silently reviving one would recreate a second
* agent lifecycle and its retired prompt/security surface. Current terminal,
* activity, and browser routes have their own focused integration suites.
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { spawn, type Subprocess } from 'bun';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
let serverProc: Subprocess | null = null;
let serverPort = 0;
let authToken = '';
let tmpDir = '';
let stateFile = '';
let retiredQueueFile = '';
async function api(pathname: string, opts: RequestInit & { noAuth?: boolean } = {}): Promise<Response> {
const { noAuth, ...fetchOpts } = opts;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(fetchOpts.headers as Record<string, string> || {}),
};
if (!noAuth && !headers.Authorization && authToken) {
headers.Authorization = `Bearer ${authToken}`;
}
return fetch(`http://127.0.0.1:${serverPort}${pathname}`, { ...fetchOpts, headers });
}
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sidebar-retired-routes-'));
stateFile = path.join(tmpDir, 'browse.json');
retiredQueueFile = path.join(tmpDir, 'sidebar-queue.jsonl');
const serverScript = path.resolve(import.meta.dir, '..', 'src', 'server.ts');
serverProc = spawn(['bun', 'run', serverScript], {
env: {
...process.env,
BROWSE_STATE_FILE: stateFile,
BROWSE_HEADLESS_SKIP: '1',
BROWSE_PORT: '0',
SIDEBAR_QUEUE_PATH: retiredQueueFile,
BROWSE_IDLE_TIMEOUT: '300',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
if (fs.existsSync(stateFile)) {
try {
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
if (state.port && state.token) {
serverPort = state.port;
authToken = state.token;
break;
}
} catch {}
}
await Bun.sleep(100);
}
if (!serverPort) throw new Error('Server did not start in time');
}, 20_000);
afterAll(() => {
if (serverProc) {
try { serverProc.kill(); } catch {}
}
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
});
const RETIRED_ROUTES: Array<[string, string]> = [
['POST', '/sidebar-command'],
['POST', '/sidebar-agent/event'],
['POST', '/sidebar-agent/kill'],
['GET', '/sidebar-session'],
['POST', '/sidebar-session/new'],
['GET', '/sidebar-chat?after=0'],
['POST', '/sidebar-chat/clear'],
];
describe('retired sidebar-agent HTTP surface', () => {
test('still applies authentication before disclosing route availability', async () => {
const response = await api('/sidebar-command', {
method: 'POST',
noAuth: true,
body: JSON.stringify({ message: 'test' }),
});
expect(response.status).toBe(401);
});
test('every retired route is absent for an authenticated caller', async () => {
for (const [method, route] of RETIRED_ROUTES) {
const response = await api(route, {
method,
body: method === 'GET' ? undefined : JSON.stringify({ message: 'test', type: 'text' }),
});
expect(response.status).toBe(404);
}
});
test('probing retired routes never creates the old queue file', async () => {
expect(fs.existsSync(retiredQueueFile)).toBe(false);
await api('/sidebar-command', {
method: 'POST',
body: JSON.stringify({ message: 'must not queue' }),
});
expect(fs.existsSync(retiredQueueFile)).toBe(false);
});
test('the current authenticated health surface remains available', async () => {
const response = await api('/health');
expect(response.status).toBe(200);
const payload = await response.json() as { status?: string };
expect(['healthy', 'unhealthy']).toContain(payload.status);
});
});
-134
View File
@@ -1,134 +0,0 @@
/**
* Current terminal-sidepanel security boundary.
*
* Detailed PTY lifecycle behavior has dedicated tests. These source contracts
* instead pin the cross-process handoff: the extension trades the daemon root
* token for a session-scoped attach token, and only the loopback terminal agent
* accepts that token from a Chrome extension origin.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..', '..');
const TERMINAL_AGENT_PATH = path.join(ROOT, 'browse', 'src', 'terminal-agent.ts');
const SERVER_PATH = path.join(ROOT, 'browse', 'src', 'server.ts');
const LEGACY_AGENT_PATH = path.join(ROOT, 'browse', 'src', 'sidebar-agent.ts');
const TERMINAL_CLIENT_PATH = path.join(ROOT, 'extension', 'sidepanel-terminal.js');
const SIDEPANEL_PATH = path.join(ROOT, 'extension', 'sidepanel.js');
const BACKGROUND_PATH = path.join(ROOT, 'extension', 'background.js');
const TERMINAL_AGENT_SRC = fs.readFileSync(TERMINAL_AGENT_PATH, 'utf8');
const SERVER_SRC = fs.readFileSync(SERVER_PATH, 'utf8');
const TERMINAL_CLIENT_SRC = fs.readFileSync(TERMINAL_CLIENT_PATH, 'utf8');
const SIDEPANEL_SRC = fs.readFileSync(SIDEPANEL_PATH, 'utf8');
const BACKGROUND_SRC = fs.readFileSync(BACKGROUND_PATH, 'utf8');
function sliceBetween(source: string, startMarker: string, endMarker: string): string {
const start = source.indexOf(startMarker);
if (start === -1) throw new Error(`Missing source marker: ${startMarker}`);
const end = source.indexOf(endMarker, start + startMarker.length);
if (end === -1) throw new Error(`Missing source marker: ${endMarker}`);
return source.slice(start, end);
}
describe('terminal sidepanel security boundary', () => {
test('PTY transport stays on loopback and sends attach auth outside the URL', () => {
expect(TERMINAL_AGENT_SRC).toContain("hostname: '127.0.0.1'");
expect(TERMINAL_AGENT_SRC).not.toContain("hostname: '0.0.0.0'");
const socketCalls = [...TERMINAL_CLIENT_SRC.matchAll(/new WebSocket\(([\s\S]*?)\);/g)]
.map((match) => match[1]);
expect(socketCalls.length).toBeGreaterThan(0);
for (const call of socketCalls) {
expect(call).toContain('ws://127.0.0.1:${terminalPort}/ws');
expect(call).toContain('gstack-pty.${');
expect(call).not.toContain('/ws?');
expect(call).not.toContain('authToken');
}
});
test('WebSocket upgrade requires extension Origin plus an in-memory session token', () => {
expect(TERMINAL_AGENT_SRC).toContain('const validTokens = new Map<string, string | null>()');
const wsRoute = sliceBetween(
TERMINAL_AGENT_SRC,
"if (url.pathname === '/ws')",
"return new Response('not found'",
);
const originGate = wsRoute.indexOf("origin.startsWith('chrome-extension://')");
const tokenGate = wsRoute.indexOf('validTokens.has(candidate)');
const upgrade = wsRoute.indexOf('server.upgrade(req');
expect(originGate).toBeGreaterThan(-1);
expect(tokenGate).toBeGreaterThan(originGate);
expect(upgrade).toBeGreaterThan(tokenGate);
expect(wsRoute).toContain('forbidden origin');
expect(wsRoute).toContain("req.headers.get('sec-websocket-protocol')");
expect(wsRoute).not.toContain("searchParams.get('token')");
});
test('/pty-session authenticates the daemon token then mints a session-scoped attach', () => {
const route = sliceBetween(
SERVER_SRC,
"if (url.pathname === '/pty-session' && req.method === 'POST')",
"if (url.pathname === '/pty-session/reattach'",
);
expect(route.indexOf('validateAuth(req)')).toBeLessThan(route.indexOf('mintLease()'));
expect(route).toContain('grantPtyToken(minted.token, lease.sessionId)');
expect(route).toContain('sessionId: lease.sessionId');
expect(route).toContain('attachToken: minted.token');
const clientMint = sliceBetween(
TERMINAL_CLIENT_SRC,
'async function mintSession()',
'function startReattachLoop',
);
expect(clientMint).toContain('/pty-session`');
expect(clientMint).toContain("'Authorization': `Bearer ${token}`");
expect(clientMint).not.toContain('?token=');
});
test('/pty-dispose authenticates and tears down only the named session', () => {
const route = sliceBetween(
SERVER_SRC,
"if (url.pathname === '/pty-dispose'",
"if (url.pathname === '/internal/lease-refresh'",
);
expect(route).toContain('authTokenFromBody === authToken');
expect(route).toContain("body?.sessionId === 'string'");
expect(route).toContain('restartPtySession(sessionId)');
expect(route).toContain('revokeLease(sessionId)');
const pagehide = SIDEPANEL_SRC.slice(SIDEPANEL_SRC.indexOf("addEventListener('pagehide'"));
expect(TERMINAL_CLIENT_SRC).toContain('window.gstackPtySession = currentSessionId');
expect(pagehide).toContain('JSON.stringify({ sessionId, authToken })');
expect(pagehide).toContain('/pty-dispose`');
expect(pagehide).not.toContain('/pty-dispose?');
});
test('background token bootstrap rejects foreign and content-script requesters', () => {
const listener = sliceBetween(
BACKGROUND_SRC,
'chrome.runtime.onMessage.addListener((msg, sender, sendResponse)',
"if (msg.type === 'fetchRefs')",
);
expect(listener).toContain('sender.id !== chrome.runtime.id');
const getToken = listener.slice(listener.indexOf("if (msg.type === 'getToken')"));
expect(getToken).toContain('if (sender.tab)');
expect(getToken).toContain('sendResponse({ token: null })');
expect(getToken).toContain('sendResponse({ token: authToken })');
});
test('interactive prompt path replaces the retired sidebar agent and routes', () => {
expect(fs.existsSync(LEGACY_AGENT_PATH)).toBe(false);
expect(SERVER_SRC).not.toMatch(/url\.pathname\s*===\s*['"]\/sidebar-/);
expect(SERVER_SRC).not.toMatch(/url\.pathname\.startsWith\(\s*['"]\/sidebar-/);
expect(SERVER_SRC).toContain('chatEnabled: false');
const spawn = sliceBetween(TERMINAL_AGENT_SRC, 'function spawnClaude', '/** Cleanup a PTY session');
expect(spawn).toContain("[claudePath, '--append-system-prompt', tabHint]");
expect(spawn).not.toMatch(/claudePath,\s*['"](?:-p|--print)['"]/);
});
});
-270
View File
@@ -1,270 +0,0 @@
/**
* Regression: sidebar layout invariants after the chat-tab rip.
*
* The Chrome side panel used to host two surfaces: Chat (one-shot
* `claude -p` queue) and Terminal (interactive PTY). Chat was ripped
* once the PTY proved out — sidebar-agent.ts is gone, the chat queue
* endpoints are gone, and the primary-tab nav (Terminal | Chat) is
* gone. Terminal is now the sole primary surface.
*
* This file locks the load-bearing invariants of that layout so a
* future refactor can't silently re-introduce the old surface or break
* the new one.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const HTML = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.html'), 'utf-8');
const JS = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.js'), 'utf-8');
const TERM_JS = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel-terminal.js'), 'utf-8');
const MANIFEST = JSON.parse(fs.readFileSync(path.join(import.meta.dir, '../../extension/manifest.json'), 'utf-8'));
describe('sidebar: chat tab + nav are removed, Terminal is sole primary surface', () => {
test('No primary-tab nav element exists', () => {
expect(HTML).not.toContain('class="primary-tabs"');
expect(HTML).not.toContain('data-pane="chat"');
expect(HTML).not.toContain('data-pane="terminal"');
});
test('No <main id="tab-chat"> pane', () => {
expect(HTML).not.toMatch(/<main[^>]*id="tab-chat"/);
expect(HTML).not.toContain('id="chat-messages"');
expect(HTML).not.toContain('id="chat-loading"');
expect(HTML).not.toContain('id="chat-welcome"');
});
test('No chat input / send button / experimental banner', () => {
expect(HTML).not.toContain('class="command-bar"');
expect(HTML).not.toContain('id="command-input"');
expect(HTML).not.toContain('id="send-btn"');
expect(HTML).not.toContain('id="stop-agent-btn"');
expect(HTML).not.toContain('id="experimental-banner"');
});
test('No clear-chat button in footer', () => {
expect(HTML).not.toContain('id="clear-chat"');
});
test('Terminal pane is .active by default and has the toolbar', () => {
expect(HTML).toMatch(/<main[^>]*id="tab-terminal"[^>]*class="tab-content active"/);
expect(HTML).toContain('id="terminal-toolbar"');
expect(HTML).toContain('id="terminal-restart-now"');
});
test('Quick-actions buttons (Cleanup / Screenshot / Cookies) survive in the terminal toolbar', () => {
// Garry explicitly wanted these kept after the chat rip — they drive
// browser actions, not chat.
expect(HTML).toContain('id="chat-cleanup-btn"');
expect(HTML).toContain('id="chat-screenshot-btn"');
expect(HTML).toContain('id="chat-cookies-btn"');
// They live inside the terminal toolbar now (siblings of the Restart
// button), not as a separate strip below all panes.
const toolbarStart = HTML.indexOf('id="terminal-toolbar"');
const toolbarEnd = HTML.indexOf('</div>', toolbarStart);
const toolbarBlock = HTML.slice(toolbarStart, toolbarEnd + 6);
expect(toolbarBlock).toContain('id="chat-cleanup-btn"');
expect(toolbarBlock).toContain('id="chat-screenshot-btn"');
expect(toolbarBlock).toContain('id="chat-cookies-btn"');
});
});
describe('sidepanel.js: chat helpers ripped, terminal-injection helper survives', () => {
test('No primary-tab click handler', () => {
expect(JS).not.toContain("querySelectorAll('.primary-tab')");
expect(JS).not.toContain('activePrimaryPaneId');
});
test('No chat polling, sendMessage, sendChat, stopAgent, or pollTabs', () => {
expect(JS).not.toContain('chatPollInterval');
expect(JS).not.toContain('function sendMessage');
expect(JS).not.toContain('function pollChat');
expect(JS).not.toContain('function pollTabs');
expect(JS).not.toContain('function switchChatTab');
expect(JS).not.toContain('function stopAgent');
expect(JS).not.toContain('function applyChatEnabled');
expect(JS).not.toContain('function showSecurityBanner');
});
test('Cleanup runs through the live PTY (no /sidebar-command POST)', () => {
// The new Cleanup handler injects the prompt straight into claude's
// PTY via gstackInjectToTerminal. The dead code path was a POST to
// /sidebar-command which kicked off a fresh claude -p subprocess.
const cleanup = JS.slice(JS.indexOf('async function runCleanup'));
expect(cleanup).toContain('window.gstackInjectToTerminal');
expect(cleanup).not.toContain('/sidebar-command');
expect(cleanup).not.toContain('addChatEntry');
});
test('Inspector "Send to Code" routes through the live PTY', () => {
const sendBtn = JS.slice(JS.indexOf('inspectorSendBtn.addEventListener'));
expect(sendBtn).toContain('window.gstackInjectToTerminal');
expect(sendBtn).not.toContain("type: 'sidebar-command'");
});
test('updateConnection no longer kicks off chat / tab polling', () => {
const update = JS.slice(JS.indexOf('function updateConnection'), JS.indexOf('function updateConnection') + 1500);
expect(update).not.toContain('chatPollInterval');
expect(update).not.toContain('tabPollInterval');
expect(update).not.toContain('pollChat');
expect(update).not.toContain('pollTabs');
// BUT must still expose the bootstrap globals for sidepanel-terminal.js.
expect(update).toContain('window.gstackServerPort');
expect(update).toContain('window.gstackAuthToken');
});
});
describe('sidepanel-terminal.js: eager auto-connect + injection API', () => {
test('Exposes window.gstackInjectToTerminal for cross-pane use', () => {
expect(TERM_JS).toContain('window.gstackInjectToTerminal');
// Returns false when no live session, true when bytes go out.
const inject = TERM_JS.slice(TERM_JS.indexOf('window.gstackInjectToTerminal'));
expect(inject).toContain('return false');
expect(inject).toContain('return true');
expect(inject).toContain('ws.readyState !== WebSocket.OPEN');
});
test('Auto-connects on init (no keypress required)', () => {
expect(TERM_JS).not.toContain('function onAnyKey');
expect(TERM_JS).not.toContain("addEventListener('keydown'");
expect(TERM_JS).toContain('function tryAutoConnect');
});
test('Repaint hook fires when Terminal pane becomes visible', () => {
// The chat-tab rip removed gstack:primary-tab-changed; we use a
// MutationObserver on #tab-terminal's class attr instead. The
// observer must call repaintIfLive when the .active class returns.
expect(TERM_JS).toContain('MutationObserver');
expect(TERM_JS).toContain("attributeFilter: ['class']");
expect(TERM_JS).toContain('repaintIfLive');
const repaint = TERM_JS.slice(TERM_JS.indexOf('function repaintIfLive'));
expect(repaint).toContain('fitAddon && fitAddon.fit()');
expect(repaint).toContain('term.refresh');
expect(repaint).toContain("type: 'resize'");
});
test('No auto-reconnect on close (Restart is user-initiated)', () => {
const closeOnly = TERM_JS.slice(
TERM_JS.indexOf("ws.addEventListener('close'"),
TERM_JS.indexOf("ws.addEventListener('error'"),
);
expect(closeOnly).not.toContain('setTimeout');
expect(closeOnly).not.toContain('tryAutoConnect');
expect(closeOnly).not.toContain('connect()');
});
test('forceRestart uses the session-scoped restart transaction and resets local state', () => {
expect(TERM_JS).toContain('function forceRestart');
const fn = TERM_JS.slice(TERM_JS.indexOf('function forceRestart'));
expect(fn).toContain("ws && ws.close(4001, 'intentional-restart')");
expect(fn).toContain('term.dispose()');
expect(fn).toContain('STATE.IDLE');
expect(fn).toContain('/pty-restart');
expect(fn).toContain('priorSessionId');
expect(fn).toContain('tryAutoConnect()');
});
test('Both restart buttons (mid-session and ENDED) call forceRestart', () => {
expect(TERM_JS).toContain("els.restart?.addEventListener('click', forceRestart)");
expect(TERM_JS).toContain("els.restartNow?.addEventListener('click', forceRestart)");
});
});
describe('server.ts: chat / sidebar-agent endpoints are gone', () => {
const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8');
test('No /sidebar-command, /sidebar-chat, /sidebar-agent/* routes', () => {
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-command['"]/);
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-chat['"]/);
expect(SERVER_SRC).not.toMatch(/url\.pathname\.startsWith\(['"]\/sidebar-agent\//);
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-agent\/event['"]/);
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-tabs['"]/);
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-session['"]/);
});
test('No chat-related state declarations or helpers', () => {
// Allow the symbol names inside the rip-marker comments — but no
// `let`, `const`, `function`, or `interface` declarations of them.
expect(SERVER_SRC).not.toMatch(/^let agentProcess/m);
expect(SERVER_SRC).not.toMatch(/^let agentStatus/m);
expect(SERVER_SRC).not.toMatch(/^let messageQueue/m);
expect(SERVER_SRC).not.toMatch(/^let sidebarSession/m);
expect(SERVER_SRC).not.toMatch(/^const tabAgents/m);
expect(SERVER_SRC).not.toMatch(/^function pickSidebarModel/m);
expect(SERVER_SRC).not.toMatch(/^function processAgentEvent/m);
expect(SERVER_SRC).not.toMatch(/^function killAgent/m);
expect(SERVER_SRC).not.toMatch(/^function addChatEntry/m);
expect(SERVER_SRC).not.toMatch(/^interface ChatEntry/m);
expect(SERVER_SRC).not.toMatch(/^interface SidebarSession/m);
});
test('/health no longer surfaces agentStatus or messageQueue length', () => {
const health = SERVER_SRC.slice(SERVER_SRC.indexOf("url.pathname === '/health'"));
const slice = health.slice(0, 2000);
expect(slice).not.toContain('agentStatus');
expect(slice).not.toContain('messageQueue');
expect(slice).not.toContain('agentStartTime');
// chatEnabled is hardcoded false now (older clients still see the field).
expect(slice).toMatch(/chatEnabled:\s*false/);
// terminalPort survives.
expect(slice).toContain('terminalPort');
});
});
describe('cli.ts: sidebar-agent is no longer spawned', () => {
const CLI_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/cli.ts'), 'utf-8');
test('No Bun.spawn of sidebar-agent.ts', () => {
expect(CLI_SRC).not.toMatch(/Bun\.spawn\(\s*\['bun',\s*'run',\s*\w*[Aa]gent[Ss]cript\][\s\S]{0,300}sidebar-agent/);
// The variable name `agentScript` was for sidebar-agent. After the
// rip there's only termAgentScript. Allow comments to mention the
// history but not active spawn calls.
expect(CLI_SRC).not.toMatch(/^\s*let agentScript = path\.resolve/m);
});
test('Terminal-agent spawn survives', () => {
expect(CLI_SRC).toContain("import { spawnTerminalAgent } from './terminal-agent-control'");
expect(CLI_SRC).toMatch(/spawnTerminalAgent\(\{[\s\S]*?stateFile:[\s\S]*?serverPort:[\s\S]*?cwd:/);
});
});
describe('files: sidebar-agent.ts and its tests are deleted', () => {
test('browse/src/sidebar-agent.ts is gone', () => {
expect(fs.existsSync(path.join(import.meta.dir, '../src/sidebar-agent.ts'))).toBe(false);
});
test('sidebar-agent test files are gone', () => {
expect(fs.existsSync(path.join(import.meta.dir, 'sidebar-agent.test.ts'))).toBe(false);
expect(fs.existsSync(path.join(import.meta.dir, 'sidebar-agent-roundtrip.test.ts'))).toBe(false);
});
});
describe('manifest: ws permission + xterm-safe CSP', () => {
test('host_permissions covers ws localhost', () => {
expect(MANIFEST.host_permissions).toContain('ws://127.0.0.1:*/');
});
test('host_permissions still covers http localhost', () => {
expect(MANIFEST.host_permissions).toContain('http://127.0.0.1:*/');
});
test('manifest does NOT add unsafe-eval to extension_pages CSP', () => {
const csp = MANIFEST.content_security_policy;
if (csp && csp.extension_pages) {
expect(csp.extension_pages).not.toContain('unsafe-eval');
}
});
});
describe('manifest: live tab awareness needs "tabs" permission', () => {
// Without "tabs", chrome.tabs.query() returns tab objects with undefined
// url/title for any site outside host_permissions (e.g., everything except
// 127.0.0.1). snapshotTabs() then writes empty strings into tabs.json and
// active-tab.json silently skips the write — the sidebar agent loses track
// of what page the user is on. activeTab is too narrow (only after a user
// gesture on the extension action) for background polling.
test('permissions includes "tabs"', () => {
expect(MANIFEST.permissions).toContain('tabs');
});
});
-96
View File
@@ -1,96 +0,0 @@
/**
* Layer 1: Unit tests for sidebar utilities.
* Tests pure functions — no server, no processes, no network.
*/
import { describe, test, expect } from 'bun:test';
import { sanitizeExtensionUrl } from '../src/sidebar-utils';
describe('sanitizeExtensionUrl', () => {
test('passes valid http URL', () => {
expect(sanitizeExtensionUrl('http://example.com')).toBe('http://example.com/');
});
test('passes valid https URL', () => {
expect(sanitizeExtensionUrl('https://example.com/page?q=1')).toBe('https://example.com/page?q=1');
});
test('rejects chrome:// URLs', () => {
expect(sanitizeExtensionUrl('chrome://extensions')).toBeNull();
});
test('rejects chrome-extension:// URLs', () => {
expect(sanitizeExtensionUrl('chrome-extension://abcdef/popup.html')).toBeNull();
});
test('rejects javascript: URLs', () => {
expect(sanitizeExtensionUrl('javascript:alert(1)')).toBeNull();
});
test('rejects file:// URLs', () => {
expect(sanitizeExtensionUrl('file:///etc/passwd')).toBeNull();
});
test('rejects data: URLs', () => {
expect(sanitizeExtensionUrl('data:text/html,<h1>hi</h1>')).toBeNull();
});
test('strips raw control characters from URL', () => {
// URL constructor percent-encodes \x00 as %00, which is safe
// The regex strips any remaining raw control chars after .href normalization
const result = sanitizeExtensionUrl('https://example.com/\x00page\x1f');
expect(result).not.toBeNull();
expect(result!).not.toMatch(/[\x00-\x1f\x7f]/);
});
test('strips newlines (prompt injection vector)', () => {
const result = sanitizeExtensionUrl('https://evil.com/%0AUser:%20ignore');
// URL constructor normalizes %0A, control char stripping removes any raw newlines
expect(result).not.toBeNull();
expect(result!).not.toContain('\n');
});
test('truncates URLs longer than 2048 chars', () => {
const longUrl = 'https://example.com/' + 'a'.repeat(3000);
const result = sanitizeExtensionUrl(longUrl);
expect(result).not.toBeNull();
expect(result!.length).toBeLessThanOrEqual(2048);
});
test('returns null for null input', () => {
expect(sanitizeExtensionUrl(null)).toBeNull();
});
test('returns null for undefined input', () => {
expect(sanitizeExtensionUrl(undefined)).toBeNull();
});
test('returns null for empty string', () => {
expect(sanitizeExtensionUrl('')).toBeNull();
});
test('returns null for invalid URL string', () => {
expect(sanitizeExtensionUrl('not a url at all')).toBeNull();
});
test('does not crash on weird input', () => {
expect(sanitizeExtensionUrl(':///')).toBeNull();
expect(sanitizeExtensionUrl(' ')).toBeNull();
expect(sanitizeExtensionUrl('\x00\x01\x02')).toBeNull();
});
test('preserves query parameters and fragments', () => {
const url = 'https://example.com/search?q=test&page=2#results';
expect(sanitizeExtensionUrl(url)).toBe(url);
});
test('preserves port numbers', () => {
expect(sanitizeExtensionUrl('http://localhost:3000/api')).toBe('http://localhost:3000/api');
});
test('handles URL with auth (user:pass@host)', () => {
const result = sanitizeExtensionUrl('https://user:pass@example.com/');
expect(result).not.toBeNull();
expect(result).toContain('example.com');
});
});
-240
View File
@@ -1,240 +0,0 @@
/**
* Source-contract tests for the terminal-first browser sidepanel.
*
* The one-shot chat queue and sidebar-agent daemon were removed. These
* checks intentionally cover the current PTY surface and its retained debug
* tools without preserving obsolete chat implementation details.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const BROWSE_ROOT = path.resolve(import.meta.dir, '..');
const REPO_ROOT = path.resolve(BROWSE_ROOT, '..');
const EXTENSION_ROOT = path.join(REPO_ROOT, 'extension');
const html = fs.readFileSync(path.join(EXTENSION_ROOT, 'sidepanel.html'), 'utf8');
const sidepanel = fs.readFileSync(path.join(EXTENSION_ROOT, 'sidepanel.js'), 'utf8');
const terminal = fs.readFileSync(path.join(EXTENSION_ROOT, 'sidepanel-terminal.js'), 'utf8');
const background = fs.readFileSync(path.join(EXTENSION_ROOT, 'background.js'), 'utf8');
function between(source: string, startMarker: string, endMarker: string): string {
const start = source.indexOf(startMarker);
if (start < 0) return '';
const end = source.indexOf(endMarker, start + startMarker.length);
return end < 0 ? source.slice(start) : source.slice(start, end);
}
function withoutComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
}
describe('terminal-first sidepanel', () => {
test('terminal is the sole active primary pane', () => {
const activeMainIds = [...html.matchAll(
/<main\s+id="([^"]+)"\s+class="[^"]*\bactive\b[^"]*"/g,
)].map((match) => match[1]);
expect(activeMainIds).toEqual(['tab-terminal']);
expect(html).toContain('id="tab-terminal"');
expect(html).toContain('role="tabpanel" aria-label="Terminal"');
expect(html).not.toContain('id="tab-chat"');
expect(sidepanel).toContain("const PRIMARY_PANE_ID = 'tab-terminal';");
expect(sidepanel).toContain('document.getElementById(PRIMARY_PANE_ID).classList.add(\'active\')');
});
test('xterm, fit, and terminal bootstrap assets are shipped and ordered', () => {
const assets = [
'lib/xterm.css',
'lib/xterm.js',
'lib/xterm-addon-fit.js',
'sidepanel-terminal.js',
];
for (const asset of assets) {
expect(fs.existsSync(path.join(EXTENSION_ROOT, asset))).toBe(true);
expect(html).toContain(asset);
}
const scriptOrder = [
html.indexOf('lib/xterm.js'),
html.indexOf('lib/xterm-addon-fit.js'),
html.indexOf('sidepanel.js'),
html.indexOf('sidepanel-terminal.js'),
];
expect(scriptOrder.every((index) => index >= 0)).toBe(true);
expect(scriptOrder).toEqual([...scriptOrder].sort((left, right) => left - right));
for (const id of [
'terminal-bootstrap',
'terminal-bootstrap-status',
'terminal-install-card',
'terminal-mount',
'terminal-ended',
'terminal-restart',
'terminal-restart-now',
]) {
expect(html).toContain(`id="${id}"`);
}
expect(terminal).toContain("setState(STATE.IDLE, { message: 'Starting Claude Code...' })");
expect(terminal).toContain('tryAutoConnect();');
});
test('retired chat queue code and daemon stay removed', () => {
const executableSidepanel = withoutComments(sidepanel);
const executableTerminal = withoutComments(terminal);
const removedFunctions = ['sendMessage', 'pollChat', 'switchChatTab'];
expect(fs.existsSync(path.join(BROWSE_ROOT, 'src', 'sidebar-agent.ts'))).toBe(false);
for (const name of removedFunctions) {
const declaration = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\(`);
expect(executableSidepanel).not.toMatch(declaration);
expect(executableTerminal).not.toMatch(declaration);
}
expect(executableSidepanel).not.toContain('/sidebar-chat');
expect(executableSidepanel).not.toContain('/sidebar-command');
expect(html).not.toContain('id="chat-input"');
expect(html).not.toContain('id="chat-messages"');
expect(html).not.toContain('id="stop-agent-btn"');
});
});
describe('PTY lifecycle security', () => {
test('bootstrap uses authenticated POST and a one-use WebSocket protocol token', () => {
const connection = between(sidepanel, 'function updateConnection(', '// ─── Port Configuration');
const mint = between(terminal, 'async function mintSession()', 'function startReattachLoop(');
expect(connection).toContain('window.gstackServerPort');
expect(connection).toContain('window.gstackAuthToken');
expect(mint).toContain('`http://127.0.0.1:${serverPort}/pty-session`');
expect(mint).toContain("method: 'POST'");
expect(mint).toContain("'Authorization': `Bearer ${token}`");
expect(mint).toContain("credentials: 'include'");
expect(terminal).toContain('const attachToken = minted.attachToken || minted.ptySessionToken');
expect(terminal).toContain(
'new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${attachToken}`])',
);
expect(terminal).not.toContain('?token=');
});
test('session identity is retained only for explicit pagehide disposal', () => {
const disposal = sidepanel.slice(sidepanel.indexOf("window.addEventListener('pagehide'"));
expect(terminal).toContain('currentSessionId = sessionId || null');
expect(terminal).toContain('window.gstackPtySession = currentSessionId');
expect(disposal).toContain('const sessionId = window.gstackPtySession');
expect(disposal).toContain('const authToken = window.gstackAuthToken');
expect(disposal).toContain('if (!sessionId || !authToken || !port) return');
expect(disposal).toContain('JSON.stringify({ sessionId, authToken })');
expect(disposal).toContain('navigator.sendBeacon(`http://127.0.0.1:${port}/pty-dispose`, blob)');
expect(disposal).not.toContain('?token=');
});
test('tab state crosses the extension boundary only through the live PTY relay', () => {
const push = between(background, 'async function pushTabState(', "chrome.tabs.onActivated.addListener");
const sidepanelRelay = between(sidepanel, "if (msg.type === 'browserTabState')", '// ─── v1.44 pagehide');
const terminalRelay = between(
terminal,
"document.addEventListener('gstack:tab-state'",
'// Repaint after a debug-tab',
);
expect(push).toContain("type: 'browserTabState'");
expect(push).toContain('...snapshot');
expect(background).toContain("pushTabState('activated')");
expect(background).toContain("pushTabState('created')");
expect(background).toContain("pushTabState('removed')");
expect(sidepanelRelay).toContain("new CustomEvent('gstack:tab-state'");
expect(sidepanelRelay).toContain('detail: { active: msg.active, tabs: msg.tabs, reason: msg.reason }');
expect(terminalRelay).toContain('if (!ws || ws.readyState !== WebSocket.OPEN) return');
expect(terminalRelay).toContain("type: 'tabState'");
expect(terminalRelay).toContain('active: ev.detail?.active');
expect(terminalRelay).toContain('tabs: ev.detail?.tabs');
});
test('page-derived inspector and cleanup prompts are scanned before PTY injection', () => {
const inspectorSend = between(sidepanel, "inspectorSendBtn.addEventListener('click'", '// ─── Quick Action Helpers');
const cleanup = between(sidepanel, 'async function runCleanup(', 'async function runScreenshot(');
for (const block of [inspectorSend, cleanup]) {
const scan = block.indexOf('gstackScanForPTYInject');
const inject = block.indexOf('gstackInjectToTerminal');
expect(scan).toBeGreaterThan(0);
expect(inject).toBeGreaterThan(scan);
expect(block).toContain("verdict === 'BLOCK'");
expect(block).toContain("verdict === 'WARN'");
}
});
});
describe('retained debug tools and quick actions', () => {
test('activity, refs, and inspector remain hidden debug panels', () => {
const debugNav = between(html, '<nav class="tabs debug-tabs"', '</nav>');
expect(html).toContain('id="tab-activity"');
expect(html).toContain('id="activity-feed"');
expect(html).toContain('id="tab-refs"');
expect(html).toContain('id="refs-list"');
expect(html).toContain('id="tab-inspector"');
expect(html).toContain('id="inspector-content"');
expect(debugNav).toContain('id="debug-tabs"');
expect(debugNav).toContain('style="display:none"');
expect([...debugNav.matchAll(/data-tab="([^"]+)"/g)].map((match) => match[1])).toEqual([
'activity',
'refs',
'inspector',
]);
});
test('debug streams use the authenticated current endpoints', () => {
const refs = between(sidepanel, 'async function fetchRefs()', '// ─── Inspector Tab');
const sseCookie = between(sidepanel, 'async function ensureSseSessionCookie()', 'async function connectSSE()');
const inspectorSse = between(sidepanel, 'async function connectInspectorSSE()', '// ─── Server Discovery');
expect(refs).toContain('`${serverUrl}/refs`');
expect(refs).toContain("headers['Authorization'] = `Bearer ${serverToken}`");
expect(sseCookie).toContain('`${serverUrl}/sse-session`');
expect(sseCookie).toContain("method: 'POST'");
expect(sseCookie).toContain("'Authorization': `Bearer ${serverToken}`");
expect(inspectorSse).toContain('await ensureSseSessionCookie()');
expect(inspectorSse).toContain('`${serverUrl}/inspector/events?_=${Date.now()}`');
expect(inspectorSse).toContain('new EventSource(url, { withCredentials: true })');
});
test('terminal toolbar exposes exactly the current quick actions', () => {
const toolbar = between(html, '<div class="terminal-toolbar"', '<div class="terminal-bootstrap"');
const buttonIds = [...toolbar.matchAll(/<button[^>]+id="([^"]+)"/g)].map((match) => match[1]);
expect(buttonIds).toEqual([
'chat-cleanup-btn',
'chat-screenshot-btn',
'chat-cookies-btn',
'terminal-restart-now',
]);
expect(toolbar).toContain('🧹 Cleanup');
expect(toolbar).toContain('📸 Screenshot');
expect(toolbar).toContain('🍪 Cookies');
expect(toolbar).toContain('↻ Restart');
expect(toolbar).not.toContain('<input');
expect(toolbar).not.toContain('<textarea');
});
test('quick actions route through the PTY or authenticated local command API', () => {
const cleanup = between(sidepanel, 'async function runCleanup(', 'async function runScreenshot(');
const screenshot = between(sidepanel, 'async function runScreenshot(', '// ─── Wire up all cleanup');
const cookies = between(sidepanel, "getElementById('chat-cookies-btn')", '// ─── Debug Tabs');
expect(cleanup).toContain("'$B cleanup --all'");
expect(cleanup).toContain('window.gstackInjectToTerminal');
expect(cleanup).not.toContain('/sidebar-command');
expect(screenshot).toContain('`${serverUrl}/command`');
expect(screenshot).toContain("command: 'screenshot'");
expect(screenshot).toContain('headers: { ...authHeaders()');
expect(cookies).toContain('`${serverUrl}/command`');
expect(cookies).toContain("command: 'goto'");
expect(cookies).toContain('`${serverUrl}/cookie-picker`');
expect(cookies).toContain('headers: authHeaders()');
});
});
@@ -1,70 +0,0 @@
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
// v1.44 patient autoConnect — static-grep invariants for the polling loop.
//
// Pre-v1.44 the sidebar gave up at 15s with "Browse server not ready.
// Reload sidebar to retry." Cold-start the browse server takes ~3-8s on a
// healthy laptop, longer on Conductor workspaces / slow CI, so the user
// frequently saw the failure message even when nothing was wrong. The
// fix: poll forever with ascending status messages and only abort on
// explicit unrecoverable signals (401 auth invalid).
const CLIENT_JS = path.resolve(
new URL(import.meta.url).pathname,
'..',
'..',
'..',
'extension',
'sidepanel-terminal.js',
);
describe('sidepanel tryAutoConnect patience (v1.44+)', () => {
test('1. no 15s give-up message', () => {
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
// The v0.x give-up string must NOT reappear — it's the message users
// saw on every cold start and the whole point of v1.44 was to delete it.
expect(src).not.toContain('Browse server not ready. Reload sidebar to retry.');
});
test('2. ascending status messages at 15s / 60s / 5min', () => {
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
expect(src).toContain('Waiting for browse server...');
expect(src).toContain('Still waiting');
expect(src).toContain('still not responding after 5 min');
});
test('3. sticky abort flag prevents loop spam on 401', () => {
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
expect(src).toContain('autoConnectAborted');
// The mint failure branch must short-circuit on 401 specifically.
expect(src).toMatch(/minted\.error.*startsWith\('401'\)/);
// tryAutoConnect tick must respect the flag.
expect(src).toMatch(/if \(autoConnectAborted\) return/);
});
test('4. forceRestart re-arms the loop by clearing the abort flag', () => {
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
// forceRestart is the user's "try again" escape hatch — must reset
// the sticky flag or 401-once means stuck-forever.
const block = sliceBetween(src, 'function forceRestart', 'function repaintIfLive');
expect(block).toContain('autoConnectAborted = false');
});
test('5. poll interval is 2s, not the legacy 200ms tight loop', () => {
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
// 200ms ticks burned CPU and made the give-up window land too fast.
// 2s is the v1.44 cadence — verify the tight-loop literal is gone.
expect(src).toContain('setTimeout(tick, 2000)');
expect(src).not.toContain('setTimeout(tick, 200)');
});
});
function sliceBetween(source: string, start: string, end: string): string {
const i = source.indexOf(start);
if (i === -1) throw new Error(`marker not found: ${start}`);
const j = source.indexOf(end, i + start.length);
if (j === -1) throw new Error(`end marker not found: ${end}`);
return source.slice(i, j);
}
-93
View File
@@ -1,93 +0,0 @@
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
// v1.44 Commit 3 — client-side re-attach loop.
//
// On unexpected WS close (anything other than clean 1000 / 4001 / 4404),
// the sidebar now silently posts /pty-session/reattach with backoff,
// opens a new WS with the fresh attachToken, writes RIS to xterm when
// the agent sends {type:"reattach-begin"}, then treats the next binary
// frame as the scrollback replay payload. Static-grep tripwires defend
// the load-bearing protocol invariants; live re-attach exercises belong
// in the e2e tier.
const TERMINAL_JS = path.resolve(
new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel-terminal.js',
);
describe('sidepanel re-attach loop (v1.44+ Commit 3)', () => {
test('1. STATE.RECONNECTING exists for the in-flight re-attach window', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
expect(src).toContain("RECONNECTING: 'reconnecting'");
});
test('2. backoff schedule matches the eng-review plan (1s/2s/4s/8s, 60s window)', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
expect(src).toContain('REATTACH_BACKOFF_MS = [1000, 2000, 4000, 8000]');
expect(src).toContain('REATTACH_WINDOW_MS = 60_000');
});
test('3. startReattachLoop posts /pty-session/reattach with sessionId', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
expect(src).toMatch(/function startReattachLoop\(prevSessionId\)/);
const block = sliceBetween(src, 'function startReattachLoop', 'function openReattachWebSocket');
expect(block).toContain('/pty-session/reattach');
expect(block).toContain('sessionId: prevSessionId');
});
test('4. 410 Gone from re-attach short-circuits to ENDED (no retry loop)', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
const block = sliceBetween(src, 'function startReattachLoop', 'function openReattachWebSocket');
// 410 = lease window expired. Retrying wouldn't help; fall through
// so the user clicks Restart for a fresh session.
expect(block).toContain('resp.status === 410');
expect(block).toContain('setState(STATE.ENDED)');
});
test('5. 401 from re-attach sticky-aborts auto-connect', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
const block = sliceBetween(src, 'function startReattachLoop', 'function openReattachWebSocket');
expect(block).toContain('resp.status === 401');
expect(block).toContain('autoConnectAborted = true');
});
test('6. openReattachWebSocket handles {type:"reattach-begin"} → RIS to xterm', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
const block = sliceBetween(src, 'function openReattachWebSocket', 'async function checkClaudeAvailable');
expect(block).toContain("msg.type === 'reattach-begin'");
// RIS (\x1bc) is the full-reset escape that clears xterm cleanly
// before the replay binary arrives.
expect(block).toContain("term.write('\\x1bc')");
expect(block).toContain('nextBinaryIsReplay = true');
});
test('7. live connect()/forceRestart() close handlers trigger re-attach on transient close', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
// Both the connect() and forceRestart() close handlers must route
// through startReattachLoop for non-clean codes. Count = 3
// (open-reattach close handler + connect close + forceRestart close).
const occurrences = (src.match(/startReattachLoop\(currentSessionId\)/g) || []).length;
expect(occurrences).toBeGreaterThanOrEqual(3);
});
test('8. clean codes (1000 / 4001 / 4404) bypass the re-attach loop', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
// The branch guard MUST exclude these codes from re-attach. 1000 =
// PTY exited (claude quit), 4001 = intentional restart, 4404 = no
// claude on PATH. Re-attaching in those cases would be wasted work
// (or actively wrong — a force-restart that re-attaches to its own
// pre-restart session is the bug we're avoiding).
expect(src).toContain('code === 1000');
expect(src).toContain('code === 4001');
expect(src).toContain('code === 4404');
});
});
function sliceBetween(source: string, start: string, end: string): string {
const i = source.indexOf(start);
if (i === -1) throw new Error(`marker not found: ${start}`);
const j = source.indexOf(end, i + start.length);
if (j === -1) throw new Error(`end marker not found: ${end}`);
return source.slice(i, j);
}
@@ -1,106 +0,0 @@
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
// v1.44 Commit 2C — client-side restart + dispose wiring.
//
// Pre-v1.44 forceRestart only closed the client WS and disposed xterm;
// the old PTY died asynchronously via the agent's WS close handler.
// Race window between kill and mint, two claude instances briefly,
// no prompt visible until the user typed.
//
// Now forceRestart POSTs /pty-restart (one transaction: dispose + mint),
// opens the new WS with the fresh attachToken from the response, and
// sends {type:"start"} for the eager spawn. pagehide handler in
// sidepanel.js sendBeacon /pty-dispose so browser quit / panel close
// doesn't leak a 60s-zombie claude.
const TERMINAL_JS = path.resolve(
new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel-terminal.js',
);
const SIDEPANEL_JS = path.resolve(
new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel.js',
);
describe('sidepanel-terminal: forceRestart via /pty-restart (v1.44+)', () => {
test('1. mintSession callers read the 4-tuple (sessionId + attachToken)', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
// The new shape lands in `minted.sessionId` and `minted.attachToken`.
expect(src).toContain('const { terminalPort, sessionId } = minted');
expect(src).toContain('minted.attachToken || minted.ptySessionToken');
// Backward-compat fallback to ptySessionToken kept so a partially-
// updated extension still works against a fresh server.
});
test('2. eager spawn via {type:"start"} on ws.open', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
// Replaces the legacy `ws.send(TextEncoder().encode("\\n"))` newline
// hack that nudged the lazy-binary-spawn.
expect(src).toMatch(/ws\.send\(JSON\.stringify\(\{\s*type:\s*'start'\s*\}\)\)/);
expect(src).not.toContain("TextEncoder().encode('\\n')");
});
test('3. forceRestart sends 4001 close code (intentional restart)', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
expect(src).toMatch(/ws\.close\(4001/);
});
test('4. forceRestart POSTs /pty-restart with current sessionId', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
expect(src).toContain('/pty-restart');
expect(src).toContain('priorSessionId ? { sessionId: priorSessionId } : {}');
});
test('5. forceRestart 401 triggers sticky abort (no spam loop)', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
// Same defense pattern as connect() — 401 must flip the sticky flag
// or every 2s the user sees a fresh "Auth invalid" message.
const block = sliceBetween(src, 'async function forceRestart', 'function repaintIfLive');
expect(block).toContain('resp.status === 401');
expect(block).toContain('autoConnectAborted = true');
});
test('6. currentSessionId is exposed on window for sidepanel.js pagehide', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
expect(src).toContain('window.gstackPtySession = currentSessionId');
});
});
describe('sidepanel: pagehide → sendBeacon /pty-dispose (v1.44+)', () => {
test('7. pagehide handler fires sendBeacon to /pty-dispose', () => {
const src = fs.readFileSync(SIDEPANEL_JS, 'utf-8');
expect(src).toMatch(/window\.addEventListener\('pagehide'/);
expect(src).toContain('navigator.sendBeacon');
expect(src).toContain('/pty-dispose');
});
test('8. pagehide payload carries sessionId + authToken in body (sendBeacon-compat)', () => {
const src = fs.readFileSync(SIDEPANEL_JS, 'utf-8');
// sendBeacon can't set custom headers — server route accepts body-auth.
// Both fields must be in the payload or the server rejects.
expect(src).toMatch(/JSON\.stringify\(\{\s*sessionId,\s*authToken\s*\}\)/);
expect(src).toContain('window.gstackPtySession');
expect(src).toContain('window.gstackAuthToken');
});
test('9. pagehide handler is best-effort (try/catch swallows failures)', () => {
const src = fs.readFileSync(SIDEPANEL_JS, 'utf-8');
// The 60s detach window catches any sendBeacon that fails, so the
// handler MUST not throw — uncaught throws can interfere with the
// browser's unload sequence. Slice between pagehide and end-of-file
// (it's the last addEventListener in sidepanel.js by design).
const i = src.indexOf("addEventListener('pagehide'");
expect(i).toBeGreaterThan(-1);
const block = src.slice(i);
expect(block).toMatch(/try \{/);
expect(block).toMatch(/} catch /);
});
});
function sliceBetween(source: string, start: string, end: string): string {
const i = source.indexOf(start);
if (i === -1) throw new Error(`marker not found: ${start}`);
const j = source.indexOf(end, i + start.length);
if (j === -1) throw new Error(`end marker not found: ${end}`);
return source.slice(i, j);
}
@@ -1,127 +0,0 @@
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
// v1.44 Commit 3 — detach state machine + ring buffer + re-attach replay.
//
// The state machine is what turns a single network blip from "fall through
// to ENDED state, click Restart" into "silent re-attach with scrollback
// intact, keep typing." Live WS cycles + buffer-overflow exercises belong
// in the e2e tier; these static-grep tripwires defend the load-bearing
// protocol + correctness properties.
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
describe('terminal-agent detach + re-attach (v1.44+ Commit 3)', () => {
test('1. PtySession carries ring buffer + alt-screen + detach state', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
const i = src.indexOf('interface PtySession {');
const j = src.indexOf('\n}', i);
const block = src.slice(i, j);
expect(block).toContain('liveWs: any | null');
expect(block).toContain('ringBuffer: Buffer[]');
expect(block).toContain('ringBufferBytes: number');
expect(block).toContain('altScreenActive: boolean');
expect(block).toContain('detached: boolean');
expect(block).toContain('detachTimer:');
});
test('2. RING_BUFFER_MAX_BYTES default is 1 MB, env-overridable', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
expect(src).toContain('GSTACK_PTY_RING_BUFFER_BYTES');
expect(src).toContain('1024 * 1024');
});
test('3. DETACH_WINDOW_MS default is 60s, env-overridable', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
expect(src).toContain('GSTACK_PTY_DETACH_WINDOW_MS');
expect(src).toContain("'60000'");
});
test('4. appendToRingBuffer evicts oldest frames past the cap', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
expect(src).toMatch(/function appendToRingBuffer\(/);
// Eviction loop: must keep at least one frame even at extreme caps
// (otherwise a single oversized frame would empty the buffer).
expect(src).toMatch(/session\.ringBufferBytes > RING_BUFFER_MAX_BYTES/);
expect(src).toContain('session.ringBuffer.length > 1');
expect(src).toContain('session.ringBuffer.shift()');
});
test('5. alt-screen tracking watches for CSI ?1049h / CSI ?1049l', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
// Canonical xterm enter/exit alt-screen sequences. Must update
// session.altScreenActive so the replay prelude knows.
expect(src).toContain('\\x1b[?1049h');
expect(src).toContain('\\x1b[?1049l');
expect(src).toContain('session.altScreenActive');
});
test('6. buildReplayPayload prefixes soft-reset (+ alt-screen if active)', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
expect(src).toMatch(/function buildReplayPayload\(/);
// DECSTR soft reset — re-defaults character attributes after the
// client's RIS clears the xterm buffer.
expect(src).toContain('\\x1b[!p');
// Conditionally re-enter alt-screen if claude was in a tool-call
// (alt-screen mode) at detach.
expect(src).toContain('session.altScreenActive');
});
test('7. WS open() re-attaches when sessionId already lives in sessionsById', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
const block = sliceBetween(src, 'open(ws) {', 'message(ws, raw) {');
expect(block).toContain('sessionsById.get(sessionId)');
expect(block).toContain('existing.liveWs = ws');
expect(block).toContain('clearTimeout(existing.detachTimer)');
// Tells the client to write RIS before treating the next binary
// frame as replay.
expect(block).toContain("type: 'reattach-begin'");
expect(block).toContain('sendBinary(buildReplayPayload(existing))');
});
test('8. WS close starts detach timer for non-intentional close codes', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
const i = src.indexOf('close(ws');
const j = src.indexOf('function handleTabState', i);
const block = src.slice(i, j);
// 4001 = intentional restart (Commit 2), 4404 = no-claude, 1000 = clean
// exit. Any other code (1006 abnormal, 1001 going-away, etc.) gets the
// 60s detach grace.
expect(block).toContain('code === 4001');
expect(block).toContain('code === 4404');
expect(block).toContain('code === 1000');
expect(block).toContain('session.detached = true');
expect(block).toContain('session.detachTimer = setTimeout');
expect(block).toContain('DETACH_WINDOW_MS');
// Detach timer must unref so the bun process can exit cleanly.
expect(block).toContain('detachTimer as any)?.unref?.()');
});
test('9. /internal/restart cancels detach timer before disposal', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
const block = sliceBetween(src, "url.pathname === '/internal/restart'", "// /claude-available");
// Without the cancellation, a later detach-timer fire would dispose a
// session that's already been disposed by the explicit restart path.
expect(block).toContain('clearTimeout(session.detachTimer)');
});
test('10. PTY on-data writes through session.liveWs (not the original ws closure)', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
// Critical for re-attach correctness: the PTY's on-data callback
// closes over `session`, not the original `ws`, so after re-attach
// it routes to the new liveWs automatically.
expect(src).toContain('session.liveWs.sendBinary');
// Always append to the ring buffer regardless of attach state — so
// a detached session still captures output for the next re-attach.
expect(src).toContain('appendToRingBuffer(session, flush)');
});
});
function sliceBetween(source: string, start: string, end: string): string {
const i = source.indexOf(start);
if (i === -1) throw new Error(`marker not found: ${start}`);
const j = source.indexOf(end, i + start.length);
if (j === -1) throw new Error(`end marker not found: ${end}`);
return source.slice(i, j);
}
@@ -1,273 +0,0 @@
/**
* Integration tests for terminal-agent.ts.
*
* Spawns the agent as a real subprocess in a temp state directory,
* exercises:
* 1. /internal/grant — loopback handshake with the internal token.
* 2. /ws Origin gate — non-extension Origin → 403.
* 3. /ws cookie gate — missing/invalid cookie → 401.
* 4. /ws full PTY round-trip — write `echo hi\n`, read `hi`.
* 5. resize control message — terminal accepts and stays alive.
* 6. close behavior — sending close terminates the PTY child.
*
* Uses /bin/bash via BROWSE_TERMINAL_BINARY override so CI doesn't need
* the `claude` binary installed.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
const AGENT_SCRIPT = path.join(import.meta.dir, '../src/terminal-agent.ts');
const BASH = '/bin/bash';
let stateDir: string;
let agentProc: any;
let agentPort: number;
let internalToken: string;
function readPortFile(): number {
for (let i = 0; i < 50; i++) {
try {
const v = parseInt(fs.readFileSync(path.join(stateDir, 'terminal-port'), 'utf-8').trim(), 10);
if (Number.isFinite(v) && v > 0) return v;
} catch {}
Bun.sleepSync(40);
}
throw new Error('terminal-agent never wrote port file');
}
function readTokenFile(): string {
for (let i = 0; i < 50; i++) {
try {
const t = fs.readFileSync(path.join(stateDir, 'terminal-internal-token'), 'utf-8').trim();
if (t.length > 16) return t;
} catch {}
Bun.sleepSync(40);
}
throw new Error('terminal-agent never wrote internal token');
}
beforeAll(() => {
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-term-'));
const stateFile = path.join(stateDir, 'browse.json');
// browse.json must exist so the agent's readBrowseToken doesn't throw.
fs.writeFileSync(stateFile, JSON.stringify({ token: 'test-browse-token' }));
agentProc = Bun.spawn(['bun', 'run', AGENT_SCRIPT], {
env: {
...process.env,
BROWSE_STATE_FILE: stateFile,
BROWSE_SERVER_PORT: '0', // not used in this test
BROWSE_TERMINAL_BINARY: BASH,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
agentPort = readPortFile();
internalToken = readTokenFile();
});
afterAll(() => {
try { agentProc?.kill?.(); } catch {}
try { fs.rmSync(stateDir, { recursive: true, force: true }); } catch {}
});
async function grantToken(token: string): Promise<Response> {
return fetch(`http://127.0.0.1:${agentPort}/internal/grant`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${internalToken}`,
},
body: JSON.stringify({ token }),
});
}
describe('terminal-agent: /internal/grant', () => {
test('accepts grants signed with the internal token', async () => {
const resp = await grantToken('test-cookie-token-very-long-yes');
expect(resp.status).toBe(200);
});
test('rejects grants with the wrong internal token', async () => {
const resp = await fetch(`http://127.0.0.1:${agentPort}/internal/grant`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer wrong-token',
},
body: JSON.stringify({ token: 'whatever' }),
});
expect(resp.status).toBe(403);
});
});
describe('terminal-agent: /ws gates', () => {
test('rejects upgrade attempts without an extension Origin', async () => {
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`);
expect(resp.status).toBe(403);
expect(await resp.text()).toBe('forbidden origin');
});
test('rejects upgrade attempts from a non-extension Origin', async () => {
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, {
headers: { 'Origin': 'https://evil.example.com' },
});
expect(resp.status).toBe(403);
});
test('rejects extension-Origin upgrades without a granted cookie', async () => {
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, {
headers: {
'Origin': 'chrome-extension://abc123',
'Cookie': 'gstack_pty=never-granted',
},
});
expect(resp.status).toBe(401);
});
});
describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () => {
test('binary writes go to PTY stdin, output streams back', async () => {
const cookie = 'rt-token-must-be-at-least-seventeen-chars-long';
const granted = await grantToken(cookie);
expect(granted.status).toBe(200);
const ws = new WebSocket(`ws://127.0.0.1:${agentPort}/ws`, {
headers: {
'Origin': 'chrome-extension://test-extension-id',
'Cookie': `gstack_pty=${cookie}`,
},
} as any);
const collected: string[] = [];
let opened = false;
let closed = false;
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('ws never opened')), 5000);
ws.addEventListener('open', () => { opened = true; clearTimeout(timer); resolve(); });
ws.addEventListener('error', (e: any) => { clearTimeout(timer); reject(new Error('ws error')); });
});
ws.addEventListener('message', (ev: any) => {
if (typeof ev.data === 'string') return; // ignore control frames
const buf = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : ev.data;
collected.push(new TextDecoder().decode(buf));
});
ws.addEventListener('close', () => { closed = true; });
// Lazy-spawn trigger: any binary frame causes the agent to spawn /bin/bash.
ws.send(new TextEncoder().encode('echo hello-pty-world\nexit\n'));
// Wait up to 5s for output and shutdown.
await new Promise<void>((resolve) => {
const start = Date.now();
const tick = () => {
const joined = collected.join('');
if (joined.includes('hello-pty-world')) return resolve();
if (Date.now() - start > 5000) return resolve();
setTimeout(tick, 50);
};
tick();
});
expect(opened).toBe(true);
const allOutput = collected.join('');
expect(allOutput).toContain('hello-pty-world');
try { ws.close(); } catch {}
// Give cleanup a moment.
await Bun.sleep(200);
});
test('Sec-WebSocket-Protocol auth path: browser-style upgrade with token in protocol', async () => {
// This is the path the actual browser extension takes. Cross-port
// SameSite=Strict cookies don't reliably survive the jump from the
// browse server (port A) to the agent (port B) when initiated from a
// chrome-extension origin, so we send the token via the only auth
// header the browser WebSocket API lets us set: Sec-WebSocket-Protocol.
//
// The browser sends `gstack-pty.<token>` and the agent must:
// 1) strip the gstack-pty. prefix
// 2) validate the token
// 3) ECHO the protocol back in the upgrade response
// Without (3) the browser closes the connection immediately, which
// is the exact bug the original cookie-only implementation hit in
// manual dogfood. This test catches that regression in CI.
const token = 'sec-protocol-token-must-be-at-least-seventeen-chars';
await grantToken(token);
// We exercise the protocol path by raw-handshaking via fetch+Upgrade,
// because Bun's test-client WebSocket constructor doesn't propagate
// `protocols` cleanly when also passed `headers` (the constructor
// detects the third-arg form unreliably). Real browsers (Chromium)
// use the standard protocols arg fine — the server-side handler is
// identical either way, so this test still locks the load-bearing
// invariant: the agent accepts a token via Sec-WebSocket-Protocol
// and echoes the protocol back so a browser would accept the upgrade.
const handshakeKey = 'dGhlIHNhbXBsZSBub25jZQ==';
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, {
headers: {
'Connection': 'Upgrade',
'Upgrade': 'websocket',
'Sec-WebSocket-Version': '13',
'Sec-WebSocket-Key': handshakeKey,
'Sec-WebSocket-Protocol': `gstack-pty.${token}`,
'Origin': 'chrome-extension://test-extension-id',
},
});
// 101 Switching Protocols + protocol echoed back = browser would accept.
// 401/403/anything else = browser would close the connection immediately
// (the bug we hit in manual dogfood).
expect(resp.status).toBe(101);
expect(resp.headers.get('upgrade')?.toLowerCase()).toBe('websocket');
expect(resp.headers.get('sec-websocket-protocol')).toBe(`gstack-pty.${token}`);
});
test('Sec-WebSocket-Protocol auth: rejects unknown token even with valid Origin', async () => {
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, {
headers: {
'Connection': 'Upgrade',
'Upgrade': 'websocket',
'Sec-WebSocket-Version': '13',
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
'Sec-WebSocket-Protocol': 'gstack-pty.never-granted-token',
'Origin': 'chrome-extension://test-extension-id',
},
});
expect(resp.status).toBe(401);
});
test('text frame {type:"resize"} is accepted (no crash, ws stays open)', async () => {
const cookie = 'resize-token-must-be-at-least-seventeen-chars';
await grantToken(cookie);
const ws = new WebSocket(`ws://127.0.0.1:${agentPort}/ws`, {
headers: {
'Origin': 'chrome-extension://test-extension-id',
'Cookie': `gstack_pty=${cookie}`,
},
} as any);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('ws never opened')), 5000);
ws.addEventListener('open', () => { clearTimeout(timer); resolve(); });
ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('ws error')); });
});
// Send a resize before anything else (lazy-spawn won't fire).
ws.send(JSON.stringify({ type: 'resize', cols: 120, rows: 40 }));
// After resize, send a binary frame; should still work.
ws.send(new TextEncoder().encode('exit\n'));
await Bun.sleep(300);
// ws still readyState 1 (OPEN) or 3 (CLOSED after exit) — both fine.
expect([WebSocket.OPEN, WebSocket.CLOSED]).toContain(ws.readyState);
try { ws.close(); } catch {}
});
});
@@ -1,51 +0,0 @@
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
// Static-grep tripwire for the v1.44 internalHandler refactor.
//
// /internal/grant and /internal/revoke were copies of the same dance:
// bearer-auth → x-browse-gen check → req.json().then(...).catch(...).
// internalHandler<T>(req, fn) collapses that into a single helper call.
// This test fails CI if the helper goes away or the existing routes
// regress to inline auth + JSON parse boilerplate. Wiring tests
// (token grant/revoke behavior) already live in
// browse/test/terminal-agent-integration.test.ts.
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
describe('terminal-agent internalHandler refactor (v1.44+)', () => {
test('1. internalHandler<T> exists with the documented signature', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
expect(src).toMatch(/async function internalHandler<T>\s*\(/);
// Body must include the auth gate, body parse, and result coercion.
expect(src).toContain('checkInternalAuth(req)');
expect(src).toContain('await req.json()');
expect(src).toContain('instanceof Response');
});
test('2. /internal/grant routes through internalHandler', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
// Match the route handler block.
const block = sliceBetween(src, "url.pathname === '/internal/grant'", "url.pathname === '/internal/revoke'");
expect(block).toContain('internalHandler(req');
// Must NOT have the old inline pattern (would be a regression).
expect(block).not.toContain('req.headers.get(\'authorization\')');
expect(block).not.toContain('req.json().then(');
});
test('3. /internal/revoke routes through internalHandler', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
const block = sliceBetween(src, "url.pathname === '/internal/revoke'", "url.pathname === '/internal/healthz'");
expect(block).toContain('internalHandler(req');
expect(block).not.toContain('req.json().then(');
});
});
function sliceBetween(source: string, start: string, end: string): string {
const i = source.indexOf(start);
if (i === -1) throw new Error(`marker not found: ${start}`);
const j = source.indexOf(end, i + start.length);
if (j === -1) throw new Error(`end marker not found: ${end}`);
return source.slice(i, j);
}
@@ -1,88 +0,0 @@
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
// v1.44 WS keepalive — static-grep invariants for the protocol contract.
//
// terminal-agent.ts and sidepanel-terminal.js cooperate on a 25s ping/pong +
// keepalive cycle so long-idle PTY connections survive NAT idle timeouts and
// Chromium's MV3 panel suspension heuristics. The wiring is invisible to
// integration tests (you'd have to wait 25s to observe a ping) but trivially
// regressed by a refactor. These tests fail CI if either side stops sending
// or stops accepting the protocol frames.
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
const CLIENT_JS = path.resolve(new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel-terminal.js');
describe('terminal-agent WS keepalive (v1.44+)', () => {
test('1. agent has a KEEPALIVE_INTERVAL_MS env knob, default 25000', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
expect(src).toContain('GSTACK_PTY_KEEPALIVE_INTERVAL_MS');
expect(src).toMatch(/KEEPALIVE_INTERVAL_MS\s*=\s*parseInt\(/);
// Default constant present so the env knob has a fallback.
expect(src).toContain("'25000'");
});
test('2. WS open handler starts a ping interval on the session', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
// The open(ws) handler in the websocket: { ... } block must call
// setInterval to drive the ping cadence and store the handle.
const wsBlock = sliceBetween(src, 'websocket: {', 'function handleTabState');
expect(wsBlock).toMatch(/open\s*\(\s*ws\s*\)/);
expect(wsBlock).toContain('setInterval');
expect(wsBlock).toContain("type: 'ping'");
expect(wsBlock).toContain('pingInterval');
});
test('3. WS close handler clears the ping interval', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
const wsBlock = sliceBetween(src, 'websocket: {', 'function handleTabState');
// close(ws, code?, reason?) MUST clearInterval the pingInterval —
// otherwise we leak timers across reconnects and the ping handler
// captures a dead ws ref. Signature widened in Commit 3 to include
// the close code for the detach state machine, hence the loose match.
expect(wsBlock).toMatch(/close\s*\(\s*ws/);
expect(wsBlock).toContain('clearInterval(session.pingInterval)');
});
test('4. message handler accepts pong / keepalive frames silently', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
// The text-frame router must recognize the keepalive vocabulary —
// if a future refactor strips this branch, unknown-text-frame
// suppression would still drop them but we lose intent.
expect(src).toMatch(/msg\?\.type === 'pong'/);
expect(src).toMatch(/msg\?\.type === 'keepalive'/);
});
test('5. client sends keepalive every 25s on ws.open', () => {
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
expect(src).toContain('keepaliveInterval');
expect(src).toMatch(/setInterval\(/);
expect(src).toContain("type: 'keepalive'");
expect(src).toContain('KEEPALIVE_INTERVAL_MS = 25000');
});
test('6. client replies pong to server ping', () => {
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
// The ws.message handler must short-circuit on msg.type === 'ping'
// and reply with {type: 'pong', ts: msg.ts}.
expect(src).toMatch(/msg\.type === 'ping'/);
expect(src).toMatch(/type: 'pong'/);
});
test('7. client clears keepalive in close + teardown + forceRestart', () => {
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
// Three teardown paths exist; all three must drop the interval to
// avoid leaking timers across reconnect attempts.
const occurrences = (src.match(/clearInterval\(keepaliveInterval\)/g) || []).length;
expect(occurrences).toBeGreaterThanOrEqual(3);
});
});
function sliceBetween(source: string, start: string, end: string): string {
const i = source.indexOf(start);
if (i === -1) throw new Error(`marker not found: ${start}`);
const j = source.indexOf(end, i + start.length);
if (j === -1) throw new Error(`end marker not found: ${end}`);
return source.slice(i, j);
}
@@ -1,161 +0,0 @@
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import {
readAgentRecord,
writeAgentRecord,
clearAgentRecord,
killAgentByRecord,
agentRecordPath,
type AgentRecord,
} from '../src/terminal-agent-control';
// REGRESSION TEST for the v1.44 PID-identity migration.
//
// Pre-v1.44, both `cli.ts` and `server.ts` killed the terminal-agent with
// `spawnSync('pkill', ['-f', 'terminal-agent\\.ts'])`. That command matches
// by argv regex — any process whose command line contains the string
// `terminal-agent.ts` got SIGTERM'd. In practice this killed:
//
// * sibling gstack sessions on the same host
// * editor processes (vim, code, less) that had the file open
// * any second gstack run on the host
//
// The v1.44 migration replaces both kill sites with identity-based PID kill
// against the record written at `<stateDir>/terminal-agent-pid` by the
// agent's own boot path. This test is the static-grep tripwire that prevents
// reintroducing the regex teardown anywhere in the source tree.
//
// Pattern mirrors browse/test/server-embedder-terminal-port.test.ts (Test 4)
// and browse/test/server-sanitize-surrogates.test.ts: read source files
// directly, assert an invariant on their contents.
const SRC_DIR = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src');
function readAllSourceFiles(): Array<{ file: string; content: string }> {
const out: Array<{ file: string; content: string }> = [];
for (const entry of fs.readdirSync(SRC_DIR)) {
if (!entry.endsWith('.ts')) continue;
const full = path.join(SRC_DIR, entry);
out.push({ file: entry, content: fs.readFileSync(full, 'utf-8') });
}
return out;
}
describe('terminal-agent PID identity (v1.44+)', () => {
test('1. no source file calls `pkill -f terminal-agent`', () => {
// The regex matches both `pkill -f terminal-agent\.ts` (escaped form
// used in spawnSync args) and `pkill -f terminal-agent.ts` (literal),
// since the dot is the only difference and both are footguns.
const offenders: string[] = [];
for (const { file, content } of readAllSourceFiles()) {
// Walk line by line so we can skip comments that mention the historical
// pattern (acceptable as documentation, not as code).
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!/pkill/.test(line)) continue;
if (!/terminal-agent/.test(line)) continue;
// Skip comment lines — historical mentions in JSDoc are fine.
const trimmed = line.trim();
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
offenders.push(`${file}:${i + 1}: ${trimmed}`);
}
}
expect(offenders).toEqual([]);
});
test('2. neither cli.ts nor server.ts calls spawnSync with pkill', () => {
// Tighter check — even if someone routes through a different code path,
// any spawnSync('pkill', ...) anywhere in src/ is the smell.
const offenders: string[] = [];
for (const { file, content } of readAllSourceFiles()) {
if (/spawnSync\s*\(\s*['"]pkill['"]/.test(content)) {
offenders.push(file);
}
}
expect(offenders).toEqual([]);
});
test('3. readAgentRecord round-trips writeAgentRecord', () => {
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gstack-pid-id-'));
try {
const record: AgentRecord = {
pid: 12345,
gen: 'test-gen-abcdef',
startedAt: Date.now(),
};
writeAgentRecord(tmpDir, record);
const read = readAgentRecord(tmpDir);
expect(read).toEqual(record);
expect(fs.existsSync(agentRecordPath(tmpDir))).toBe(true);
clearAgentRecord(tmpDir);
expect(readAgentRecord(tmpDir)).toBeNull();
expect(fs.existsSync(agentRecordPath(tmpDir))).toBe(false);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test('4. readAgentRecord returns null on missing or malformed file', () => {
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gstack-pid-id-'));
try {
// Missing.
expect(readAgentRecord(tmpDir)).toBeNull();
// Malformed: wrong type for pid.
fs.writeFileSync(agentRecordPath(tmpDir), JSON.stringify({ pid: 'not-a-number', gen: 'x', startedAt: 0 }));
expect(readAgentRecord(tmpDir)).toBeNull();
// Malformed: not JSON.
fs.writeFileSync(agentRecordPath(tmpDir), 'definitely not json');
expect(readAgentRecord(tmpDir)).toBeNull();
// Missing field.
fs.writeFileSync(agentRecordPath(tmpDir), JSON.stringify({ pid: 1, gen: 'x' }));
expect(readAgentRecord(tmpDir)).toBeNull();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test('5. killAgentByRecord returns false for a dead PID and never throws', () => {
// PID 2147483646 is below Linux PID_MAX_LIMIT but way above macOS's
// typical max — no real process will ever hold it. isProcessAlive
// returns false; killAgentByRecord no-ops.
const record: AgentRecord = {
pid: 2147483646,
gen: 'sentinel',
startedAt: Date.now(),
};
const result = killAgentByRecord(record, 'SIGTERM');
expect(result).toBe(false);
});
test('6. killAgentByRecord skips the kill when isProcessAlive is false', () => {
// Guard via process.kill stub: confirm killAgentByRecord does NOT call
// process.kill with a non-zero signal when the PID is dead. This is the
// belt-and-suspenders defense against PID-reuse: even if isProcessAlive
// changes implementation, killAgentByRecord must validate liveness first.
const origKill = process.kill;
const kills: Array<[number, NodeJS.Signals | number]> = [];
(process as any).kill = ((pid: number, sig: NodeJS.Signals | number) => {
kills.push([pid, sig ?? 'SIGTERM']);
if (sig === 0) {
const err: any = new Error('ESRCH');
err.code = 'ESRCH';
throw err;
}
return true;
}) as any;
try {
const record: AgentRecord = { pid: 9999999, gen: 'x', startedAt: Date.now() };
killAgentByRecord(record, 'SIGTERM');
const terminations = kills.filter(([, s]) => s !== 0);
expect(terminations).toEqual([]);
} finally {
(process as any).kill = origKill;
}
});
});
@@ -1,155 +0,0 @@
import { describe, test, expect, beforeEach } from 'bun:test';
import {
appendToRingBuffer,
buildReplayPayload,
type PtySession,
} from '../src/terminal-agent';
// Runtime exercises for the v1.44 Commit 3 ring buffer + replay prelude.
// Companion to browse/test/terminal-agent-detach-reattach.test.ts which
// covers the structural invariants; this file calls the helpers directly
// to prove behavioral correctness without spinning up a real Bun.serve
// listener.
function fresh(): PtySession {
return {
proc: null,
cols: 80,
rows: 24,
cookie: 'test-cookie',
liveWs: null,
sessionId: 'test-session',
spawned: false,
pingInterval: null,
ringBuffer: [],
ringBufferBytes: 0,
altScreenActive: false,
detached: false,
detachTimer: null,
};
}
describe('appendToRingBuffer runtime', () => {
test('appends frames in order and tracks byte count', () => {
const s = fresh();
appendToRingBuffer(s, Buffer.from('hello '));
appendToRingBuffer(s, Buffer.from('world'));
expect(s.ringBuffer).toHaveLength(2);
expect(s.ringBufferBytes).toBe(11);
expect(Buffer.concat(s.ringBuffer).toString()).toBe('hello world');
});
test('evicts oldest frames when cap exceeded', () => {
// Default cap is 1 MB. Override via env wouldn't help inside this
// running process (constant was read at module load), so use frames
// big enough to exceed it deterministically.
const s = fresh();
const big = Buffer.alloc(400_000, 0x41); // 400 KB of 'A'
appendToRingBuffer(s, big);
appendToRingBuffer(s, big);
appendToRingBuffer(s, big); // total 1.2 MB — exceeds default cap
// Eviction must drop frames until under cap; first 400 KB chunk goes.
expect(s.ringBuffer.length).toBeLessThan(3);
expect(s.ringBufferBytes).toBeLessThanOrEqual(1024 * 1024);
});
test('keeps at least one frame even when a single frame exceeds the cap', () => {
const s = fresh();
// 2 MB single frame — bigger than the 1 MB cap. The eviction loop
// guards on `ringBuffer.length > 1`, so the single oversized frame
// stays. Without that guard, the buffer would empty itself, defeating
// the whole point of replay on re-attach.
const huge = Buffer.alloc(2 * 1024 * 1024, 0x42);
appendToRingBuffer(s, huge);
expect(s.ringBuffer.length).toBe(1);
expect(s.ringBufferBytes).toBe(huge.length);
});
test('tracks alt-screen enter (CSI ?1049h)', () => {
const s = fresh();
expect(s.altScreenActive).toBe(false);
appendToRingBuffer(s, Buffer.from('plain text'));
expect(s.altScreenActive).toBe(false);
appendToRingBuffer(s, Buffer.from('\x1b[?1049h'));
expect(s.altScreenActive).toBe(true);
});
test('tracks alt-screen exit (CSI ?1049l)', () => {
const s = fresh();
appendToRingBuffer(s, Buffer.from('\x1b[?1049h'));
expect(s.altScreenActive).toBe(true);
appendToRingBuffer(s, Buffer.from('\x1b[?1049l'));
expect(s.altScreenActive).toBe(false);
});
test('trailing state wins when enter + exit appear in one frame', () => {
const s = fresh();
// Tool call opened alt-screen then closed it inside one render — net
// state is back to main screen. lastIndexOf comparison handles this.
appendToRingBuffer(s, Buffer.from('start\x1b[?1049hmiddle\x1b[?1049lend'));
expect(s.altScreenActive).toBe(false);
const s2 = fresh();
// Reverse order: exited then re-entered — net state alt-screen.
appendToRingBuffer(s2, Buffer.from('\x1b[?1049l\x1b[?1049h'));
expect(s2.altScreenActive).toBe(true);
});
});
describe('buildReplayPayload runtime', () => {
test('prepends DECSTR soft reset before ring buffer contents', () => {
const s = fresh();
appendToRingBuffer(s, Buffer.from('prompt> '));
const payload = buildReplayPayload(s).toString('latin1');
expect(payload.startsWith('\x1b[!p')).toBe(true);
expect(payload.endsWith('prompt> ')).toBe(true);
});
test('re-enters alt-screen when session was in alt-screen at detach', () => {
const s = fresh();
appendToRingBuffer(s, Buffer.from('\x1b[?1049h tool output '));
const payload = buildReplayPayload(s).toString('latin1');
// Order: soft reset, alt-screen re-enter, ring buffer.
expect(payload.indexOf('\x1b[!p')).toBeLessThan(payload.indexOf('\x1b[?1049h'));
expect(payload.indexOf('\x1b[?1049h')).toBeLessThan(payload.indexOf('tool output'));
});
test('omits alt-screen re-enter when session was on main screen', () => {
const s = fresh();
appendToRingBuffer(s, Buffer.from('regular prompt'));
const payload = buildReplayPayload(s).toString('latin1');
// Soft reset is present, but alt-screen enter is NOT. Both substrings
// are otherwise identical 8 bytes apart in the alphabet, so equal-
// substring checks need to be strict.
expect(payload).toContain('\x1b[!p');
expect(payload).not.toContain('\x1b[?1049h');
});
test('replay buffer length = soft-reset + (optional alt-screen) + ring bytes', () => {
const s = fresh();
appendToRingBuffer(s, Buffer.from('abc'));
appendToRingBuffer(s, Buffer.from('def'));
const payload = buildReplayPayload(s);
// 4 bytes (DECSTR) + 6 bytes (abc/def) = 10 bytes. No alt-screen.
expect(payload.length).toBe(4 + 6);
});
});
describe('lease lifecycle interplay (via pty-session-lease)', () => {
// Cross-module behavior: lease + ring buffer are both per-session.
// This catches the case where a refactor accidentally couples them.
test('lease registry is independent of ring buffer state', async () => {
const { mintLease, validateLease, __resetLeases } = await import('../src/pty-session-lease');
__resetLeases();
const a = mintLease();
const b = mintLease();
expect(a.sessionId).not.toBe(b.sessionId);
const va = validateLease(a.sessionId);
const vb = validateLease(b.sessionId);
expect(va.ok && vb.ok).toBe(true);
if (va.ok && vb.ok) {
expect(va.expiresAt).toBe(a.expiresAt);
expect(vb.expiresAt).toBe(b.expiresAt);
}
});
});
@@ -1,96 +0,0 @@
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
// v1.44 Commit 2 — terminal-agent sessionId routing + eager spawn.
//
// Live spawn tests would require a real claude binary on PATH and a Bun.serve
// listener; both are e2e-tier. These static-grep tripwires defend the load-
// bearing protocol changes:
// - validTokens carries the sessionId binding (Map, not Set)
// - sessionsById index exists for /internal/restart + (Commit 3) re-attach
// - /internal/restart is scoped to one sessionId (codex T2 fix)
// - {type:"start"} triggers spawn for eager UX after forceRestart
// - maybeSpawnPty helper is the single entry point for both spawn paths
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
describe('terminal-agent session routing (v1.44+ Commit 2)', () => {
test('1. validTokens is a Map binding token → sessionId', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
// Pre-Commit 2 was `Set<string>`; the Map carries the sessionId
// binding that /internal/restart and (Commit 3) re-attach depend on.
expect(src).toMatch(/const validTokens = new Map<string, string \| null>\(\)/);
expect(src).not.toMatch(/const validTokens = new Set</);
});
test('2. sessionsById reverse index exists', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
expect(src).toMatch(/const sessionsById = new Map<string, PtySession>\(\)/);
// Populated in open() — required so /internal/restart can find the session.
expect(src).toMatch(/if \(sessionId\) sessionsById\.set\(sessionId, session\)/);
});
test('3. /internal/grant binds an optional sessionId to the token', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
const block = sliceBetween(src, "url.pathname === '/internal/grant'", "url.pathname === '/internal/revoke'");
expect(block).toContain('validTokens.set(body.token, sid)');
expect(block).toContain('body?.sessionId');
});
test('4. /internal/restart is scoped to one sessionId, not dispose-all', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
const block = sliceBetween(src, "url.pathname === '/internal/restart'", "// /claude-available");
expect(block).toContain('sessionsById.get(sid)');
expect(block).toContain('disposeSession(session)');
expect(block).toContain('sessionsById.delete(sid)');
// Negative: must NOT enumerate all live sessions and dispose them
// (codex T2 caught this — pre-spec the route killed every PTY on the
// agent, breaking multi-sidebar / pair-agent setups).
expect(block).not.toMatch(/for\s*\(\s*const\s+\[?ws/);
});
test('5. WS upgrade surfaces sessionId on ws.data', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
expect(src).toContain('validTokens.get(token) ?? null');
expect(src).toMatch(/data:\s*\{\s*cookie:\s*token,\s*sessionId\s*\}/);
});
test('6. eager spawn via {type:"start"} text frame', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
expect(src).toMatch(/msg\?\.type === 'start'/);
// Both spawn paths route through the same helper for parity.
expect(src).toContain('function maybeSpawnPty(');
expect(src).toMatch(/maybeSpawnPty\(ws, session\)/);
});
test('7. close() drops sessionsById entry alongside ws cleanup', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
// Commit 3 widened the close signature to `close(ws, code, _reason)`
// for the detach state machine. Match either shape so test is stable
// across the rest of the long-lived-sidebar PR.
const i = src.indexOf('close(ws');
expect(i).toBeGreaterThan(-1);
const j = src.indexOf('function handleTabState', i);
const block = src.slice(i, j);
expect(block).toContain('sessionsById.delete(session.sessionId)');
});
test('8. PtySession interface carries the sessionId field', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
// Whole interface — close paren is sufficient.
const i = src.indexOf('interface PtySession {');
expect(i).toBeGreaterThan(-1);
const j = src.indexOf('\n}', i);
const block = src.slice(i, j);
expect(block).toContain('sessionId: string | null');
});
});
function sliceBetween(source: string, start: string, end: string): string {
const i = source.indexOf(start);
if (i === -1) throw new Error(`marker not found: ${start}`);
const j = source.indexOf(end, i + start.length);
if (j === -1) throw new Error(`end marker not found: ${end}`);
return source.slice(i, j);
}
@@ -1,91 +0,0 @@
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
// v1.44 terminal-agent watchdog — static-grep invariants.
//
// The watchdog respawns terminal-agent when its PID dies. Live process-tree
// tests would require spawning, killing, and observing across two real Bun
// processes — slow and flaky in the free tier. These tripwires defend the
// load-bearing properties: identity-based liveness check (not name match),
// crash-loop guard, gated on ownsTerminalAgent, and cleared on shutdown.
const SERVER_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'server.ts');
const CONTROL_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent-control.ts');
describe('terminal-agent watchdog (v1.44+)', () => {
test('1. spawnTerminalAgent helper exists with PID return type', () => {
const src = fs.readFileSync(CONTROL_TS, 'utf-8');
expect(src).toMatch(/export function spawnTerminalAgent\(/);
// Must clean up prior PID before spawning (no zombies).
expect(src).toContain('readAgentRecord(stateDir)');
expect(src).toContain('killAgentByRecord(prior');
expect(src).toContain('clearAgentRecord(stateDir)');
});
test('2. watchdog is gated on ownsTerminalAgent', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
// Match the comment + the guard. The guard MUST be a positive check;
// an inverted check would respawn for embedders and trample their PTY.
const block = sliceBetween(src, '─── Terminal-Agent Watchdog', 'Factory-scoped validateAuth');
expect(block).toMatch(/if \(ownsTerminalAgent\)/);
expect(block).toContain('agentWatchdogInterval = setInterval');
});
test('3. watchdog uses PID liveness, not process name probe', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
const block = sliceBetween(src, '─── Terminal-Agent Watchdog', 'Factory-scoped validateAuth');
// The whole point of the v1.44 watchdog over v1.43- pkill teardown:
// identity-based liveness. Slow-but-alive agents must NOT trigger
// respawn (split-brain defense).
expect(block).toContain('readAgentRecord(stateDir)');
expect(block).toContain('isProcessAlive(record.pid)');
// Negative: no executable name-based process lookup. Allow the strings
// to appear in prose comments (the watchdog doc explains what it
// replaces), reject only actual invocations.
expect(block).not.toMatch(/spawnSync\s*\(\s*['"]pkill/);
expect(block).not.toMatch(/Bun\.spawn\s*\(\s*\[\s*['"]pgrep/);
});
test('4. crash-loop guard with rolling window', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
const block = sliceBetween(src, '─── Terminal-Agent Watchdog', 'Factory-scoped validateAuth');
expect(block).toContain('RESPAWN_GUARD_WINDOW_MS = 60_000');
expect(block).toContain('RESPAWN_GUARD_MAX = 3');
expect(block).toContain('respawnHistory');
expect(block).toContain('agentRespawnGuardTripped');
// Window pruning: old entries must be evicted before counting toward
// the limit. Otherwise a daemon up for a week with one crash a day
// would eventually trip the guard.
expect(block).toMatch(/respawnHistory\.shift\(\)/);
});
test('5. watchdog interval is cleared on shutdown', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
expect(src).toContain('if (agentWatchdogInterval) clearInterval(agentWatchdogInterval)');
});
test('6. tick interval is env-overridable for tests', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
expect(src).toContain('GSTACK_AGENT_WATCHDOG_TICK_MS');
});
test('7. CLI cold-start path uses the same spawnTerminalAgent helper', () => {
const cli = fs.readFileSync(
path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'cli.ts'),
'utf-8',
);
// Otherwise the CLI and watchdog could drift on spawn env/cwd, and
// teardown invariants tested against one would silently miss the other.
expect(cli).toContain('spawnTerminalAgent({');
expect(cli).toContain("from './terminal-agent-control'");
});
});
function sliceBetween(source: string, start: string, end: string): string {
const i = source.indexOf(start);
if (i === -1) throw new Error(`marker not found: ${start}`);
const j = source.indexOf(end, i + start.length);
if (j === -1) throw new Error(`end marker not found: ${end}`);
return source.slice(i, j);
}
-258
View File
@@ -1,258 +0,0 @@
/**
* 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, buildPtyClearCookie,
PTY_COOKIE_NAME, __resetPtySessions,
} from '../src/pty-session-cookie';
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('clear-cookie has Max-Age=0', () => {
expect(buildPtyClearCookie()).toContain('Max-Age=0');
});
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('Source-level guard: /health does NOT surface ptyToken', () => {
test('/health response body does not include ptyToken', () => {
const healthIdx = SERVER_SRC.indexOf("url.pathname === '/health'");
expect(healthIdx).toBeGreaterThan(-1);
// Slice from /health through the response close-bracket.
const slice = SERVER_SRC.slice(healthIdx, healthIdx + 2000);
// The /health JSON.stringify body must not mention the cookie token.
// It's allowed to include `terminalPort` (a port number, not auth).
expect(slice).not.toContain('ptyToken');
expect(slice).not.toContain('gstack_pty');
expect(slice).toContain('terminalPort');
});
});
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
// Cookie gstack_pty (fallback). Both verify against validTokens.
expect(wsHandler).toContain('sec-websocket-protocol');
expect(wsHandler).toContain('gstack_pty');
expect(wsHandler).toContain('validTokens.has');
});
test('Sec-WebSocket-Protocol auth: strips gstack-pty. prefix and echoes back', () => {
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, AND echo the
// protocol back in the upgrade response — without the echo, the
// browser closes the connection immediately.
expect(wsHandler).toContain("'gstack-pty.'");
expect(wsHandler).toContain('Sec-WebSocket-Protocol');
expect(wsHandler).toContain('acceptedProtocol');
});
test('lazy spawn: upgrade/open never spawn and both message triggers share maybeSpawnPty', () => {
// The whole point of lazy-spawn (codex finding #8) is that neither the
// HTTP upgrade nor websocket open creates a PTY. Only message frames may
// enter maybeSpawnPty: an explicit start frame or the first binary byte.
const upgradeBlock = AGENT_SRC.slice(
AGENT_SRC.indexOf("if (url.pathname === '/ws')"),
AGENT_SRC.indexOf("websocket: {"),
);
expect(upgradeBlock).not.toContain('spawnClaude(');
expect(upgradeBlock).not.toContain('maybeSpawnPty(');
const openHandler = AGENT_SRC.slice(
AGENT_SRC.indexOf('open(ws) {'),
AGENT_SRC.indexOf('message(ws, raw)'),
);
expect(openHandler).toContain('spawned: false');
expect(openHandler).not.toContain('spawnClaude(');
expect(openHandler).not.toContain('maybeSpawnPty(');
// maybeSpawnPty is the sole production call site for spawnClaude. Keeping
// that ownership centralized ensures both triggers share idempotency and
// failure handling instead of acquiring subtly different spawn paths.
const spawnOwner = AGENT_SRC.slice(
AGENT_SRC.indexOf('function maybeSpawnPty'),
AGENT_SRC.indexOf('function buildServer'),
);
expect(spawnOwner).toContain('if (session.spawned) return true');
expect(spawnOwner).toContain('spawnClaude(session.cols, session.rows');
expect(AGENT_SRC.match(/\bspawnClaude\s*\(/g)).toHaveLength(2); // declaration + owner call
const messageHandler = AGENT_SRC.slice(
AGENT_SRC.indexOf('message(ws, raw)'),
AGENT_SRC.indexOf('close(ws, code'),
);
expect(messageHandler).not.toContain('spawnClaude(');
const startTrigger = messageHandler.slice(
messageHandler.indexOf("if (msg?.type === 'start')"),
messageHandler.indexOf('// Unknown text frame'),
);
expect(startTrigger).toContain('maybeSpawnPty(ws, session)');
const binaryTrigger = messageHandler.slice(
messageHandler.indexOf('// Binary input. Lazy-spawn'),
);
expect(binaryTrigger).toContain('if (!session.spawned)');
expect(binaryTrigger).toContain('if (!maybeSpawnPty(ws, session)) return');
expect(AGENT_SRC.match(/\bmaybeSpawnPty\s*\(/g)).toHaveLength(3); // declaration + two triggers
});
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'));
expect(dispose).toContain("'SIGINT'");
expect(dispose).toContain("'SIGKILL'");
expect(dispose).toContain('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 via tmp + rename for both files (so claude never reads
// a half-written JSON document).
expect(fn).toContain("'tabs.json'");
expect(fn).toContain("'active-tab.json'");
expect(fn).toContain('renameSync');
// 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('Source-level guard: server.ts /pty-session route', () => {
test('validates AUTH_TOKEN, grants over loopback, returns token + Set-Cookie', () => {
const route = SERVER_SRC.slice(SERVER_SRC.indexOf("url.pathname === '/pty-session'"));
// Must check auth before minting.
const beforeMint = route.slice(0, route.indexOf('mintPtySessionToken'));
expect(beforeMint).toContain('validateAuth');
// Must call the loopback grant before responding (otherwise the
// agent's validTokens Set never sees the token and /ws would 401).
expect(route).toContain('grantPtyToken');
// Must 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).
expect(route).toContain('ptySessionToken');
// Set-Cookie is kept as a fallback for non-browser callers.
expect(route).toContain('Set-Cookie');
expect(route).toContain('buildPtySetCookie');
});
});