browser-skill-commands.ts
466 lines · browse/src/browser-skill-commands.ts at 3f79d81
1/**
2 * $B skill subcommands — CLI surface for browser-skills.
3 *
4 * Subcommands:
5 * list — list all skills, with resolved tier
6 * show <name> — print skill SKILL.md
7 * run <name> [--arg ...] [--timeout=Ns] — spawn the skill script, return JSON
8 * test <name> — run script.test.ts via bun test
9 * rm <name> [--global] — tombstone a user-tier skill
10 *
11 * Load-bearing: spawnSkill mints a per-spawn scoped token (read+write scope)
12 * and passes it via GSTACK_SKILL_TOKEN. The skill never sees the daemon root
13 * token. Untrusted skills get a scrubbed env (no $HOME, $PATH minimal, no
14 * secrets like $GITHUB_TOKEN/$OPENAI_API_KEY/etc.) and a locked cwd. Trusted
15 * skills (frontmatter `trusted: true`) inherit the full process env.
16 *
17 * Output protocol: stdout = JSON, stderr = streaming logs, exit code 0/non-0.
18 * stdout cap = 1MB (truncate + nonzero exit if exceeded). Default timeout 60s.
19 */
21import * as fs from 'fs';
22import * as os from 'os';
23import * as path from 'path';
24import {
25 listBrowserSkills,
26 readBrowserSkill,
27 tombstoneBrowserSkill,
28 defaultTierPaths,
29 type BrowserSkill,
30 type TierPaths,
31} from './browser-skills';
32import { mintSkillToken, revokeSkillToken, generateSpawnId } from './skill-token';
34const DEFAULT_TIMEOUT_SECONDS = 60;
35const MAX_STDOUT_BYTES = 1024 * 1024; // 1 MB
37// ─── Public command dispatcher ──────────────────────────────────
39export interface SkillCommandContext {
40 /** Daemon port the skill should connect back to. */
41 port: number;
42 /** Optional override of tier paths (tests pass synthetic dirs). */
43 tiers?: TierPaths;
44}
46/**
47 * Dispatch a `$B skill <subcommand>` invocation. Returns the response string
48 * for the daemon to relay back to the CLI. Throws on invalid usage.
49 */
50export async function handleSkillCommand(args: string[], ctx: SkillCommandContext): Promise<string> {
51 const sub = args[0];
52 const rest = args.slice(1);
54 switch (sub) {
55 case undefined:
56 case 'help':
57 case '--help':
58 return formatUsage();
59 case 'list':
60 return handleList(ctx);
61 case 'show':
62 return handleShow(rest, ctx);
63 case 'run':
64 return handleRun(rest, ctx);
65 case 'test':
66 return handleTest(rest, ctx);
67 case 'rm':
68 return handleRm(rest, ctx);
69 default:
70 throw new Error(`Unknown skill subcommand: "${sub}". Try: list, show, run, test, rm.`);
71 }
72}
74function formatUsage(): string {
75 return [
76 'Usage: $B skill <subcommand>',
77 '',
78 ' list List all skills with resolved tier',
79 ' show <name> Print SKILL.md',
80 ' run <name> [--arg k=v]... [--timeout=Ns] Run the skill script',
81 ' test <name> Run script.test.ts',
82 ' rm <name> [--global] Tombstone a user-tier skill',
83 ].join('\n');
84}
86// ─── list ───────────────────────────────────────────────────────
88function handleList(ctx: SkillCommandContext): string {
89 const tiers = ctx.tiers ?? defaultTierPaths();
90 const skills = listBrowserSkills(tiers);
91 if (skills.length === 0) {
92 return 'No browser-skills found.\n\nTry: $B skill show <name> (none right now)\n';
93 }
94 const lines: string[] = ['NAME TIER HOST DESC'];
95 for (const s of skills) {
96 const desc = (s.frontmatter.description ?? '').slice(0, 40);
97 lines.push(
98 [
99 s.name.padEnd(30),
100 s.tier.padEnd(8),
101 s.frontmatter.host.padEnd(28),
102 desc,
103 ].join(' '),
104 );
105 }
106 return lines.join('\n') + '\n';
107}
109// ─── show ───────────────────────────────────────────────────────
111function handleShow(args: string[], ctx: SkillCommandContext): string {
112 const name = args[0];
113 if (!name) throw new Error('Usage: $B skill show <name>');
114 const tiers = ctx.tiers ?? defaultTierPaths();
115 const skill = readBrowserSkill(name, tiers);
116 if (!skill) throw new Error(`Skill "${name}" not found in any tier.`);
117 return readFile(path.join(skill.dir, 'SKILL.md'));
118}
120function readFile(p: string): string {
121 return fs.readFileSync(p, 'utf-8');
122}
124// ─── run ────────────────────────────────────────────────────────
126interface ParsedRunArgs {
127 passthrough: string[];
128 timeoutSeconds: number;
129}
131export function parseSkillRunArgs(args: string[]): ParsedRunArgs {
132 const passthrough: string[] = [];
133 let timeoutSeconds = DEFAULT_TIMEOUT_SECONDS;
134 for (let i = 0; i < args.length; i++) {
135 const a = args[i];
136 if (a.startsWith('--timeout=')) {
137 const n = parseInt(a.slice('--timeout='.length), 10);
138 if (!isNaN(n) && n > 0) timeoutSeconds = n;
139 continue;
140 }
141 passthrough.push(a);
142 }
143 return { passthrough, timeoutSeconds };
144}
146async function handleRun(args: string[], ctx: SkillCommandContext): Promise<string> {
147 const name = args[0];
148 if (!name) throw new Error('Usage: $B skill run <name> [--arg k=v]... [--timeout=Ns]');
149 const tiers = ctx.tiers ?? defaultTierPaths();
150 const skill = readBrowserSkill(name, tiers);
151 if (!skill) throw new Error(`Skill "${name}" not found.`);
153 const { passthrough, timeoutSeconds } = parseSkillRunArgs(args.slice(1));
154 const result = await spawnSkill({
155 skill,
156 skillArgs: passthrough,
157 trusted: skill.frontmatter.trusted,
158 timeoutSeconds,
159 port: ctx.port,
160 });
162 if (result.exitCode !== 0 || result.timedOut || result.truncated) {
163 const summary = result.truncated
164 ? `truncated stdout at ${MAX_STDOUT_BYTES} bytes`
165 : result.timedOut
166 ? `timed out after ${timeoutSeconds}s`
167 : `exit ${result.exitCode}`;
168 const err = new Error(`Skill "${name}" failed: ${summary}\n--- stderr ---\n${result.stderr.slice(0, 4096)}`);
169 (err as any).exitCode = result.exitCode || 1;
170 throw err;
171 }
172 return result.stdout;
173}
175// ─── test ───────────────────────────────────────────────────────
177async function handleTest(args: string[], ctx: SkillCommandContext): Promise<string> {
178 const name = args[0];
179 if (!name) throw new Error('Usage: $B skill test <name>');
180 const tiers = ctx.tiers ?? defaultTierPaths();
181 const skill = readBrowserSkill(name, tiers);
182 if (!skill) throw new Error(`Skill "${name}" not found.`);
184 const testFile = path.join(skill.dir, 'script.test.ts');
185 if (!fs.existsSync(testFile)) {
186 throw new Error(`Skill "${name}" has no script.test.ts at ${testFile}`);
187 }
189 const { stdout, stderr, exitCode } = await runToFiles(['bun', 'test', testFile], {
190 cwd: skill.dir,
191 env: process.env,
192 });
194 if (exitCode !== 0) {
195 throw new Error(`Skill "${name}" tests failed (exit ${exitCode}).\n${stderr || stdout}`);
196 }
198 // Return both streams, concatenated in bun's own layout (banner, blank line,
199 // summary). Picking one drops half the report, and the half callers assert on
200 // (the summary) is the half that lives on stderr.
201 const report = (stdout + stderr).trim();
202 if (!report) {
203 // A passing `bun test` always prints a summary, so exit 0 with no output at
204 // all means we failed to capture the run rather than that it went well.
205 // Say so instead of returning a synthetic "passed" that can't be verified.
206 throw new Error(`Skill "${name}" tests exited 0 but produced no output — the run was not captured.`);
207 }
208 return report + '\n';
209}
211interface RunToFilesOptions {
212 cwd: string;
213 env: Record<string, string> | NodeJS.ProcessEnv;
214 /** Kill the child after this many ms. Omit for no timeout. */
215 timeoutMs?: number;
216 /** Cap the captured stdout. Bytes past the cap are dropped, `truncated` set. */
217 maxStdoutBytes?: number;
218}
220interface RunToFilesResult {
221 stdout: string;
222 stderr: string;
223 exitCode: number;
224 timedOut: boolean;
225 truncated: boolean;
226}
228/**
229 * Run a command, capturing stdout/stderr by pointing the child's file
230 * descriptors at temp files rather than at pipes.
231 *
232 * Why not `stdout: 'pipe'`: under a loaded parent, the FIRST piped spawn in a
233 * process intermittently yields an empty stderr even though the child wrote it
234 * and exited 0. The data is lost inside Bun's async pipe plumbing, so neither
235 * draining before awaiting exit nor a manual `getReader()` loop avoids it —
236 * both were measured losing the same bytes in the same position. It surfaced in
237 * `$B skill test`, where `bun test` splits its report across streams (banner ->
238 * stdout, pass/fail summary -> stderr) so a dropped stderr silently degraded
239 * the result to just the banner; for `$B skill run` the same loss would blank
240 * the skill's JSON result and still look like success.
241 *
242 * Writing to files takes user-space streams out of the path: the kernel has
243 * flushed every byte by the time the child exits, so the post-exit read is
244 * always complete. It also removes the pipe-buffer stall risk on chatty
245 * children. `Bun.spawnSync` captures reliably too, but blocking the event loop
246 * is not an option here — a spawned skill calls back into this same daemon on
247 * GSTACK_PORT, so a synchronous wait would deadlock it.
248 */
249async function runToFiles(cmd: string[], opts: RunToFilesOptions): Promise<RunToFilesResult> {
250 const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-skill-'));
251 const outPath = path.join(dir, 'stdout');
252 const errPath = path.join(dir, 'stderr');
253 try {
254 // Hand Bun the destinations as BunFiles rather than raw fds we opened: Bun
255 // then owns the descriptors for the child's whole lifetime. Opening them
256 // here and closing them after exit instead put us in Bun's fd bookkeeping,
257 // which surfaced as a stray EBADF from epoll_ctl on a later spawn.
258 const proc = Bun.spawn(cmd, {
259 windowsHide: true,
260 cwd: opts.cwd,
261 env: opts.env as any,
262 stdout: Bun.file(outPath) as any,
263 stderr: Bun.file(errPath) as any,
264 });
266 let timedOut = false;
267 const killer = opts.timeoutMs === undefined ? undefined : setTimeout(() => {
268 timedOut = true;
269 try { proc.kill(); } catch {}
270 }, opts.timeoutMs);
272 const exitCode = await proc.exited;
273 if (killer !== undefined) clearTimeout(killer);
275 // The child's own writes are flushed by the kernel when it exits, so
276 // everything it wrote is readable here.
277 const cap = opts.maxStdoutBytes ?? Infinity;
278 const stdout = readCappedFile(outPath, cap);
279 const stderr = readCappedFile(errPath, cap);
280 return {
281 stdout: stdout.text,
282 stderr: stderr.text,
283 exitCode: timedOut ? 124 : exitCode,
284 timedOut,
285 truncated: stdout.truncated,
286 };
287 } finally {
288 fs.rmSync(dir, { recursive: true, force: true });
289 }
290}
292interface CappedRead { text: string; truncated: boolean; }
294/** Read at most `capBytes` from a file, reporting whether anything was dropped. */
295function readCappedFile(p: string, capBytes: number): CappedRead {
296 const size = fs.statSync(p).size;
297 if (size <= capBytes) return { text: fs.readFileSync(p, 'utf-8'), truncated: false };
298 const fd = fs.openSync(p, 'r');
299 try {
300 const buf = Buffer.alloc(capBytes);
301 const read = fs.readSync(fd, buf, 0, capBytes, 0);
302 return { text: buf.subarray(0, read).toString('utf-8'), truncated: true };
303 } finally {
304 try { fs.closeSync(fd); } catch {}
305 }
306}
308// ─── rm ─────────────────────────────────────────────────────────
310function handleRm(args: string[], ctx: SkillCommandContext): string {
311 const name = args[0];
312 if (!name) throw new Error('Usage: $B skill rm <name> [--global]');
313 const isGlobal = args.includes('--global');
314 const tier: 'project' | 'global' = isGlobal ? 'global' : 'project';
316 const tiers = ctx.tiers ?? defaultTierPaths();
317 // For UX: if no project tier exists at all, default to global.
318 const effectiveTier: 'project' | 'global' = (tier === 'project' && !tiers.project) ? 'global' : tier;
320 const dst = tombstoneBrowserSkill(name, effectiveTier, tiers);
321 return `Tombstoned "${name}" (${effectiveTier} tier) → ${dst}\n`;
322}
324// ─── spawnSkill (load-bearing) ──────────────────────────────────
326export interface SpawnSkillOptions {
327 skill: BrowserSkill;
328 skillArgs: string[];
329 trusted: boolean;
330 timeoutSeconds: number;
331 port: number;
332}
334export interface SpawnSkillResult {
335 stdout: string;
336 stderr: string;
337 exitCode: number;
338 timedOut: boolean;
339 truncated: boolean;
340}
342/**
343 * Spawn a skill script as a child process.
344 *
345 * 1. Mint a scoped token (read+write only; expires at timeout + 30s slack).
346 * 2. Build the env: trusted=true → process.env; trusted=false → scrubbed.
347 * GSTACK_PORT and GSTACK_SKILL_TOKEN are always set.
348 * 3. Spawn `bun run script.ts -- <args>` with cwd=skill.dir.
349 * 4. Capture stdout (capped at 1MB) and stderr; enforce timeout.
350 * 5. On exit/timeout, revoke the token. Always.
351 */
352export async function spawnSkill(opts: SpawnSkillOptions): Promise<SpawnSkillResult> {
353 const spawnId = generateSpawnId();
354 const tokenInfo = mintSkillToken({
355 skillName: opts.skill.name,
356 spawnId,
357 spawnTimeoutSeconds: opts.timeoutSeconds,
358 });
360 try {
361 const env = buildSpawnEnv({
362 trusted: opts.trusted,
363 port: opts.port,
364 skillToken: tokenInfo.token,
365 });
366 const scriptPath = path.join(opts.skill.dir, 'script.ts');
367 if (!fs.existsSync(scriptPath)) {
368 throw new Error(`Skill "${opts.skill.name}" missing script.ts at ${scriptPath}`);
369 }
371 // Captured via temp files, not pipes — see runToFiles for why. A dropped
372 // read here would blank the skill's JSON result and still report success.
373 return await runToFiles(['bun', 'run', scriptPath, '--', ...opts.skillArgs], {
374 cwd: opts.skill.dir,
375 env,
376 timeoutMs: opts.timeoutSeconds * 1000,
377 maxStdoutBytes: MAX_STDOUT_BYTES,
378 });
379 } finally {
380 revokeSkillToken(opts.skill.name, spawnId);
381 }
382}
384// ─── env construction (security-critical) ───────────────────────
386/**
387 * Env keys ALWAYS scrubbed for untrusted skills. These represent secrets,
388 * authority, or developer-environment context that an agent-authored script
389 * should not see.
390 */
391const SECRET_KEY_PATTERNS = [
392 /TOKEN/i, /KEY/i, /SECRET/i, /PASSWORD/i, /CREDENTIAL/i,
393 /^AWS_/, /^AZURE_/, /^GCP_/, /^GOOGLE_APPLICATION_/,
394 /^ANTHROPIC_/, /^OPENAI_/, /^GITHUB_/, /^GH_/,
395 /^SSH_/, /^GPG_/,
396 /^NPM_TOKEN/, /^PYPI_/,
397];
399/**
400 * Allowlist for untrusted spawns. Anything not in this list is dropped.
401 * Includes: minimal PATH, locale, terminal type. Skills get GSTACK_PORT +
402 * GSTACK_SKILL_TOKEN injected separately.
403 */
404const UNTRUSTED_ALLOWLIST = new Set([
405 'LANG', 'LC_ALL', 'LC_CTYPE',
406 'TERM',
407 'TZ',
408]);
410interface BuildEnvOptions {
411 trusted: boolean;
412 port: number;
413 skillToken: string;
414}
416export function buildSpawnEnv(opts: BuildEnvOptions): Record<string, string> {
417 const out: Record<string, string> = {};
419 if (opts.trusted) {
420 // Trusted: pass through process.env, but always strip the daemon root token
421 // if the parent had one in env (defense in depth).
422 for (const [k, v] of Object.entries(process.env)) {
423 if (v === undefined) continue;
424 if (k === 'GSTACK_TOKEN') continue; // never propagate root token
425 out[k] = v;
426 }
427 // Set a minimal PATH if missing.
428 if (!out.PATH) out.PATH = '/usr/local/bin:/usr/bin:/bin';
429 } else {
430 // Untrusted: minimal allowlist.
431 for (const k of UNTRUSTED_ALLOWLIST) {
432 const v = process.env[k];
433 if (v !== undefined) out[k] = v;
434 }
435 // Provide a minimal PATH so `bun` is findable. Prefer the resolved bun dir
436 // so scripts using a custom Bun install still work, but otherwise fall back
437 // to /usr/local/bin:/usr/bin:/bin.
438 out.PATH = resolveMinimalPath();
439 }
441 // Drop anything that pattern-matches a secret. (Trusted path can have secrets
442 // intentionally — e.g. an internal-tool skill — but we still strip GSTACK_TOKEN
443 // above.)
444 if (!opts.trusted) {
445 for (const k of Object.keys(out)) {
446 if (SECRET_KEY_PATTERNS.some(p => p.test(k))) delete out[k];
447 }
448 }
450 // Inject the daemon connection (always last so callers can't override).
451 out.GSTACK_PORT = String(opts.port);
452 out.GSTACK_SKILL_TOKEN = opts.skillToken;
454 return out;
455}
457function resolveMinimalPath(): string {
458 // Prefer the directory bun lives in; fall back to standard system dirs.
459 const fallback = '/usr/local/bin:/usr/bin:/bin';
460 const bunPath = process.execPath;
461 if (bunPath && bunPath.includes('/bun')) {
462 const dir = path.dirname(bunPath);
463 return `${dir}:${fallback}`;
464 }
465 return fallback;
466}