Files
gstack/browse/test/browser-skill-commands.test.ts
T
Garry TanandClaude Fable 5 5c081a355a fix(test): kill the four worst fixed sleeps (300s/30s/30s/20s)
- watchdog.test: the 20s blind wait for one production parent-watchdog
  tick becomes BROWSE_PARENT_WATCHDOG_INTERVAL_MS=250 (new env knob in
  server.ts, NaN-safe, production default unchanged) + polls for the
  boot line and the tick's stay-alive log — strictly stronger (the old
  form never proved a tick observed the parent death). 24s → 3.6s.
- stop-dead-daemon / terminal-agent-owner-watchdog: the 300s/30s
  stand-in child lifetimes become stdin-EOF-bound — the child can never
  self-exit mid-test on a slow runner (spurious-failure class) and
  self-reaps instantly if the test dies (no 300s orphans). Node-compat
  stdin APIs (owner-watchdog runs on the Windows lane).
- browser-skill-commands: the sleeper fixture's 30s self-time becomes
  8s (no stdin pipe exists in runToFiles) — far above the 1s product
  timeout it must outlive, below the test ceiling, so a timeout-kill
  regression fails on clean assertions instead of an opaque bun
  timeout; added: stdout must NOT contain 'done'.

45/45 green across the four files + server tripwires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 05:31:02 +00:00

434 lines
18 KiB
TypeScript

