Initial import from garrytan/gstack@026751e (main snapshot via local relay)
Some checks failed
Workflow Lint / actionlint (push) Has been cancelled
Build CI Image / build (push) Has been cancelled
Skill Docs Freshness / check-freshness (push) Has been cancelled
Periodic Evals / build-image (push) Has been cancelled
Periodic Evals / evals (map[file:test/codex-e2e.test.ts name:e2e-codex]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/gemini-e2e.test.ts name:e2e-gemini]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-design.test.ts name:e2e-design]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-plan.test.ts name:e2e-plan]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-qa-bugs.test.ts name:e2e-qa-bugs]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-qa-workflow.test.ts name:e2e-qa-workflow]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-review.test.ts name:e2e-review]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-workflow.test.ts name:e2e-workflow]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-routing-e2e.test.ts name:e2e-routing]) (push) Has been cancelled
Some checks failed
Workflow Lint / actionlint (push) Has been cancelled
Build CI Image / build (push) Has been cancelled
Skill Docs Freshness / check-freshness (push) Has been cancelled
Periodic Evals / build-image (push) Has been cancelled
Periodic Evals / evals (map[file:test/codex-e2e.test.ts name:e2e-codex]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/gemini-e2e.test.ts name:e2e-gemini]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-design.test.ts name:e2e-design]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-plan.test.ts name:e2e-plan]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-qa-bugs.test.ts name:e2e-qa-bugs]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-qa-workflow.test.ts name:e2e-qa-workflow]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-review.test.ts name:e2e-review]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-workflow.test.ts name:e2e-workflow]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-routing-e2e.test.ts name:e2e-routing]) (push) Has been cancelled
Source: https://github.com/garrytan/gstack/commit/026751e
This commit is contained in:
273
browse/test/terminal-agent-integration.test.ts
Normal file
273
browse/test/terminal-agent-integration.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 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 {}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user