Merge remote-tracking branch 'origin/main' into garrytan/gbrain-code-smell-audit

# Conflicts:
#	CHANGELOG.md
#	browse/test/dual-listener.test.ts
#	browse/test/fixtures/security-bench-haiku-responses.json
#	browse/test/sidebar-tabs.test.ts
#	browse/test/sidebar-ux.test.ts
#	browse/test/terminal-agent.test.ts
#	claude/SKILL.md.tmpl
#	scripts/gen-skill-docs.ts
#	scripts/proactive-suggestions.json
#	spec/SKILL.md
#	test/gen-skill-docs.test.ts
#	test/host-config.test.ts
This commit is contained in:
Garry Tan
2026-08-15 07:31:02 -07:00
246 changed files with 8802 additions and 1257 deletions
+7 -2
View File
@@ -42,9 +42,14 @@ beforeAll(async () => {
// The test needs to start a server. Let's use the existing server infrastructure.
});
afterAll(() => {
afterAll(async () => {
try { testServer.server.stop(); } catch {}
setTimeout(() => process.exit(0), 500);
// Close only this file's own browser — never process.exit(): bun test runs
// all files in one process, so a delayed exit kills the whole suite
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
// so race it at 3s and abandon; the child is reaped at process exit.
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
});
// We need a running browse server for HTTP tests.
+177 -4
View File
@@ -3,6 +3,9 @@ import * as path from 'path';
// Load the polyfill into a fresh object (don't clobber globalThis.Bun)
const polyfillPath = path.resolve(import.meta.dir, '../src/bun-polyfill.cjs');
// Forward slashes so the path survives interpolation into a JS string literal
// on Windows, which is the platform this polyfill exists for.
const requirePath = polyfillPath.replace(/\\/g, '/');
describe('bun-polyfill', () => {
// We test the polyfill by requiring it in a subprocess under Node.js
@@ -10,7 +13,7 @@ describe('bun-polyfill', () => {
test('Bun.sleep resolves after delay', async () => {
const result = Bun.spawnSync(['node', '-e', `
require('${polyfillPath}');
require('${requirePath}');
(async () => {
const start = Date.now();
await Bun.sleep(50);
@@ -24,7 +27,7 @@ describe('bun-polyfill', () => {
test('Bun.spawnSync runs a command and returns stdout', () => {
const result = Bun.spawnSync(['node', '-e', `
require('${polyfillPath}');
require('${requirePath}');
const r = Bun.spawnSync(['echo', 'hello'], { stdout: 'pipe' });
console.log(r.stdout.toString().trim());
console.log('exit:' + r.exitCode);
@@ -36,7 +39,7 @@ describe('bun-polyfill', () => {
test('Bun.spawn launches a process with pid', async () => {
const result = Bun.spawnSync(['node', '-e', `
require('${polyfillPath}');
require('${requirePath}');
const p = Bun.spawn(['echo', 'test'], { stdio: ['pipe', 'pipe', 'pipe'] });
console.log(typeof p.pid === 'number' ? 'HAS_PID' : 'NO_PID');
console.log(typeof p.kill === 'function' ? 'HAS_KILL' : 'NO_KILL');
@@ -48,9 +51,179 @@ describe('bun-polyfill', () => {
expect(lines[2]).toBe('HAS_UNREF');
});
// Bun.spawn parity: `proc.exited` is a Promise resolving to the exit code.
// The DPAPI helper and isBrowserRunning both `await proc.exited`; without
// it the awaits resolve immediately to `undefined` and the caller reads
// stdout before the child has produced it — surfacing as a silent failure.
test('Bun.spawn exposes proc.exited that resolves to the exit code', async () => {
const result = Bun.spawnSync(['node', '-e', `
require('${requirePath}');
(async () => {
const p = Bun.spawn(['node', '-e', 'process.exit(0)'], { stdio: ['ignore', 'ignore', 'ignore'] });
console.log(typeof p.exited === 'object' && typeof p.exited.then === 'function' ? 'IS_PROMISE' : 'NOT_PROMISE');
console.log('exit:' + await p.exited);
})();
`], { stdout: 'pipe', stderr: 'pipe' });
const lines = result.stdout.toString().trim().split('\n');
expect(lines[0]).toBe('IS_PROMISE');
expect(lines[1]).toBe('exit:0');
});
test('Bun.spawn proc.exited reflects non-zero exit codes', async () => {
const result = Bun.spawnSync(['node', '-e', `
require('${requirePath}');
(async () => {
const p = Bun.spawn(['node', '-e', 'process.exit(3)'], { stdio: ['ignore', 'ignore', 'ignore'] });
console.log('exit:' + await p.exited);
})();
`], { stdout: 'pipe', stderr: 'pipe' });
expect(result.stdout.toString().trim()).toBe('exit:3');
});
test('Bun.spawn proc.exited resolves before reading stdout (no race)', async () => {
const result = Bun.spawnSync(['node', '-e', `
require('${requirePath}');
(async () => {
// Real-world pattern: write to stdout, then exit. Awaiting proc.exited
// before reading must guarantee the bytes are flushed.
const p = Bun.spawn(['node', '-e', 'process.stdout.write("ready"); process.exit(0)'], {
stdio: ['ignore', 'pipe', 'ignore']
});
const code = await p.exited;
const out = await new Response(p.stdout).text();
console.log(out + ':' + code);
})();
`], { stdout: 'pipe', stderr: 'pipe' });
expect(result.stdout.toString().trim()).toBe('ready:0');
});
// Spawn-failure case: Node emits 'error' but not 'exit' when the binary
// is missing, so listening only for 'exit' hangs `await proc.exited`
// forever. The lifecycle promise must resolve on either event.
test('Bun.spawn proc.exited resolves on spawn failure (missing binary)', async () => {
const result = Bun.spawnSync(['node', '-e', `
require('${requirePath}');
(async () => {
const p = Bun.spawn(['this-binary-does-not-exist-zzz-' + Date.now()], {
stdio: ['ignore', 'pipe', 'pipe']
});
const code = await Promise.race([
p.exited,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 3000))
]).catch(() => 'TIMEOUT');
console.log('exit:' + code);
})();
`], { stdout: 'pipe', stderr: 'pipe' });
// Anything other than 'TIMEOUT' (and ideally a non-zero number) means the
// lifecycle promise resolved on the spawn error.
const out = result.stdout.toString().trim();
expect(out).not.toBe('exit:TIMEOUT');
expect(out).toMatch(/^exit:\d+$/);
});
// GSTACK_SPAWN_MAX_BUFFER caps the drain so a runaway child can't OOM the
// server. Past the cap, the pipe keeps flowing (child doesn't block) but
// further bytes are dropped. Set a small cap, write more than that, assert
// the captured stdout equals the cap and the child exits cleanly.
test('Bun.spawn caps buffered output at GSTACK_SPAWN_MAX_BUFFER', async () => {
const result = Bun.spawnSync(['node', '-e', `
process.env.GSTACK_SPAWN_MAX_BUFFER = '${1024}';
require('${requirePath}');
(async () => {
// Child writes 10 KB; cap is 1 KB; drained output should be exactly 1 KB
// and exit should still resolve cleanly (child not back-pressured to death).
const p = Bun.spawn(
['node', '-e', 'process.stdout.write("y".repeat(10 * 1024)); process.exit(0)'],
{ stdio: ['ignore', 'pipe', 'ignore'] }
);
const code = await Promise.race([
p.exited,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 3000))
]).catch(() => 'TIMEOUT');
const out = await new Response(p.stdout).text();
console.log(out.length + ':' + code);
})();
`], { stdout: 'pipe', stderr: 'pipe' });
expect(result.stdout.toString().trim()).toBe('1024:0');
});
// Regression for the pipe-blocking case: if the child writes more than the
// OS pipe buffer (~16-64 KB) and the polyfill doesn't drain eagerly, the
// child blocks in write() and `exit` never fires. 1 MB is well past every
// OS pipe buffer size. Pre-fix this test hangs forever; post-fix it returns
// in <500ms. Bun's default per-test timeout is 5s — generous here.
test('Bun.spawn drains large stdout so proc.exited still resolves', async () => {
const result = Bun.spawnSync(['node', '-e', `
require('${requirePath}');
(async () => {
const ONE_MB = 1024 * 1024;
// Exit in the write callback, not straight after write(): on modern
// Node a pipe write past the OS buffer is async, and process.exit()
// right after write() truncates at ~64 KB even with a live reader.
// The callback only fires once the full MB is flushed — which still
// requires the parent to drain, so the regression (no eager drain →
// child blocks → timeout) is still caught.
const p = Bun.spawn(
['node', '-e', 'process.stdout.write("x".repeat(' + ONE_MB + '), () => process.exit(0))'],
{ stdio: ['ignore', 'pipe', 'ignore'] }
);
const code = await Promise.race([
p.exited,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 10000))
]).catch(e => 'TIMEOUT');
const out = await new Response(p.stdout).text();
console.log(out.length + ':' + code);
})().catch((e) => { console.log('THREW:' + e.message); });
`], { stdout: 'pipe', stderr: 'pipe' });
expect(result.stdout.toString().trim()).toBe('1048576:0');
}, 15000);
// windowsHide is the one option where Node's default is the opposite of
// Bun's: Node shows the child's console window, Bun hides it. Dropping it
// in translation makes every spawned child pop a window on Windows, which
// is the platform this whole file exists for. Both shims are covered.
test('Bun.spawn defaults windowsHide to true', () => {
const result = Bun.spawnSync(['node', '-e', `
const cp = require('child_process');
const orig = cp.spawn;
let seen;
cp.spawn = (c, a, o) => { seen = o; return orig(c, a, o); };
require('${requirePath}');
Bun.spawn(['node', '-e', ''], { stdio: ['ignore', 'ignore', 'ignore'] });
console.log('windowsHide:' + seen.windowsHide);
`], { stdout: 'pipe', stderr: 'pipe' });
expect(result.stdout.toString().trim()).toBe('windowsHide:true');
});
test('Bun.spawnSync defaults windowsHide to true', () => {
const result = Bun.spawnSync(['node', '-e', `
const cp = require('child_process');
const orig = cp.spawnSync;
let seen;
cp.spawnSync = (c, a, o) => { seen = o; return orig(c, a, o); };
require('${requirePath}');
Bun.spawnSync(['node', '-e', '']);
console.log('windowsHide:' + seen.windowsHide);
`], { stdout: 'pipe', stderr: 'pipe' });
expect(result.stdout.toString().trim()).toBe('windowsHide:true');
});
test('an explicit windowsHide:false is honored', () => {
const result = Bun.spawnSync(['node', '-e', `
const cp = require('child_process');
const orig = cp.spawn;
let seen;
cp.spawn = (c, a, o) => { seen = o; return orig(c, a, o); };
require('${requirePath}');
Bun.spawn(['node', '-e', ''], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false });
console.log('windowsHide:' + seen.windowsHide);
`], { stdout: 'pipe', stderr: 'pipe' });
expect(result.stdout.toString().trim()).toBe('windowsHide:false');
});
test('Bun.serve creates an HTTP server that responds', async () => {
const result = Bun.spawnSync(['node', '-e', `
require('${polyfillPath}');
require('${requirePath}');
const server = Bun.serve({
port: 0, // Note: polyfill uses port directly, so we pick one
hostname: '127.0.0.1',
+1 -1
View File
@@ -18,7 +18,7 @@ import { withCdpSession, getOrCreateCdpSession } from '../src/cdp-bridge';
// 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');
const SRC_DIR = path.resolve(import.meta.path, '..', '..', 'src');
function readAllSourceFiles(): Array<{ file: string; content: string }> {
const out: Array<{ file: string; content: string }> = [];
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { acquireServerLock } from '../src/cli';
function withTempDir<T>(fn: (dir: string) => T): T {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-lock-'));
try {
return fn(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
function captureErrors<T>(fn: () => T): { result: T; messages: string[] } {
const original = console.error;
const messages: string[] = [];
console.error = (...args: unknown[]) => {
messages.push(args.map(String).join(' '));
};
try {
return { result: fn(), messages };
} finally {
console.error = original;
}
}
describe('browse CLI server lock diagnostics (#1084)', () => {
test('logs non-EEXIST open failures instead of reporting phantom lock contention', () => {
withTempDir((dir) => {
const lockPath = path.join(dir, 'missing-parent', 'browse.json.lock');
const { result, messages } = captureErrors(() => acquireServerLock(lockPath));
expect(result).toBeNull();
expect(messages.join('\n')).toContain('unexpected ENOENT while opening');
expect(messages.join('\n')).toContain(lockPath);
});
});
test('returns null silently when a live process holds the lock', () => {
withTempDir((dir) => {
const lockPath = path.join(dir, 'browse.json.lock');
fs.writeFileSync(lockPath, `${process.pid}\n`);
const { result, messages } = captureErrors(() => acquireServerLock(lockPath));
expect(result).toBeNull();
expect(messages).toEqual([]);
});
});
test('logs holder PID read failures with code and lock path', () => {
withTempDir((dir) => {
const lockPath = path.join(dir, 'browse.json.lock');
fs.mkdirSync(lockPath);
const { result, messages } = captureErrors(() => acquireServerLock(lockPath));
expect(result).toBeNull();
expect(messages.join('\n')).toContain('unexpected EISDIR while reading holder PID from');
expect(messages.join('\n')).toContain(lockPath);
});
});
test('removes stale lock and reacquires it', () => {
withTempDir((dir) => {
const lockPath = path.join(dir, 'browse.json.lock');
fs.writeFileSync(lockPath, 'not-a-pid\n');
const release = acquireServerLock(lockPath);
expect(release).toBeFunction();
expect(fs.readFileSync(lockPath, 'utf-8').trim()).toBe(String(process.pid));
release?.();
expect(fs.existsSync(lockPath)).toBe(false);
});
});
});
@@ -0,0 +1,77 @@
/**
* Coverage for #1846 — `browse` CLI must not report "Server failed to start
* within Ns" when the detached daemon actually came up healthy a moment later.
*
* The spawned server is `detached: true` + `.unref()`'d, so it keeps booting
* independently of the CLI's poll loop. On a loaded machine (the issue repro is
* Windows under load) the loop's budget can elapse in the gap between its last
* health tick and the daemon becoming ready — the very next `browse status`
* then shows a healthy, listening server. #1732 only widened the budget; the
* throw site itself still fired on timeout regardless of real health.
*
* Two invariants are defended here:
* 1. `startServer` does a final readState()+isServerHealthy() re-check before
* the timeout throw (structural — removes the false negative at any budget).
* 2. The startup budget is env-overridable via BROWSE_START_TIMEOUT, matching
* the BROWSE_* tunable convention (BROWSE_PORT, BROWSE_IDLE_TIMEOUT, ...).
*
* (1) is a static source invariant (live spawn cycles belong in the e2e tier);
* (2) is exercised behaviorally against the exported pure helper.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { resolveStartTimeout } from '../src/cli';
const CLI = path.join(import.meta.dir, '..', 'src', 'cli.ts');
const read = (): string => fs.readFileSync(CLI, 'utf-8');
describe('#1846 startServer false-negative on a late-healthy detached daemon', () => {
test('a final health re-check sits between the poll loop and the timeout throw', () => {
const src = read();
const throwIdx = src.indexOf('Server failed to start within');
expect(throwIdx).toBeGreaterThan(-1);
// The startServer poll loop ends at its `await Bun.sleep(100)`; the final
// re-check must live AFTER that loop and BEFORE the timeout throw.
const loopEnd = src.lastIndexOf('await Bun.sleep(100)', throwIdx);
expect(loopEnd).toBeGreaterThan(-1);
const between = src.slice(loopEnd, throwIdx);
// It must re-read state and re-probe health, then be able to return — i.e.
// a genuine recovery path, not just a comment.
expect(between).toContain('readState()');
expect(between).toMatch(/isServerHealthy\([^)]*\)/);
expect(between).toMatch(/return\s+\w+;/);
});
test('the re-check returns the recovered state rather than swallowing it', () => {
const src = read();
// Guard against a refactor that probes health but forgets to return the
// state (which would re-introduce the false negative).
expect(src).toMatch(/if\s*\([^)]*await\s+isServerHealthy\([^)]*\)\)\s*\{\s*return\s+\w+;/);
});
});
describe('#1846 BROWSE_START_TIMEOUT env override (resolveStartTimeout)', () => {
const platformDefault = resolveStartTimeout({} as NodeJS.ProcessEnv);
test('platform default is a positive millisecond budget when unset', () => {
expect(platformDefault).toBeGreaterThan(0);
});
test('honors a positive BROWSE_START_TIMEOUT override', () => {
expect(resolveStartTimeout({ BROWSE_START_TIMEOUT: '42000' } as NodeJS.ProcessEnv)).toBe(42000);
});
test('falls back to the platform default for non-positive / unparseable values', () => {
for (const bad of ['0', '-5', 'abc', '', ' ']) {
expect(resolveStartTimeout({ BROWSE_START_TIMEOUT: bad } as NodeJS.ProcessEnv)).toBe(platformDefault);
}
});
test('MAX_START_WAIT is wired through resolveStartTimeout (no stray hardcoded constant)', () => {
const src = read();
expect(src).toMatch(/const\s+MAX_START_WAIT\s*=\s*resolveStartTimeout\(\)/);
});
});
+1 -1
View File
@@ -15,7 +15,7 @@ import * as path from 'path';
// 3-8s each). These tripwires defend the load-bearing invariants:
// opt-in by default, signal handlers wired, crash-loop guard, env knobs.
const CLI_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'cli.ts');
const CLI_TS = path.resolve(import.meta.path, '..', '..', 'src', 'cli.ts');
describe('CLI outer supervisor (v1.44+)', () => {
test('1. supervisor is opt-in via --supervise flag or BROWSE_SUPERVISE env', () => {
+24 -5
View File
@@ -126,11 +126,14 @@ beforeAll(async () => {
await bm.launch();
});
afterAll(() => {
// Force kill browser instead of graceful close (avoids hang)
afterAll(async () => {
try { testServer.server.stop(); } catch {}
// bm.close() can hang — just let process exit handle it
setTimeout(() => process.exit(0), 500);
// Close only this file's own browser — never process.exit(): bun test runs
// all files in one process, so a delayed exit kills the whole suite
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
// so race it at 3s and abandon; the child is reaped at process exit.
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
});
// ─── Navigation ─────────────────────────────────────────────────
@@ -913,7 +916,10 @@ describe('CLI lifecycle', () => {
cliEnv.BROWSE_STATE_FILE = stateFile;
const result = await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
const proc = spawn('bun', ['run', cliPath, 'status'], {
timeout: 15000,
// Must exceed the CLI's startup budget (resolveStartTimeout, 15s
// non-CI POSIX) or a slow cold boot under full-suite load gets the
// child killed at the exact moment the CLI would have succeeded.
timeout: 18000,
env: cliEnv,
});
let stdout = '';
@@ -2315,6 +2321,19 @@ describe('load-html', () => {
}
});
test('load-html rejects .svg files', async () => {
const svgPath = path.join(tmpDir, `load-html-test-${Date.now()}.svg`);
fs.writeFileSync(svgPath, '<svg xmlns="http://www.w3.org/2000/svg"><text>hi</text></svg>');
try {
await handleWriteCommand('load-html', [svgPath], bm);
expect(true).toBe(false);
} catch (err: any) {
expect(err.message).toMatch(/does not appear to be HTML/);
} finally {
try { fs.unlinkSync(svgPath); } catch {}
}
});
test('load-html rejects file outside safe dirs', async () => {
try {
await handleWriteCommand('load-html', ['/etc/passwd.html'], bm);
+7 -2
View File
@@ -69,10 +69,15 @@ beforeAll(async () => {
await handleWriteCommand('goto', [boardUrl], bm);
});
afterAll(() => {
afterAll(async () => {
try { server.stop(); } catch {}
fs.rmSync(tmpDir, { recursive: true, force: true });
setTimeout(() => process.exit(0), 500);
// Close only this file's own browser — never process.exit(): bun test runs
// all files in one process, so a delayed exit kills the whole suite
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
// so race it at 3s and abandon; the child is reaped at process exit.
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
});
// ─── DOM Structure ──────────────────────────────────────────────
+35
View File
@@ -124,6 +124,41 @@ describe('config', () => {
expect(fs.existsSync(path.join(tmpDir, '.gitignore'))).toBe(false);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('leaves .gitignore alone when git already ignores .gstack/ globally', () => {
const { spawnSync } = require('child_process');
const tmpDir = path.join(os.tmpdir(), `browse-gitignore-global-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
// Set up a real git repo
spawnSync('git', ['init', '-q'], { cwd: tmpDir });
spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir });
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir });
// Write a global excludes file that ignores .gstack/
const excludesFile = path.join(tmpDir, 'global-gitignore');
fs.writeFileSync(excludesFile, '.gstack/\n');
spawnSync('git', ['config', 'core.excludesFile', excludesFile], { cwd: tmpDir });
// .gitignore exists but does NOT contain .gstack/
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n');
spawnSync('git', ['add', '.gitignore'], { cwd: tmpDir });
spawnSync('git', ['commit', '-qm', 'init'], { cwd: tmpDir });
// Verify git knows .gstack/ is ignored
const check = spawnSync('git', ['check-ignore', '-q', '.gstack/'], { cwd: tmpDir });
expect(check.status).toBe(0);
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config);
// .gitignore must NOT have been modified
const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
expect(content).toBe('node_modules/\n');
expect(fs.existsSync(path.join(tmpDir, '.gstack'))).toBe(true);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
});
describe('getRemoteSlug', () => {
+7 -2
View File
@@ -470,9 +470,14 @@ describe('Hidden element stripping', () => {
await bm.launch();
});
afterAll(() => {
afterAll(async () => {
try { testServer.server.stop(); } catch {}
setTimeout(() => process.exit(0), 500);
// Close only this file's own browser — never process.exit(): bun test
// runs all files in one process, so a delayed exit kills the whole suite
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
// so race it at 3s and abandon; the child is reaped at process exit.
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
});
test('detects CSS-hidden elements on injection-hidden page', async () => {
+2 -3
View File
@@ -224,9 +224,8 @@ describe('/command tunnel command allowlist', () => {
'return handleCommand(body, tokenInfo)'
);
expect(commandBlock).toContain("surface === 'tunnel'");
// v1.63.0.0 made the allowlist args-aware (canDispatchOverTunnel gained a
// second param for --out denial); this pin was stale from then until the
// free suite got a CI job.
// Args-aware since the --out (disk write) tunnel ban: the dispatch gate
// takes both the command and its args.
expect(commandBlock).toContain('canDispatchOverTunnel(body?.command, body?.args)');
expect(commandBlock).toContain('disallowed_command');
expect(commandBlock).toContain('is not allowed over the tunnel surface');
+271
View File
@@ -0,0 +1,271 @@
/**
* Sender authorization for privileged extension messages.
*
* A content script runs in web-page context and can be influenced by page
* content; a foreign extension is not us. Neither may read or spend the
* browse server's auth token or port through background.js's message
* surface. PR #1822 (@punksterlabs) found getPort handing the token to any
* caller that passed the type allowlist; this suite pins the reimplemented
* gate BEHAVIORALLY — it drives the real background.js onMessage listener
* under a chrome stub with four sender shapes (own extension page, own
* content script, foreign extension, url-less) and asserts denied responses
* are { error: 'unauthorized' } with no token/port fields at all.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
const EXT_DIR = path.join(import.meta.dir, '..', '..', 'extension');
const BG_SRC = fs.readFileSync(path.join(EXT_DIR, 'background.js'), 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const senderAuth = require(path.join(EXT_DIR, 'sender-auth.js'));
// The pinned production id (derivable via browse/scripts/extension-id.ts) —
// the policy only compares it against sender.id, so any stable value works.
const OWN_ID = 'dgbkdbjebeiblbajiilljmhjdpmiglep';
const FOREIGN_ID = 'ffffffffffffffffffffffffffffffff';
// ─── The four sender shapes ─────────────────────────────────────
const PAGE_SENDER = { id: OWN_ID, url: `chrome-extension://${OWN_ID}/sidepanel.html` };
const CONTENT_SCRIPT_SENDER = { id: OWN_ID, url: 'https://evil.example/page', tab: { id: 42 } };
const FOREIGN_SENDER = { id: FOREIGN_ID, url: `chrome-extension://${FOREIGN_ID}/background.html` };
const NO_URL_SENDER = { id: OWN_ID };
const PRIVILEGED = [
'getPort', 'setPort', 'getServerUrl', 'getToken', 'fetchRefs',
'command', 'sidebar-command', 'getTabState',
];
// Content-script-originated flows that must keep working.
const CONTENT_SCRIPT_TYPES = ['openSidePanel', 'elementPicked', 'pickerCancelled', 'inspectResult'];
// Sidepanel-originated, non-privileged (page effects only, no token/port).
const PAGE_EFFECT_TYPES = ['sidebarOpened', 'startInspector', 'stopInspector', 'applyStyle', 'toggleClass', 'injectCSS', 'resetAll'];
const LEAK_FIELDS = ['token', 'authToken', 'port', 'url', 'connected', 'tabs', 'active', 'ok'];
// ─── Unit: the policy predicate ─────────────────────────────────
describe('sender-auth policy (unit)', () => {
test('own extension page is allowed for every privileged type', () => {
for (const type of PRIVILEGED) {
expect(senderAuth.denialFor(type, PAGE_SENDER, OWN_ID)).toBeNull();
}
expect(senderAuth.isExtensionPageSender(PAGE_SENDER, OWN_ID)).toBe(true);
});
test('own popup page is allowed (any own-extension page path)', () => {
const popup = { id: OWN_ID, url: `chrome-extension://${OWN_ID}/popup.html` };
expect(senderAuth.denialFor('getPort', popup, OWN_ID)).toBeNull();
});
test('own content script (sender.tab + page URL) is denied for every privileged type', () => {
for (const type of PRIVILEGED) {
const denial = senderAuth.denialFor(type, CONTENT_SCRIPT_SENDER, OWN_ID);
expect(denial).toEqual({ error: 'unauthorized' });
expect(Object.keys(denial)).toEqual(['error']);
}
});
test('foreign extension id is denied for every privileged type', () => {
for (const type of PRIVILEGED) {
expect(senderAuth.denialFor(type, FOREIGN_SENDER, OWN_ID)).toEqual({ error: 'unauthorized' });
}
});
test('missing sender.url is denied (no provenance)', () => {
for (const type of PRIVILEGED) {
expect(senderAuth.denialFor(type, NO_URL_SENDER, OWN_ID)).toEqual({ error: 'unauthorized' });
}
expect(senderAuth.denialFor('getToken', undefined, OWN_ID)).toEqual({ error: 'unauthorized' });
});
test('own extension page opened inside a TAB is denied (conservative: sender.tab wins)', () => {
const pageInTab = { id: OWN_ID, url: `chrome-extension://${OWN_ID}/sidepanel.html`, tab: { id: 7 } };
expect(senderAuth.denialFor('getToken', pageInTab, OWN_ID)).toEqual({ error: 'unauthorized' });
});
test('non-privileged types are never gated here — content-script flows stay reachable', () => {
for (const type of [...CONTENT_SCRIPT_TYPES, ...PAGE_EFFECT_TYPES]) {
expect(senderAuth.denialFor(type, CONTENT_SCRIPT_SENDER, OWN_ID)).toBeNull();
expect(senderAuth.denialFor(type, PAGE_SENDER, OWN_ID)).toBeNull();
}
});
});
// ─── Behavioral: the real background.js listener ────────────────
type Listener = (msg: unknown, sender: unknown, sendResponse: (r: unknown) => void) => unknown;
function loadBackground() {
const captured: { listener?: Listener } = {};
const calls = { storageSet: [] as unknown[], fetch: [] as unknown[] };
const never = new Promise(() => {}); // storage.get never settles → startup health polling never starts
const chromeStub = {
runtime: {
id: OWN_ID,
onMessage: { addListener: (fn: Listener) => { captured.listener = fn; } },
onInstalled: { addListener: () => {} },
sendMessage: () => Promise.resolve(),
},
storage: {
local: {
get: () => never,
set: (obj: unknown) => { calls.storageSet.push(obj); return Promise.resolve(); },
},
},
tabs: {
onActivated: { addListener: () => {} },
onCreated: { addListener: () => {} },
onRemoved: { addListener: () => {} },
onUpdated: { addListener: () => {} },
query: (_opts: unknown, cb?: (tabs: unknown[]) => void) => {
if (cb) { cb([]); return; }
return Promise.resolve([]);
},
sendMessage: () => Promise.resolve(),
get: () => {},
},
action: { setBadgeBackgroundColor: () => {}, setBadgeText: () => {} },
scripting: { executeScript: () => Promise.resolve(), insertCSS: () => Promise.resolve() },
// no chrome.sidePanel: autoOpenSidePanel exits immediately (no retry timers)
};
const fetchSpy = (...args: unknown[]) => {
calls.fetch.push(args);
return Promise.reject(new Error('no network in tests'));
};
// background.js is a classic (non-module) service worker script — evaluate
// it with its globals injected. importScripts is satisfied by passing the
// already-required sender-auth module under the global name it registers.
const run = new Function('chrome', 'importScripts', 'gstackSenderAuth', 'fetch', BG_SRC);
run(chromeStub, () => {}, senderAuth, fetchSpy);
if (!captured.listener) throw new Error('background.js did not register an onMessage listener');
return { listener: captured.listener, calls };
}
function dispatch(listener: Listener, msg: unknown, sender: unknown) {
const result = { responded: false, response: undefined as Record<string, unknown> | undefined };
listener(msg, sender, (resp: unknown) => {
result.responded = true;
result.response = resp as Record<string, unknown>;
});
return result;
}
// Denied senders get { error: 'unauthorized' } and nothing else — or no
// response at all (the pre-existing foreign-sender early return). Either
// way: never a token, port, or tab-state field.
function expectDenied(result: ReturnType<typeof dispatch>) {
if (result.responded) {
expect(result.response).toEqual({ error: 'unauthorized' });
expect(Object.keys(result.response!)).toEqual(['error']);
}
const resp = result.response ?? {};
for (const leak of LEAK_FIELDS) {
expect(resp[leak]).toBeUndefined();
}
}
describe('background.js onMessage listener (behavioral)', () => {
const { listener, calls } = loadBackground();
test('own sidepanel page: getPort responds with port/connected/token fields, no error', () => {
const r = dispatch(listener, { type: 'getPort' }, PAGE_SENDER);
expect(r.responded).toBe(true);
expect('port' in r.response!).toBe(true);
expect('connected' in r.response!).toBe(true);
// The sidepanel's tryConnect reads resp.token — the field must exist for
// extension pages (value is null until the token bootstrap completes).
expect('token' in r.response!).toBe(true);
expect(r.response!.error).toBeUndefined();
});
test('own sidepanel page: getToken responds with a token field', () => {
const r = dispatch(listener, { type: 'getToken' }, PAGE_SENDER);
expect(r.responded).toBe(true);
expect('token' in r.response!).toBe(true);
expect(r.response!.error).toBeUndefined();
});
test('own content script: every privileged type is denied with no token/port fields', () => {
for (const type of PRIVILEGED) {
const r = dispatch(listener, { type }, CONTENT_SCRIPT_SENDER);
expect(r.responded).toBe(true); // the gate answers, it does not go silent
expectDenied(r);
}
});
test('foreign extension: every privileged type yields no token/port fields', () => {
for (const type of PRIVILEGED) {
expectDenied(dispatch(listener, { type }, FOREIGN_SENDER));
}
});
test('missing sender.url: every privileged type is denied', () => {
for (const type of PRIVILEGED) {
const r = dispatch(listener, { type }, NO_URL_SENDER);
expect(r.responded).toBe(true);
expectDenied(r);
}
});
test('denied setPort never persists the attacker port', () => {
const before = calls.storageSet.length;
const r = dispatch(listener, { type: 'setPort', port: 6666 }, CONTENT_SCRIPT_SENDER);
expectDenied(r);
expect(calls.storageSet.length).toBe(before);
});
test('denied command never reaches the network and fails at the gate, not the handler', () => {
const before = calls.fetch.length;
const r = dispatch(listener, { type: 'command', command: 'goto', args: ['https://evil.example'] }, CONTENT_SCRIPT_SENDER);
// 'unauthorized' proves the gate fired; the handler's own failure mode is
// 'Not connected to browse server'.
expect(r.response).toEqual({ error: 'unauthorized' });
expect(calls.fetch.length).toBe(before);
});
test('content script can still run the inspector flow (elementPicked → ok)', async () => {
const r = dispatch(
listener,
{ type: 'elementPicked', selector: '#hero', tagName: 'div', classes: [], id: null, dimensions: { width: 1, height: 1 } },
CONTENT_SCRIPT_SENDER,
);
await new Promise((res) => setTimeout(res, 10));
expect(r.response).toEqual({ ok: true });
});
test('content script can still request openSidePanel (not rejected as unauthorized)', () => {
const r = dispatch(listener, { type: 'openSidePanel' }, CONTENT_SCRIPT_SENDER);
// chrome.sidePanel is absent in the stub so the handler is a no-op — the
// load-bearing assertion is that the gate did not deny it.
expect(r.response?.error).toBeUndefined();
});
test('sidepanel getTabState still works (terminal pane tab sync)', async () => {
const r = dispatch(listener, { type: 'getTabState' }, PAGE_SENDER);
await new Promise((res) => setTimeout(res, 10));
expect(r.responded).toBe(true);
expect(r.response).toEqual({ active: null, tabs: [] });
});
});
// ─── Wiring tripwire ────────────────────────────────────────────
// The behavioral suite injects senderAuth directly, so pin that the real
// worker actually loads it: importScripts of the helper file plus a
// denialFor call in the listener. A refactor that drops either fails here.
describe('background.js ↔ sender-auth.js wiring', () => {
test('background.js importScripts sender-auth.js (classic worker load path)', () => {
expect(BG_SRC).toContain("importScripts('sender-auth.js')");
});
test('background.js consults gstackSenderAuth.denialFor in the message listener', () => {
expect(BG_SRC).toContain('gstackSenderAuth.denialFor(msg.type, sender, chrome.runtime.id)');
});
test('manifest keeps a classic (non-module) service worker — importScripts requires it', () => {
const manifest = JSON.parse(fs.readFileSync(path.join(EXT_DIR, 'manifest.json'), 'utf-8'));
expect(manifest.background.service_worker).toBe('background.js');
expect(manifest.background.type).toBeUndefined();
});
});
+30
View File
@@ -77,6 +77,26 @@ describe('restrictDirectoryPermissions', () => {
fs.mkdirSync(d);
expect(() => restrictDirectoryPermissions(d)).not.toThrow();
});
test('on Windows, the directory stays usable by the calling process', () => {
if (process.platform !== 'win32') return;
const d = path.join(tmpDir, 'still-usable');
fs.mkdirSync(d);
fs.writeFileSync(path.join(d, 'before'), 'x');
restrictDirectoryPermissions(d);
// Regression: an unqualified username passed to icacls can resolve to
// the machine SID rather than the user account. Combined with
// /inheritance:r that leaves a directory whose only ACE matches nobody,
// so the process that just "secured" it can no longer enumerate or
// write to it. icacls still reports success, so a not-toThrow assertion
// sails straight past it — hence these access checks.
expect(() => fs.readdirSync(d)).not.toThrow();
expect(fs.readdirSync(d)).toContain('before');
expect(() => fs.writeFileSync(path.join(d, 'after'), 'y')).not.toThrow();
expect(fs.readFileSync(path.join(d, 'after'), 'utf8')).toBe('y');
});
});
describe('writeSecureFile', () => {
@@ -138,6 +158,16 @@ describe('mkdirSecure', () => {
expect(() => mkdirSecure(d)).not.toThrow();
});
test('on Windows, the created directory stays usable by the caller', () => {
if (process.platform !== 'win32') return;
// The state-dir path that broke: mkdirSecure() creates .gstack/, hardens
// it, and the very next thing the daemon does is write a lockfile inside.
const d = path.join(tmpDir, 'state', '.gstack');
mkdirSecure(d);
expect(() => fs.writeFileSync(path.join(d, 'browse.json.lock'), '1')).not.toThrow();
expect(fs.readdirSync(d)).toContain('browse.json.lock');
});
test('recursive behavior: creates intermediate directories', () => {
const d = path.join(tmpDir, 'a', 'b', 'c');
mkdirSecure(d);
+57
View File
@@ -0,0 +1,57 @@
/**
* Regression test for `browse fill` on change-only validators.
*
* Playwright's Locator.fill() dispatches an `input` event but not `change`.
* Frameworks that validate on `change` (AngularJS ng-change, debounced
* strength/match checks — e.g. cPanel's Jupiter theme "Add FTP Account"
* password-match check) never see the update: the DOM value is correct but
* the framework's own validator still reports a mismatch.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { startTestServer } from './test-server';
import { BrowserManager } from '../src/browser-manager';
import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands';
const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) =>
_handleWriteCommand(cmd, args, b.getActiveSession(), b);
let testServer: ReturnType<typeof startTestServer>;
let bm: BrowserManager;
let baseUrl: string;
beforeAll(async () => {
testServer = startTestServer(0);
baseUrl = testServer.url;
bm = new BrowserManager();
await bm.launch();
});
afterAll(async () => {
try { testServer.server.stop(); } catch {}
// Close only this file's own browser — never process.exit(): bun test runs
// all files in one process, so a delayed exit kills the whole suite
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
// already died, so race it at 3s and abandon; the child is reaped at exit.
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
});
describe('fill dispatches change event', () => {
test('a change-only validator sees the filled value', async () => {
await handleWriteCommand('goto', [baseUrl + '/change-only-validator.html'], bm);
await handleWriteCommand('fill', ['#password', 'hello123'], bm);
await handleWriteCommand('fill', ['#password2', 'hello123'], bm);
const status = await bm.getPage().locator('#match-status').textContent();
expect(status).toBe('match');
});
test('a change-only validator still catches a real mismatch', async () => {
await handleWriteCommand('goto', [baseUrl + '/change-only-validator.html'], bm);
await handleWriteCommand('fill', ['#password', 'hello123'], bm);
await handleWriteCommand('fill', ['#password2', 'different'], bm);
const status = await bm.getPage().locator('#match-status').textContent();
expect(status).toBe('no-match');
});
});
+31
View File
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Test Page - Change-Only Validator</title>
</head>
<body>
<h1>Change-Only Validator</h1>
<!--
Minimal repro of AngularJS ng-change / debounced cross-field validators
(e.g. cPanel's Jupiter theme "Add FTP Account" password-match check):
the listener only reacts to `change`, never `input`. A page like this
silently "loses" a Playwright-style value-set-without-a-change-event.
-->
<input type="password" id="password" name="password">
<input type="password" id="password2" name="password2">
<div id="match-status">unknown</div>
<script>
function checkMatch() {
var a = document.getElementById('password').value;
var b = document.getElementById('password2').value;
document.getElementById('match-status').textContent =
a && a === b ? 'match' : 'no-match';
}
document.getElementById('password').addEventListener('change', checkMatch);
document.getElementById('password2').addEventListener('change', checkMatch);
</script>
</body>
</html>
+8
View File
@@ -42,6 +42,14 @@ beforeEach(() => {
const binDir = join(gstackDir, 'bin');
mkdirSync(binDir);
symlinkSync(join(import.meta.dir, '..', '..', 'bin', 'gstack-config'), join(binDir, 'gstack-config'));
// v1.63+: the script sources bin/gstack-egress-lib.sh unconditionally
// (receipted fetch helpers). A real install always has it beside
// gstack-config; without this link every test failed at the source line —
// masked until the suite-truncation fix because the runner died first.
symlinkSync(
join(import.meta.dir, '..', '..', 'bin', 'gstack-egress-lib.sh'),
join(binDir, 'gstack-egress-lib.sh'),
);
});
afterEach(() => {
+17 -5
View File
@@ -26,9 +26,14 @@ beforeAll(async () => {
await bm.launch();
});
afterAll(() => {
afterAll(async () => {
try { testServer.server.stop(); } catch {}
setTimeout(() => process.exit(0), 500);
// Close only this file's own browser — never process.exit(): bun test runs
// all files in one process, so a delayed exit kills the whole suite
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
// so race it at 3s and abandon; the child is reaped at process exit.
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
});
// ─── Unit Tests: Failure Tracking (no browser needed) ────────────
@@ -172,8 +177,15 @@ describe('handoff edge cases', () => {
// Each handoff test creates its own BrowserManager since handoff swaps the browser.
// These tests run sequentially (one browser at a time) to avoid resource issues.
// Headed-mode launch is broken on current macOS (the rebrand invalidates the
// Chrome-for-Testing bundle signature and XProtect kills the relaunch —
// #2242, #2554, #2138). These three integration tests drive a real headed
// handoff and fail ~5s in on any darwin box. They stay ENABLED on Linux CI.
// Un-skip when the browse-daemon lifecycle wave lands the signature fix.
const HEADED_BROKEN_ON_DARWIN = process.platform === 'darwin';
describe('handoff integration', () => {
test('full handoff: cookies preserved, headed mode active, commands work', async () => {
test.skipIf(HEADED_BROKEN_ON_DARWIN)('full handoff: cookies preserved, headed mode active, commands work', async () => {
const hbm = new BrowserManager();
await hbm.launch();
@@ -206,7 +218,7 @@ describe('handoff integration', () => {
}
}, 45000);
test('multi-tab handoff preserves all tabs', async () => {
test.skipIf(HEADED_BROKEN_ON_DARWIN)('multi-tab handoff preserves all tabs', async () => {
const hbm = new BrowserManager();
await hbm.launch();
@@ -223,7 +235,7 @@ describe('handoff integration', () => {
}
}, 45000);
test('handoff meta command joins args as message', async () => {
test.skipIf(HEADED_BROKEN_ON_DARWIN)('handoff meta command joins args as message', async () => {
const hbm = new BrowserManager();
await hbm.launch();
@@ -0,0 +1,138 @@
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { isProcessAlive } from '../src/error-handling';
import { spawnTerminalAgent } from '../src/terminal-agent-control';
// REGRESSION TEST for the Windows terminal-agent leak.
//
// Symptom (reported on Windows 11, 48GB box under a heavy parallel build):
// a console window popped to the foreground every 60 seconds, and orphaned
// `bun run terminal-agent.ts` processes accumulated at one per minute until
// the machine ran out of committable memory.
//
// Root cause was a three-bug chain, each of which this file pins:
//
// 1. `isProcessAlive` shelled out to `tasklist` on Windows with a 3s
// timeout. A Bun.spawnSync that hits its timeout STILL RETURNS, carrying
// partial stdout — so the `.includes()` PID match came back false and a
// LIVE agent was reported dead. Measured tasklist latency was 700-1700ms
// idle, and far worse under memory pressure, so the timeout was reachable
// in ordinary use.
// 2. That false negative made `killAgentByRecord` skip the kill (it
// validates liveness first) while the watchdog respawned anyway —
// leaking the survivor. Each orphan added memory pressure, slowing the
// next tasklist, producing the next false negative. Self-reinforcing.
// 3. Neither the tasklist probe nor the agent spawn passed `windowsHide`,
// so every tick allocated a visible console and stole focus.
//
// The guard-window arithmetic bug that let this run unbounded instead of
// tripping the crash-loop guard is pinned separately, in test 6.
const SRC_DIR = path.resolve(import.meta.dir, '..', 'src');
function readAllSourceFiles(): Array<{ file: string; content: string }> {
return fs
.readdirSync(SRC_DIR)
.filter((e) => e.endsWith('.ts'))
.map((e) => ({ file: e, content: fs.readFileSync(path.join(SRC_DIR, e), 'utf-8') }));
}
/** Strip line and block comments so static greps only see real code. */
function stripComments(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
}
describe('process liveness probe (Windows terminal-agent leak)', () => {
test('1. isProcessAlive reports the current process alive', () => {
expect(isProcessAlive(process.pid)).toBe(true);
});
test('2. isProcessAlive reports an unused PID dead', () => {
// Below Linux PID_MAX_LIMIT, far above any realistic Windows/macOS PID.
expect(isProcessAlive(2147483646)).toBe(false);
});
test('3. isProcessAlive spawns NO subprocess', () => {
// The heart of the bug: a liveness probe that forks is slow enough to
// time out, and a timed-out probe silently answers "dead". Signal 0
// cannot time out because it never leaves the process.
const origSpawn = (Bun as any).spawn;
const origSpawnSync = (Bun as any).spawnSync;
const spawns: string[] = [];
(Bun as any).spawn = (...args: any[]) => { spawns.push(`spawn:${JSON.stringify(args[0])}`); return origSpawn(...args); };
(Bun as any).spawnSync = (...args: any[]) => { spawns.push(`spawnSync:${JSON.stringify(args[0])}`); return origSpawnSync(...args); };
try {
isProcessAlive(process.pid);
isProcessAlive(2147483646);
expect(spawns).toEqual([]);
} finally {
(Bun as any).spawn = origSpawn;
(Bun as any).spawnSync = origSpawnSync;
}
});
test('4. no source file probes liveness via tasklist', () => {
// Static tripwire: re-introducing a tasklist-based existence check
// anywhere in src/ resurrects the false-negative class.
const offenders: string[] = [];
for (const { file, content } of readAllSourceFiles()) {
const code = stripComments(content);
// `PID eq` is the existence-probe form specifically. Other tasklist
// uses (e.g. IMAGENAME filters for browser detection) are unaffected.
if (/tasklist/.test(code) && /PID eq/.test(code)) offenders.push(file);
}
expect(offenders).toEqual([]);
});
test('5. spawnTerminalAgent passes windowsHide so no console is shown', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-hide-'));
const script = path.join(tmpDir, 'fake-agent.ts');
fs.writeFileSync(script, '// no-op\n');
const origSpawn = (Bun as any).spawn;
let captured: any = null;
(Bun as any).spawn = (_cmd: any, opts: any) => {
captured = opts;
return { pid: 4242, unref() {} };
};
try {
const pid = spawnTerminalAgent({
stateFile: path.join(tmpDir, 'state.json'),
serverPort: 12345,
ownerPid: process.pid,
cwd: tmpDir,
scriptPath: script,
});
expect(pid).toBe(4242);
expect(captured).not.toBeNull();
expect(captured.windowsHide).toBe(true);
// Owner-PID lifetime tie (#2019): the agent polls this and exits when
// its owning browse server dies, so it can't be adopted by PID 1.
expect(captured.env.BROWSE_OWNER_PID).toBe(String(process.pid));
// Detached background daemon — must not inherit a terminal either.
expect(captured.stdio).toEqual(['ignore', 'ignore', 'ignore']);
} finally {
(Bun as any).spawn = origSpawn;
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test('6. respawn guard window spans enough ticks for the guard to fire', () => {
// The guard was `RESPAWN_GUARD_WINDOW_MS = 60_000` against a 60_000ms
// tick, allowing at most ONE respawn in the window — so the
// `>= RESPAWN_GUARD_MAX (3)` trip condition was unreachable and a steady
// one-per-tick leak never self-limited. Assert the window is derived from
// the tick rather than fixed.
const src = fs.readFileSync(path.join(SRC_DIR, 'server.ts'), 'utf-8');
const match = src.match(/const RESPAWN_GUARD_WINDOW_MS =([\s\S]{0,160}?);/);
expect(match).not.toBeNull();
expect(match![1]).toContain('AGENT_WATCHDOG_TICK_MS');
// Pin the arithmetic itself: at the default tick, three respawns must fit.
const tick = 60_000;
const guardMax = 3;
const windowMs = Math.max(60_000, tick * (guardMax + 2));
expect(windowMs).toBeGreaterThanOrEqual(tick * guardMax);
});
});
+7 -2
View File
@@ -56,9 +56,14 @@ describe('defense-in-depth — live Playwright fixture', () => {
await bm.launch();
});
afterAll(() => {
afterAll(async () => {
try { testServer.server.stop(); } catch {}
setTimeout(() => process.exit(0), 500);
// Close only this file's own browser — never process.exit(): bun test
// runs all files in one process, so a delayed exit kills the whole suite
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
// so race it at 3s and abandon; the child is reaped at process exit.
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
});
test('L2 — content-security.ts hidden-element stripper detects the .sneaky div', async () => {
@@ -236,7 +236,7 @@ describe('buildFetchHandler ownsTerminalAgent gate', () => {
// 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,
import.meta.path,
'..',
'..',
'src',
+1 -1
View File
@@ -7,7 +7,7 @@ import * as path from 'path';
// 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');
const SERVER_TS = path.resolve(import.meta.path, '..', '..', 'src', 'server.ts');
describe('server: PTY lease routes (v1.44+ Commit 2)', () => {
test('1. /pty-session returns the 4-tuple shape (sessionId, attachToken, leaseExpiresAt)', () => {
+12 -11
View File
@@ -157,9 +157,9 @@ describe('sidepanel-terminal.js: eager auto-connect + injection API', () => {
test('forceRestart helper closes ws, disposes xterm, returns to IDLE', () => {
expect(TERM_JS).toContain('function forceRestart');
const fn = TERM_JS.slice(TERM_JS.indexOf('function forceRestart'));
// Deliberate close code so the agent's close handler can distinguish an
// intentional restart from a dropped connection (codex D8 redesign).
expect(fn).toContain("ws.close(4001, 'intentional-restart')");
// close() carries an intentional-restart close code so the agent's
// close handler can distinguish user restarts from network drops.
expect(fn).toContain("ws && ws.close(4001, 'intentional-restart')");
expect(fn).toContain('term.dispose()');
expect(fn).toContain('STATE.IDLE');
expect(fn).toContain('tryAutoConnect()');
@@ -225,16 +225,17 @@ describe('cli.ts: sidebar-agent is no longer spawned', () => {
});
test('Terminal-agent spawn survives', () => {
// The inline Bun.spawn of termAgentScript moved into the shared
// spawnTerminalAgent helper (terminal-agent-control.ts) so the CLI
// cold-start path and the supervisor respawn path share one
// identity-tracked spawn. The CLI must still call it.
expect(CLI_SRC).toContain("import { spawnTerminalAgent } from './terminal-agent-control'");
expect(CLI_SRC).toMatch(/spawnTerminalAgent\(\{/);
// v1.44 moved the raw Bun.spawn into the shared spawnTerminalAgent
// helper (terminal-agent-control.ts) so cli.ts, the supervisor respawn
// loop, and the watchdog all share identity-based process control.
// cli.ts must still route through that helper.
expect(CLI_SRC).toContain('spawnTerminalAgent');
const CONTROL_SRC = fs.readFileSync(
path.join(import.meta.dir, '../src/terminal-agent-control.ts'), 'utf-8');
path.join(import.meta.dir, '../src/terminal-agent-control.ts'),
'utf-8',
);
expect(CONTROL_SRC).toContain('terminal-agent.ts');
expect(CONTROL_SRC).toMatch(/spawn\(\['bun',\s*'run',\s*script\]/);
expect(CONTROL_SRC).toMatch(/\.spawn\(\['bun',\s*'run',\s*script\]/);
});
});
+196 -25
View File
@@ -1,14 +1,23 @@
/**
* Tests for sidebar UX invariants that survived the chat-tab rip:
* - Browser tab bar HTML/CSS + browser-manager tab sync plumbing
* - Inspector message allowlist + CSP fallback basic picker
* - Cleanup/screenshot toolbar buttons + deterministic cleanup heuristics
* - Welcome page, sidebar auto-open, arrow hint signal chain
* - Connection auth race, startup fast-retry, debug visibility
* Structural tests for the sidebar's surviving UX surfaces:
* - Quick-action toolbar (cleanup via PTY injection, screenshot, cookies)
* - CSP fallback basic picker (content.js) + inspector allowlist
* - Deterministic cleanup heuristics (write-commands.ts)
* - Welcome page + sidebar auto-open + arrow hint signal chain
* - Connection/auth race prevention + startup health check
* - browser-manager tab tracking + no-focus-steal invariants
* - Server shutdown teardown of the terminal-agent
*
* The chat-queue pipeline (sidebar-agent.ts, /sidebar-command,
* /sidebar-chat, chat bubbles) is gone its tests were pruned with it.
* See sidebar-tabs.test.ts for the invariants locking that removal.
* History: this file used to also pin the chat-queue architecture
* (sidebar-agent.ts, /sidebar-command, /sidebar-chat, /sidebar-tabs,
* per-tab chat context, stop button, chat polling, processAgentEvent,
* pickSidebarModel). That entire path was deliberately ripped in PR #1216
* (v1.14.0.0) when the interactive claude PTY (terminal-agent.ts) proved
* strictly more capable see docs/designs/SIDEBAR_MESSAGE_FLOW.md. The
* stale blocks kept "passing" only because a teardown bug made `bun test`
* exit 0 before reporting; once that was fixed (PR #2172) they surfaced as
* failures and were removed. The rip itself is pinned as absence tests in
* browse/test/sidebar-tabs.test.ts.
*/
import { describe, test, expect } from 'bun:test';
@@ -17,8 +26,6 @@ import * as path from 'path';
const ROOT = path.resolve(__dirname, '..');
// ─── Browser tab bar ────────────────────────────────────────────
describe('browser tab bar (sidepanel.html)', () => {
const html = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.html'), 'utf-8');
@@ -48,8 +55,6 @@ describe('sidebar→browser tab switch', () => {
describe('browser→sidebar tab sync', () => {
const bmSrc = fs.readFileSync(path.join(ROOT, 'src', 'browser-manager.ts'), 'utf-8');
const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8');
const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8');
test('syncActiveTabByUrl method exists on BrowserManager', () => {
expect(bmSrc).toContain('syncActiveTabByUrl(activeUrl: string)');
@@ -89,12 +94,16 @@ describe('browser→sidebar tab sync', () => {
expect(fn).toContain('this.pages.size <= 1');
});
// NOTE: the /sidebar-tabs + /sidebar-command server consumers of
// syncActiveTabByUrl and the sidepanel chat-tab handlers were removed
// with the chat-queue rip (PR #1216). The BrowserManager primitives above
// survive (tab tracking feeds active-tab.json for the PTY claude).
test('background.js listens for chrome.tabs.onActivated', () => {
const bgSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'background.js'), 'utf-8');
expect(bgSrc).toContain('chrome.tabs.onActivated.addListener');
expect(bgSrc).toContain('browserTabActivated');
});
});
describe('browser tab bar (sidepanel.css)', () => {
@@ -197,13 +206,14 @@ describe('CSP fallback basic picker', () => {
expect(contentSrc).toContain('getBoundingClientRect()');
});
test('content.js contains CSSOM iteration tolerating cross-origin sheets', () => {
test('content.js contains CSSOM iteration guarded against cross-origin sheets', () => {
expect(contentSrc).toContain('document.styleSheets');
expect(contentSrc).toContain('cssRules');
// Cross-origin sheets throw DOMException on cssRules access — the
// iteration swallows exactly that (typed catch), nothing broader.
expect(contentSrc).toContain('same-origin only');
expect(contentSrc).toContain('instanceof DOMException');
// Cross-origin stylesheets throw DOMException on cssRules access. The
// iteration must swallow exactly that (typed catch, not a bare catch {}
// — see the slop-scan philosophy in CLAUDE.md).
expect(contentSrc).toContain('(same-origin only)');
expect(contentSrc).toMatch(/catch \(e\) \{ if \(!\(e instanceof DOMException\)\) throw e; \}/);
});
test('content.js saves and restores outline on elements', () => {
@@ -260,6 +270,24 @@ describe('cleanup and screenshot buttons', () => {
expect(html).toContain('quick-actions');
});
test('cleanup button injects smart prompt into the live PTY (not just deterministic selectors)', () => {
// Cleanup pipes a prompt into the running claude PTY via
// gstackInjectToTerminal (the chat-queue POST to /sidebar-command was
// ripped in PR #1216 — the live REPL is the only execution surface).
const cleanupFn = js.slice(
js.indexOf('async function runCleanup('),
js.indexOf('async function runScreenshot('),
);
expect(cleanupFn).toContain('gstackInjectToTerminal');
expect(cleanupFn).toContain('cleanupPrompt');
// Should include both deterministic first pass AND agent snapshot analysis
expect(cleanupFn).toContain('cleanup --all');
expect(cleanupFn).toContain('snapshot -i');
// Should instruct claude to keep site branding
expect(cleanupFn).toContain('Keep the site');
expect(cleanupFn).toContain('header/masthead');
});
test('sidepanel.js screenshot handler POSTs to /command with screenshot', () => {
expect(js).toContain("command: 'screenshot'");
});
@@ -408,9 +436,9 @@ describe('chat toolbar buttons disabled state', () => {
});
});
// ─── No focus stealing (switchTab bringToFront) ─────────────────
// ─── Focus stealing prevention ──────────────────────────────────
describe('no focus stealing (switchTab bringToFront)', () => {
describe('tab switching does not steal focus', () => {
const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8');
const bmSrc = fs.readFileSync(path.join(ROOT, 'src', 'browser-manager.ts'), 'utf-8');
@@ -438,6 +466,17 @@ describe('LLM-based cleanup (smart agent cleanup)', () => {
const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8');
const wcSrc = fs.readFileSync(path.join(ROOT, 'src', 'write-commands.ts'), 'utf-8');
test('cleanup button does not bypass the agent with a direct /command POST', () => {
const cleanupFn = js.slice(
js.indexOf('async function runCleanup('),
js.indexOf('async function runScreenshot('),
);
// The smart cleanup goes through the claude PTY, never a raw
// deterministic /command fetch. (The PTY-injection wiring itself is
// pinned in sidebar-tabs.test.ts.)
expect(cleanupFn).not.toMatch(/fetch.*\/command['"]/);
});
test('cleanup prompt includes deterministic first pass', () => {
const cleanupFn = js.slice(
js.indexOf('async function runCleanup('),
@@ -447,6 +486,64 @@ describe('LLM-based cleanup (smart agent cleanup)', () => {
expect(cleanupFn).toContain('cleanup --all');
});
test('cleanup prompt instructs agent to snapshot and analyze', () => {
const cleanupFn = js.slice(
js.indexOf('async function runCleanup('),
js.indexOf('async function runScreenshot('),
);
// Agent should take a snapshot to see what deterministic pass missed
expect(cleanupFn).toContain('snapshot -i');
// Agent should analyze what remains
expect(cleanupFn).toContain('identify any remaining');
});
test('cleanup prompt lists specific clutter categories for agent', () => {
const cleanupFn = js.slice(
js.indexOf('async function runCleanup('),
js.indexOf('async function runScreenshot('),
);
// Should guide the agent on what to look for
expect(cleanupFn).toContain('cookie/consent banners');
expect(cleanupFn).toContain('newsletter popups');
expect(cleanupFn).toContain('login walls');
expect(cleanupFn).toContain('video autoplay');
expect(cleanupFn).toContain('sidebar');
expect(cleanupFn).toContain('share');
expect(cleanupFn).toContain('floating chat');
});
test('cleanup prompt instructs agent to preserve site identity', () => {
const cleanupFn = js.slice(
js.indexOf('async function runCleanup('),
js.indexOf('async function runScreenshot('),
);
// Must keep the site looking like itself
expect(cleanupFn).toContain('Keep the site');
expect(cleanupFn).toContain('header/masthead');
expect(cleanupFn).toContain('headline');
expect(cleanupFn).toContain('article body');
expect(cleanupFn).toContain('byline');
});
test('cleanup prompt instructs agent to unlock scrolling', () => {
const cleanupFn = js.slice(
js.indexOf('async function runCleanup('),
js.indexOf('async function runScreenshot('),
);
expect(cleanupFn).toContain('unlock scrolling');
expect(cleanupFn).toContain('scroll-locked');
});
test('cleanup prompt instructs agent to use $B eval for removal', () => {
const cleanupFn = js.slice(
js.indexOf('async function runCleanup('),
js.indexOf('async function runScreenshot('),
);
// Agent should use $B eval to hide elements via JavaScript
expect(cleanupFn).toContain('$B eval');
expect(cleanupFn).toContain('hide each');
});
test('cleanup removes loading state after short delay (agent is async)', () => {
const cleanupFn = js.slice(
js.indexOf('async function runCleanup('),
@@ -677,13 +774,16 @@ describe('sidebar arrow hint hide flow (4-step signal chain)', () => {
test('step 1: sidepanel sends sidebarOpened message on connect', () => {
expect(spSrc).toContain("{ type: 'sidebarOpened' }");
// Should be in updateConnection, after setConnState('connected').
// Window is 1500 chars — the function grew bootstrap-global exports
// for sidepanel-terminal.js ahead of the sidebarOpened send.
// Window is generous: updateConnection also exposes the PTY bootstrap
// globals (gstackServerPort/gstackAuthToken) before the connected branch.
const connectFn = spSrc.slice(
spSrc.indexOf('function updateConnection('),
spSrc.indexOf('function updateConnection(') + 1500,
spSrc.indexOf('function updateConnection(') + 2500,
);
expect(connectFn).toContain('sidebarOpened');
const connectedIdx = connectFn.indexOf("setConnState('connected')");
const openedIdx = connectFn.indexOf('sidebarOpened');
expect(connectedIdx).toBeGreaterThan(0);
expect(openedIdx).toBeGreaterThan(connectedIdx);
});
// Step 2: background.js accepts and relays sidebarOpened
@@ -798,6 +898,51 @@ describe('sidebar debug visibility when stuck', () => {
});
});
describe('BROWSE_NO_AUTOSTART (sidebar headless prevention)', () => {
const cliSrc = fs.readFileSync(path.join(ROOT, 'src', 'cli.ts'), 'utf-8');
const termAgentSrc = fs.readFileSync(path.join(ROOT, 'src', 'terminal-agent.ts'), 'utf-8');
test('cli.ts checks BROWSE_NO_AUTOSTART before starting a new server', () => {
// ensureServer must check this env var BEFORE spawning a server.
// (Anchor on the open paren — both functions grew parameters.)
const ensureStart = cliSrc.indexOf('async function ensureServer(');
const ensureEnd = cliSrc.indexOf('\nasync function ', ensureStart + 1);
const ensureServerFn = cliSrc.slice(
ensureStart,
ensureEnd > ensureStart ? ensureEnd : undefined,
);
expect(ensureServerFn).toContain('BROWSE_NO_AUTOSTART');
expect(ensureServerFn).toContain('process.exit(1)');
});
test('cli.ts shows actionable error message when BROWSE_NO_AUTOSTART blocks', () => {
expect(cliSrc).toContain('/open-gstack-browser');
expect(cliSrc).toContain('BROWSE_NO_AUTOSTART is set');
});
test('terminal-agent.ts sets BROWSE_NO_AUTOSTART=1 for the claude PTY', () => {
// The PTY claude must reuse THIS headed server, never race to spawn
// its own. (sidebar-agent.ts, the original setter, was ripped in
// PR #1216 — the PTY agent inherited the same env contract.)
expect(termAgentSrc).toContain("BROWSE_NO_AUTOSTART: '1'");
});
test('terminal-agent.ts sets BROWSE_PORT for headed server reuse', () => {
expect(termAgentSrc).toContain('BROWSE_PORT');
});
test('BROWSE_NO_AUTOSTART check happens before lock acquisition', () => {
// The guard must be BEFORE the lock acquisition. If it's after,
// we'd acquire a lock and then exit, leaving a stale lock file.
const ensureServerStart = cliSrc.indexOf('async function ensureServer(');
const noAutoStart = cliSrc.indexOf('BROWSE_NO_AUTOSTART', ensureServerStart);
const lockAcquisition = cliSrc.indexOf('Acquire lock', ensureServerStart);
expect(noAutoStart).toBeGreaterThan(0);
expect(lockAcquisition).toBeGreaterThan(0);
expect(noAutoStart).toBeLessThan(lockAcquisition);
});
});
// ─── Idle timeout disabled in headed mode (server.ts) ───────────
//
// The original 'idle check skips in headed mode' string-grep test was deleted
@@ -806,6 +951,32 @@ describe('sidebar debug visibility when stuck', () => {
// Behavioral coverage lives in browse/test/server-factory.test.ts under the
// 'idle timer + onDisconnect dual-instance fix' describe block, which
// exercises the headed/headless/tunnel branches of idleCheckTick directly.
// The companion '/sidebar-command resets idle timer' test went with the
// chat-queue rip (PR #1216) — /command and /batch reset the timer and are
// covered by that factory suite.
// ─── Shutdown kills the terminal-agent (server.ts) ──────────────
describe('shutdown cleanup (server.ts)', () => {
const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8');
test('shutdown kills the terminal-agent via identity-based kill (no pkill)', () => {
// v1.44+ identity-based teardown: only the PID recorded by THIS
// daemon's agent is signaled. The pre-v1.44 `pkill -f terminal-agent`
// regex killed sibling gstack sessions on the same host (also pinned
// by browse/test/terminal-agent-pid-identity.test.ts).
const shutdownFn = serverSrc.slice(
serverSrc.indexOf('async function shutdown('),
serverSrc.indexOf('async function shutdown(') + 1200,
);
expect(shutdownFn).toContain('killAgentByRecord');
expect(shutdownFn).toContain('readAgentRecord');
// No pkill CALL — the word may appear in the explanatory comment, so
// match invocation shapes only. The repo-wide reintroduction tripwire
// is browse/test/terminal-agent-pid-identity.test.ts.
expect(shutdownFn).not.toMatch(/(?:spawnSync|execSync|\$)\(\s*['"`]pkill/);
});
});
// ─── Cookie button in sidebar footer ────────────────────────────
@@ -12,7 +12,7 @@ import * as path from 'path';
// explicit unrecoverable signals (401 auth invalid).
const CLIENT_JS = path.resolve(
new URL(import.meta.url).pathname,
import.meta.path,
'..',
'..',
'..',
+1 -1
View File
@@ -13,7 +13,7 @@ import * as path from 'path';
// in the e2e tier.
const TERMINAL_JS = path.resolve(
new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel-terminal.js',
import.meta.path, '..', '..', '..', 'extension', 'sidepanel-terminal.js',
);
describe('sidepanel re-attach loop (v1.44+ Commit 3)', () => {
@@ -16,10 +16,10 @@ import * as path from 'path';
// doesn't leak a 60s-zombie claude.
const TERMINAL_JS = path.resolve(
new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel-terminal.js',
import.meta.path, '..', '..', '..', 'extension', 'sidepanel-terminal.js',
);
const SIDEPANEL_JS = path.resolve(
new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel.js',
import.meta.path, '..', '..', '..', 'extension', 'sidepanel.js',
);
describe('sidepanel-terminal: forceRestart via /pty-restart (v1.44+)', () => {
+7 -2
View File
@@ -31,9 +31,14 @@ beforeAll(async () => {
await bm.launch();
});
afterAll(() => {
afterAll(async () => {
try { testServer.server.stop(); } catch {}
setTimeout(() => process.exit(0), 500);
// Close only this file's own browser — never process.exit(): bun test runs
// all files in one process, so a delayed exit kills the whole suite
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
// so race it at 3s and abandon; the child is reaped at process exit.
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
});
// ─── Snapshot Output ────────────────────────────────────────────
@@ -10,7 +10,7 @@ import * as path from 'path';
// 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');
const AGENT_TS = path.resolve(import.meta.path, '..', '..', 'src', 'terminal-agent.ts');
describe('terminal-agent detach + re-attach (v1.44+ Commit 3)', () => {
test('1. PtySession carries ring buffer + alt-screen + detach state', () => {
@@ -227,6 +227,45 @@ describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () =
expect(resp.headers.get('sec-websocket-protocol')).toBe(`gstack-pty.${token}`);
});
test('upgrade response contains exactly ONE Sec-WebSocket-Protocol header', async () => {
// RFC 6455: the server MUST select at most one subprotocol. Bun >= 1.3
// auto-echoes the first offered protocol in server.upgrade(), so a
// manual echo on top of that produced TWO Sec-WebSocket-Protocol
// headers — and strict clients (Chromium, python websockets) reject the
// handshake, leaving the sidebar terminal permanently disconnected.
//
// Headers.get() normalizes duplicates away, so this test handshakes
// over a raw socket and counts header lines in the response head.
const token = 'dup-proto-token-must-be-at-least-seventeen-chars';
await grantToken(token);
const head = await new Promise<string>((resolve, reject) => {
const req =
'GET /ws HTTP/1.1\r\n' +
`Host: 127.0.0.1:${agentPort}\r\n` +
'Connection: Upgrade\r\n' +
'Upgrade: websocket\r\n' +
'Sec-WebSocket-Version: 13\r\n' +
'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n' +
`Sec-WebSocket-Protocol: gstack-pty.${token}\r\n` +
'Origin: chrome-extension://test-extension-id\r\n' +
'\r\n';
let buf = '';
const socket = require('net').connect(agentPort, '127.0.0.1', () => socket.write(req));
socket.setTimeout(5000, () => { socket.destroy(); reject(new Error('handshake timeout')); });
socket.on('data', (chunk: Buffer) => {
buf += chunk.toString('utf8');
const end = buf.indexOf('\r\n\r\n');
if (end !== -1) { socket.destroy(); resolve(buf.slice(0, end)); }
});
socket.on('error', reject);
});
expect(head).toContain('101');
const protoLines = head.split('\r\n').filter(l => l.toLowerCase().startsWith('sec-websocket-protocol:'));
expect(protoLines).toEqual([`Sec-WebSocket-Protocol: 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: {
@@ -12,7 +12,7 @@ import * as path from 'path';
// (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');
const AGENT_TS = path.resolve(import.meta.path, '..', '..', 'src', 'terminal-agent.ts');
describe('terminal-agent internalHandler refactor (v1.44+)', () => {
test('1. internalHandler<T> exists with the documented signature', () => {
+2 -2
View File
@@ -11,8 +11,8 @@ import * as path from 'path';
// 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');
const AGENT_TS = path.resolve(import.meta.path, '..', '..', 'src', 'terminal-agent.ts');
const CLIENT_JS = path.resolve(import.meta.path, '..', '..', '..', 'extension', 'sidepanel-terminal.js');
describe('terminal-agent WS keepalive (v1.44+)', () => {
test('1. agent has a KEEPALIVE_INTERVAL_MS env knob, default 25000', () => {
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const AGENT_SCRIPT = path.join(import.meta.dir, '../src/terminal-agent.ts');
const spawned: any[] = [];
const tempDirs: string[] = [];
function isAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitFor(predicate: () => boolean, timeoutMs = 5_000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return true;
await Bun.sleep(25);
}
return predicate();
}
afterEach(() => {
for (const proc of spawned.splice(0)) {
try { proc.kill?.('SIGKILL'); } catch {}
}
for (const dir of tempDirs.splice(0)) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
});
describe('terminal-agent owner lifecycle', () => {
test('exits after its owning browse server process exits', async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-term-owner-'));
tempDirs.push(stateDir);
const stateFile = path.join(stateDir, 'browse.json');
fs.writeFileSync(stateFile, JSON.stringify({ token: 'test-token' }));
// process.execPath (the running bun) instead of `sleep`: coreutils are
// not guaranteed on a bare windows-latest runner, and this test is on the
// Windows CI curated list — the owner-orphan leak it pins is a Windows bug.
const owner = Bun.spawn(
[process.execPath, '-e', 'await Bun.sleep(30000)'],
{ stdio: ['ignore', 'ignore', 'ignore'] },
);
spawned.push(owner);
const agent = Bun.spawn(['bun', 'run', AGENT_SCRIPT], {
env: {
...process.env,
BROWSE_STATE_FILE: stateFile,
BROWSE_SERVER_PORT: '0',
BROWSE_OWNER_PID: String(owner.pid),
GSTACK_TERMINAL_OWNER_WATCHDOG_MS: '25',
},
stdio: ['ignore', 'ignore', 'ignore'],
});
spawned.push(agent);
expect(await waitFor(() => fs.existsSync(path.join(stateDir, 'terminal-agent-pid')))).toBe(true);
expect(isAlive(agent.pid)).toBe(true);
owner.kill('SIGTERM');
await owner.exited;
expect(await waitFor(() => !isAlive(agent.pid))).toBe(true);
expect(fs.existsSync(path.join(stateDir, 'terminal-agent-pid'))).toBe(false);
expect(fs.existsSync(path.join(stateDir, 'terminal-port'))).toBe(false);
});
});
@@ -30,7 +30,7 @@ import {
// 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');
const SRC_DIR = path.resolve(import.meta.path, '..', '..', 'src');
function readAllSourceFiles(): Array<{ file: string; content: string }> {
const out: Array<{ file: string; content: string }> = [];
@@ -13,7 +13,7 @@ import * as path from 'path';
// - {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');
const AGENT_TS = path.resolve(import.meta.path, '..', '..', 'src', 'terminal-agent.ts');
describe('terminal-agent session routing (v1.44+ Commit 2)', () => {
test('1. validTokens is a Map binding token → sessionId', () => {
+10 -4
View File
@@ -10,8 +10,8 @@ import * as path from 'path';
// 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');
const SERVER_TS = path.resolve(import.meta.path, '..', '..', 'src', 'server.ts');
const CONTROL_TS = path.resolve(import.meta.path, '..', '..', 'src', 'terminal-agent-control.ts');
describe('terminal-agent watchdog (v1.44+)', () => {
test('1. spawnTerminalAgent helper exists with PID return type', () => {
@@ -50,7 +50,13 @@ describe('terminal-agent watchdog (v1.44+)', () => {
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');
// The window MUST be derived from the tick, not a fixed 60_000. It was
// hardcoded to 60_000 against a 60_000ms tick, so at most ONE respawn
// could ever sit inside the window and the `>= RESPAWN_GUARD_MAX` trip
// was unreachable — a steady one-respawn-per-tick leak ran unbounded
// instead of self-limiting after 3. Pinning the literal is what let that
// ship, so pin the relationship instead.
expect(block).toMatch(/RESPAWN_GUARD_WINDOW_MS =[\s\S]{0,200}AGENT_WATCHDOG_TICK_MS/);
expect(block).toContain('RESPAWN_GUARD_MAX = 3');
expect(block).toContain('respawnHistory');
expect(block).toContain('agentRespawnGuardTripped');
@@ -72,7 +78,7 @@ describe('terminal-agent watchdog (v1.44+)', () => {
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'),
path.resolve(import.meta.path, '..', '..', 'src', 'cli.ts'),
'utf-8',
);
// Otherwise the CLI and watchdog could drift on spawn env/cwd, and
+29 -8
View File
@@ -129,21 +129,26 @@ describe('Source-level guard: terminal-agent', () => {
expect(wsHandler).toContain('validTokens.has');
});
test('Sec-WebSocket-Protocol auth: strips gstack-pty. prefix and echoes back', () => {
test('Sec-WebSocket-Protocol auth: strips gstack-pty. prefix, no manual echo', () => {
const wsHandler = AGENT_SRC.slice(AGENT_SRC.indexOf("if (url.pathname === '/ws')"));
// Browsers send `Sec-WebSocket-Protocol: gstack-pty.<token>`. The agent
// must strip the prefix before checking validTokens, AND echo the
// protocol back in the upgrade response — without the echo, the
// browser closes the connection immediately.
// must strip the prefix before checking validTokens. The protocol echo
// is Bun's job: Bun >= 1.3 auto-echoes the first offered protocol in the
// 101 response. A manual echo on top produced a DUPLICATE
// Sec-WebSocket-Protocol header, which strict clients (Chromium, python
// websockets) reject per RFC 6455 — the sidebar terminal could never
// connect. Pin the invariant: no manual echo in the upgrade call.
expect(wsHandler).toContain("'gstack-pty.'");
expect(wsHandler).toContain('Sec-WebSocket-Protocol');
expect(wsHandler).toContain('acceptedProtocol');
expect(wsHandler).toContain('sec-websocket-protocol');
expect(wsHandler).not.toContain("headers: { 'Sec-WebSocket-Protocol'");
});
test('lazy spawn: claude PTY is spawned in message handler, not on upgrade', () => {
// The whole point of lazy-spawn (codex finding #8) is that the WS
// upgrade itself does NOT call spawnClaude. Spawn happens on first
// message frame.
// upgrade itself does NOT spawn claude. Spawn happens on first
// message frame (binary input or the v1.44 explicit `start` frame),
// routed through the maybeSpawnPty helper, which is the only caller
// of spawnClaude.
const upgradeBlock = AGENT_SRC.slice(
AGENT_SRC.indexOf("if (url.pathname === '/ws')"),
AGENT_SRC.indexOf("websocket: {"),
@@ -151,11 +156,27 @@ describe('Source-level guard: terminal-agent', () => {
// v1.44 renamed spawnClaude -> maybeSpawnPty (explicit `start` frame +
// lazy first-byte spawn share one helper). Pin was stale from then until
// the free suite got a CI job.
expect(upgradeBlock).not.toContain('spawnClaude(');
expect(upgradeBlock).not.toContain('maybeSpawnPty(');
// Spawn must be invoked from the message handler (lazy on first byte).
// v1.44 routes both spawn triggers (explicit {type:"start"} text frame
// and the lazy binary-frame path) through the maybeSpawnPty helper.
const messageHandler = AGENT_SRC.slice(AGENT_SRC.indexOf('message(ws, raw)'));
expect(messageHandler).toContain('maybeSpawnPty(');
expect(messageHandler).toContain('!session.spawned');
// The open() upgrade handler must not spawn — it only creates the
// (spawned: false) session record or re-attaches a detached one.
const openBlock = AGENT_SRC.slice(
AGENT_SRC.indexOf('open(ws)'),
AGENT_SRC.indexOf('message(ws, raw)'),
);
expect(openBlock).not.toContain('spawnClaude(');
expect(openBlock).not.toContain('maybeSpawnPty(');
// And the helper itself is where spawnClaude actually happens, gated
// on session.spawned so it stays a single-shot lazy spawn.
const helperBlock = AGENT_SRC.slice(AGENT_SRC.indexOf('function maybeSpawnPty'));
expect(helperBlock).toContain('spawnClaude(');
expect(helperBlock).toContain('if (session.spawned) return true;');
});
test('process.on uncaughtException + unhandledRejection handlers exist', () => {
+22
View File
@@ -47,6 +47,28 @@ describe('validateNavigationUrl', () => {
await expect(validateNavigationUrl('file://host.example.com/foo.html')).rejects.toThrow(/Unsupported file URL host/i);
});
// The daemon opens its own first tab on about:blank, so blocking it meant a restarted
// daemon could never initialise — and `make-pdf setup`, whose Chromium smoke test is
// `browse newtab about:blank`, reported "Chromium failed to launch" on a healthy browser.
it('allows about:blank — the daemon opens its own first tab there', async () => {
await expect(validateNavigationUrl('about:blank')).resolves.toBe('about:blank');
});
it('allows about:blank regardless of case, since URL parsing normalises it', async () => {
await expect(validateNavigationUrl('ABOUT:BLANK')).resolves.toBe('about:blank');
});
// The allowance is about:blank EXACTLY, not the about: scheme. about:blank has no
// origin and loads nothing; the rest of the scheme is a real surface.
it('still blocks other about: URLs', async () => {
await expect(validateNavigationUrl('about:config')).rejects.toThrow(/scheme.*not allowed/i);
await expect(validateNavigationUrl('about:net-internals')).rejects.toThrow(/scheme.*not allowed/i);
});
it('blocks about:blankfoo — exact match, never a prefix test', async () => {
await expect(validateNavigationUrl('about:blankfoo')).rejects.toThrow(/scheme.*not allowed/i);
});
it('blocks javascript: scheme', async () => {
await expect(validateNavigationUrl('javascript:alert(1)')).rejects.toThrow(/scheme.*not allowed/i);
});