/**
* browser-skill-commands tests — covers the dispatch surface, env scrubbing,
* spawn lifecycle, timeout, stdout cap.
*
* The `run` and `test` subcommands spawn `bun` subprocesses, so these tests
* write tiny inline scripts to the synthetic skill dir and assert behavior
* end-to-end.
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
initRegistry, validateToken, listTokens, __resetRegistry,
} from '../src/token-registry';
import {
handleSkillCommand,
spawnSkill,
buildSpawnEnv,
parseSkillRunArgs,
} from '../src/browser-skill-commands';
import { readBrowserSkill, type TierPaths } from '../src/browser-skills';
let tmpRoot: string;
let tiers: TierPaths;
beforeEach(() => {
// __resetRegistry zeroes rootToken so the new initRegistry mismatch guard
// doesn't fire on the immediate initRegistry call.
__resetRegistry();
initRegistry('root-token-for-tests');
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'browser-skill-cmd-test-'));
tiers = {
project: path.join(tmpRoot, 'project', '.gstack', 'browser-skills'),
global: path.join(tmpRoot, 'home', '.gstack', 'browser-skills'),
bundled: path.join(tmpRoot, 'gstack-install', 'browser-skills'),
};
fs.mkdirSync(tiers.project!, { recursive: true });
fs.mkdirSync(tiers.global, { recursive: true });
fs.mkdirSync(tiers.bundled, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
function makeSkillDir(tierRoot: string, name: string, frontmatter: string, scriptBody: string = '') {
const dir = path.join(tierRoot, name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\n${frontmatter}\n---\nbody\n`);
if (scriptBody) {
fs.writeFileSync(path.join(dir, 'script.ts'), scriptBody);
}
return dir;
}
describe('parseSkillRunArgs', () => {
it('extracts --timeout=N', () => {
const r = parseSkillRunArgs(['--timeout=10', '--arg', 'foo=bar']);
expect(r.timeoutSeconds).toBe(10);
expect(r.passthrough).toEqual(['--arg', 'foo=bar']);
});
it('defaults to 60s when no timeout', () => {
const r = parseSkillRunArgs(['--arg', 'foo=bar']);
expect(r.timeoutSeconds).toBe(60);
expect(r.passthrough).toEqual(['--arg', 'foo=bar']);
});
it('passes through unknown flags', () => {
const r = parseSkillRunArgs(['--keywords=ai', '--limit=10']);
expect(r.passthrough).toEqual(['--keywords=ai', '--limit=10']);
});
it('ignores invalid --timeout values', () => {
const r = parseSkillRunArgs(['--timeout=abc', '--timeout=-5']);
expect(r.timeoutSeconds).toBe(60);
});
});
describe('handleSkillCommand: list', () => {
it('shows empty message when no skills', async () => {
const result = await handleSkillCommand(['list'], { port: 9999, tiers });
expect(result).toContain('No browser-skills found');
});
it('lists skills with their resolved tier', async () => {
makeSkillDir(tiers.bundled, 'foo', 'name: foo\nhost: a.com\ndescription: foo desc');
makeSkillDir(tiers.global, 'bar', 'name: bar\nhost: b.com\ndescription: bar desc');
const result = await handleSkillCommand(['list'], { port: 9999, tiers });
expect(result).toContain('foo');
expect(result).toContain('bundled');
expect(result).toContain('a.com');
expect(result).toContain('bar');
expect(result).toContain('global');
});
it('prints project tier when same name in multiple tiers', async () => {
makeSkillDir(tiers.bundled, 'shared', 'name: shared\nhost: bundled.com');
makeSkillDir(tiers.project!, 'shared', 'name: shared\nhost: project.com');
const result = await handleSkillCommand(['list'], { port: 9999, tiers });
expect(result).toContain('project');
expect(result).toContain('project.com');
expect(result).not.toContain('bundled.com');
});
});
describe('handleSkillCommand: show', () => {
it('prints SKILL.md', async () => {
makeSkillDir(tiers.bundled, 'foo', 'name: foo\nhost: a.com\ndescription: hi');
const result = await handleSkillCommand(['show', 'foo'], { port: 9999, tiers });
expect(result).toContain('name: foo');
expect(result).toContain('host: a.com');
expect(result).toContain('body');
});
it('throws when skill missing', async () => {
await expect(handleSkillCommand(['show', 'nope'], { port: 9999, tiers })).rejects.toThrow(/not found/);
});
it('throws when name omitted', async () => {
await expect(handleSkillCommand(['show'], { port: 9999, tiers })).rejects.toThrow(/Usage/);
});
});
describe('handleSkillCommand: rm', () => {
it('tombstones global skill by default', async () => {
makeSkillDir(tiers.global, 'gone', 'name: gone\nhost: x.com');
// No project tier skill, so default tier resolution should target global anyway.
// But the function defaults to 'project' unless --global. With no project
// skill, it would error — pass --global explicitly.
const result = await handleSkillCommand(['rm', 'gone', '--global'], { port: 9999, tiers });
expect(result).toContain('Tombstoned');
expect(fs.existsSync(path.join(tiers.global, 'gone'))).toBe(false);
});
it('tombstones project skill', async () => {
makeSkillDir(tiers.project!, 'gone', 'name: gone\nhost: x.com');
const result = await handleSkillCommand(['rm', 'gone'], { port: 9999, tiers });
expect(result).toContain('Tombstoned');
expect(fs.existsSync(path.join(tiers.project!, 'gone'))).toBe(false);
});
it('falls back to global when no project tier path', async () => {
const tiersNoProject = { ...tiers, project: null };
makeSkillDir(tiers.global, 'gone', 'name: gone\nhost: x.com');
const result = await handleSkillCommand(['rm', 'gone'], { port: 9999, tiers: tiersNoProject });
expect(result).toContain('global');
});
});
describe('handleSkillCommand: help / unknown', () => {
it('prints usage with no subcommand', async () => {
const r = await handleSkillCommand([], { port: 9999, tiers });
expect(r).toContain('Usage');
});
it('throws on unknown subcommand', async () => {
await expect(handleSkillCommand(['frobnicate'], { port: 9999, tiers }))
.rejects.toThrow(/Unknown skill subcommand/);
});
});
describe('buildSpawnEnv', () => {
let origEnv: Record<string, string | undefined>;
beforeEach(() => {
origEnv = { ...process.env };
// Plant some secrets for scrub-tests
process.env.GITHUB_TOKEN = 'gh-secret';
process.env.OPENAI_API_KEY = 'oai-secret';
process.env.MY_PASSWORD = 'sup3r';
process.env.NPM_TOKEN = 'npmtok';
process.env.AWS_SECRET_ACCESS_KEY = 'aws-secret';
process.env.GSTACK_TOKEN = 'root-token';
process.env.HOME = '/Users/test';
process.env.PATH = '/test/bin:/usr/bin';
process.env.LANG = 'en_US.UTF-8';
});
afterEach(() => {
// process.env = origEnv replaces only the reference; the underlying
// env stays mutated and leaks to later test files in the same Bun
// process (e.g., breaks Bun.which('bash') in security.test.ts and
// bun-spawn in pair-agent-tunnel-eval.test.ts). Delete every current
// key then re-assign from the snapshot — restores the actual env.
for (const k of Object.keys(process.env)) {
if (!(k in origEnv)) delete process.env[k];
}
for (const [k, v] of Object.entries(origEnv)) {
if (v !== undefined) process.env[k] = v;
}
});
it('untrusted: drops $HOME and secrets', () => {
const env = buildSpawnEnv({ trusted: false, port: 1234, skillToken: 'tok' });
expect(env.HOME).toBeUndefined();
expect(env.GITHUB_TOKEN).toBeUndefined();
expect(env.OPENAI_API_KEY).toBeUndefined();
expect(env.MY_PASSWORD).toBeUndefined();
expect(env.NPM_TOKEN).toBeUndefined();
expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined();
expect(env.GSTACK_TOKEN).toBeUndefined();
});
it('untrusted: keeps locale + TERM', () => {
process.env.TERM = 'xterm-256color';
const env = buildSpawnEnv({ trusted: false, port: 1234, skillToken: 'tok' });
expect(env.LANG).toBe('en_US.UTF-8');
expect(env.TERM).toBe('xterm-256color');
});
it('untrusted: PATH is minimal (no /test/bin override)', () => {
const env = buildSpawnEnv({ trusted: false, port: 1234, skillToken: 'tok' });
expect(env.PATH).not.toContain('/test/bin');
expect(env.PATH).toMatch(/\/(usr\/local\/)?bin/);
});
it('untrusted: injects GSTACK_PORT + GSTACK_SKILL_TOKEN', () => {
const env = buildSpawnEnv({ trusted: false, port: 1234, skillToken: 'tok-xyz' });
expect(env.GSTACK_PORT).toBe('1234');
expect(env.GSTACK_SKILL_TOKEN).toBe('tok-xyz');
});
it('trusted: keeps $HOME', () => {
const env = buildSpawnEnv({ trusted: true, port: 1234, skillToken: 'tok' });
expect(env.HOME).toBe('/Users/test');
});
it('trusted: still strips GSTACK_TOKEN (defense in depth)', () => {
const env = buildSpawnEnv({ trusted: true, port: 1234, skillToken: 'tok' });
expect(env.GSTACK_TOKEN).toBeUndefined();
});
it('trusted: keeps developer secrets (intentional)', () => {
const env = buildSpawnEnv({ trusted: true, port: 1234, skillToken: 'tok' });
expect(env.GITHUB_TOKEN).toBe('gh-secret');
});
it('GSTACK_PORT/GSTACK_SKILL_TOKEN can never be overridden by parent env', () => {
process.env.GSTACK_PORT = '99999'; // attacker-set
process.env.GSTACK_SKILL_TOKEN = 'attacker-tok';
const env = buildSpawnEnv({ trusted: true, port: 1234, skillToken: 'real-tok' });
expect(env.GSTACK_PORT).toBe('1234');
expect(env.GSTACK_SKILL_TOKEN).toBe('real-tok');
});
});
// ─── Spawn integration ──────────────────────────────────────────
//
// Tests below shell out to `bun run` against a synthesized script.ts, so they
// take 1-3s each. Skip the suite if BUN_TEST_NO_SPAWN is set.
const SKIP_SPAWN = process.env.BUN_TEST_NO_SPAWN === '1';
describe.skipIf(SKIP_SPAWN)('spawnSkill: lifecycle', () => {
it('happy path: returns stdout, exit 0, token revoked', async () => {
const dir = makeSkillDir(tiers.bundled, 'echo-skill',
'name: echo-skill\nhost: x.com\ntrusted: true',
`console.log(JSON.stringify({ ok: true, args: process.argv.slice(2) }));`,
);
const skill = readBrowserSkill('echo-skill', tiers)!;
const result = await spawnSkill({
skill,
skillArgs: ['hello'],
trusted: true,
timeoutSeconds: 30,
port: 9999,
});
expect(result.exitCode).toBe(0);
expect(result.timedOut).toBe(false);
expect(result.truncated).toBe(false);
const parsed = JSON.parse(result.stdout);
expect(parsed.ok).toBe(true);
// Only --timeout filtering happens; -- is preserved by Bun.
expect(parsed.args).toContain('hello');
// Token revoked: nothing left in the registry for this client.
expect(listTokens().filter(t => t.clientId.startsWith('skill:echo-skill:'))).toEqual([]);
});
it('untrusted spawn: GSTACK_SKILL_TOKEN visible, root env scrubbed', async () => {
const dir = makeSkillDir(tiers.bundled, 'env-probe',
'name: env-probe\nhost: x.com', // trusted defaults to false
`console.log(JSON.stringify({
port: process.env.GSTACK_PORT,
token: process.env.GSTACK_SKILL_TOKEN,
home: process.env.HOME ?? null,
gh: process.env.GITHUB_TOKEN ?? null,
gstack: process.env.GSTACK_TOKEN ?? null,
}));`,
);
const origEnv = { ...process.env };
process.env.GITHUB_TOKEN = 'gh-secret';
process.env.GSTACK_TOKEN = 'root';
try {
const skill = readBrowserSkill('env-probe', tiers)!;
const result = await spawnSkill({
skill, skillArgs: [], trusted: false, timeoutSeconds: 30, port: 4242,
});
expect(result.exitCode).toBe(0);
const parsed = JSON.parse(result.stdout);
expect(parsed.port).toBe('4242');
expect(parsed.token).toMatch(/^gsk_sess_/);
expect(parsed.home).toBeNull();
expect(parsed.gh).toBeNull();
expect(parsed.gstack).toBeNull();
} finally {
// See afterEach comment in `buildSpawnEnv` describe — direct
// reassignment of process.env doesn't actually restore the
// underlying env in Bun. Delete + re-assign instead.
for (const k of Object.keys(process.env)) {
if (!(k in origEnv)) delete process.env[k];
}
for (const [k, v] of Object.entries(origEnv)) {
if (v !== undefined) process.env[k] = v;
}
}
});
it('trusted spawn: HOME passes through', async () => {
const dir = makeSkillDir(tiers.bundled, 'env-trusted',
'name: env-trusted\nhost: x.com\ntrusted: true',
`console.log(JSON.stringify({ home: process.env.HOME ?? null }));`,
);
const origEnv = { ...process.env };
process.env.HOME = '/Users/test-user';
try {
const skill = readBrowserSkill('env-trusted', tiers)!;
const result = await spawnSkill({
skill, skillArgs: [], trusted: true, timeoutSeconds: 30, port: 9999,
});
const parsed = JSON.parse(result.stdout);
expect(parsed.home).toBe('/Users/test-user');
} finally {
for (const k of Object.keys(process.env)) {
if (!(k in origEnv)) delete process.env[k];
}
for (const [k, v] of Object.entries(origEnv)) {
if (v !== undefined) process.env[k] = v;
}
}
});
it('timeout fires, exit code 124, token revoked', async () => {
const dir = makeSkillDir(tiers.bundled, 'sleeper',
'name: sleeper\nhost: x.com\ntrusted: true',
// The child's self-lifetime is a bound, not a wait — the test blocks
// only for the 1s spawn timeout that kills it. 8s is sized to be far
// above that 1s (the kill always lands first) but below this test's
// 10s ceiling: if the timeout-kill ever regresses, the child completes,
// prints "done", and the assertions below fail cleanly in-budget
// instead of the test opaquely timing out while the child lingers.
// (runToFiles gives skill children no stdin pipe, so a parent-death
// EOF lifetime isn't available here — self-timing is required.)
`await new Promise(r => setTimeout(r, 8000)); console.log("done");`,
);
const skill = readBrowserSkill('sleeper', tiers)!;
const result = await spawnSkill({
skill, skillArgs: [], trusted: true, timeoutSeconds: 1, port: 9999,
});
expect(result.timedOut).toBe(true);
expect(result.exitCode).toBe(124);
// The kill must land before the script completes — "done" ever appearing
// means the child outlived its timeout.
expect(result.stdout).not.toContain('done');
expect(listTokens().filter(t => t.clientId.startsWith('skill:sleeper:'))).toEqual([]);
}, 10_000);
it('script crash propagates nonzero exit', async () => {
const dir = makeSkillDir(tiers.bundled, 'crasher',
'name: crasher\nhost: x.com\ntrusted: true',
`process.exit(7);`,
);
const skill = readBrowserSkill('crasher', tiers)!;
const result = await spawnSkill({
skill, skillArgs: [], trusted: true, timeoutSeconds: 5, port: 9999,
});
expect(result.exitCode).toBe(7);
expect(result.timedOut).toBe(false);
});
it('stdout > 1MB truncates and reports truncated', async () => {
const dir = makeSkillDir(tiers.bundled, 'flood',
'name: flood\nhost: x.com\ntrusted: true',
// Emit ~2MB of "x" so the cap fires deterministically.
`const chunk = 'x'.repeat(64 * 1024);
for (let i = 0; i < 40; i++) process.stdout.write(chunk);`,
);
const skill = readBrowserSkill('flood', tiers)!;
const result = await spawnSkill({
skill, skillArgs: [], trusted: true, timeoutSeconds: 10, port: 9999,
});
expect(result.truncated).toBe(true);
expect(result.stdout.length).toBeLessThanOrEqual(1024 * 1024);
}, 10_000);
});
describe('subprocess capture goes through temp files, not pipes', () => {
// Tripwire. Capturing a child's output through `stdout: 'pipe'` is lossy
// here: under a loaded parent, the first piped spawn in the process
// intermittently yields an empty stderr even though the child wrote it and
// exited 0. Neither draining before awaiting exit nor a manual getReader()
// loop avoids it — both were measured losing the same bytes. It flaked
// `$B skill test` (a dropped stderr left only bun's banner) and would blank
// a skill's JSON result on `$B skill run` while still reporting success.
//
// runToFiles() points the child's fds at temp files instead, so the kernel
// has flushed everything by the time the child exits. This test fails if a
// refactor reintroduces pipe capture in this module.
//
// Comments are stripped first, so the module's own prose — which names the
// banned pattern in order to explain it — doesn't trip checks meant for code.
const src = fs.readFileSync(
path.join(import.meta.dir, '..', 'src', 'browser-skill-commands.ts'), 'utf-8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
it("does not spawn with stdout/stderr: 'pipe'", () => {
expect(src).not.toMatch(/std(out|err):\s*'pipe'/);
});
it('does not read child output via Response(proc.stdout/stderr) or getReader', () => {
expect(src).not.toMatch(/new Response\(\s*proc\.(stdout|stderr)/);
expect(src).not.toMatch(/proc\.(stdout|stderr)[\s\S]{0,40}getReader\(/);
});
it('every spawn site routes through runToFiles', () => {
// The structural invariant: runToFiles owns the module's only Bun.spawn,
// so any present or future spawn site inherits the file-based capture.
// Counted rather than name-checked so adding a spawn site that bypasses
// the helper fails here instead of silently reintroducing the bug.
expect(src.match(/Bun\.spawn\(/g) ?? []).toHaveLength(1);
expect((src.match(/await runToFiles\(/g) ?? []).length).toBeGreaterThanOrEqual(2);
});
});