Skill Tracer
Skills › gstack › browse › meta-commands.ts

meta-commands.ts

1154 lines · browse/src/meta-commands.ts at 3f79d81

1/**
2 * Meta commands — tabs, server control, screenshots, chain, diff, snapshot
3 */
4
5import type { BrowserManager } from './browser-manager';
6import { handleSnapshot } from './snapshot';
7import { getCleanText } from './read-commands';
8import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent, canonicalizeCommand } from './commands';
9import { handleDomainSkillCommand } from './domain-skill-commands';
10import { handleSkillCommand } from './browser-skill-commands';
11import { validateNavigationUrl } from './url-validation';
12import { checkScope, type TokenInfo } from './token-registry';
13import { validateOutputPath, validateReadPath, SAFE_DIRECTORIES, escapeRegExp } from './path-security';
14import { guardScreenshotBuffer, guardScreenshotPath } from './screenshot-size-guard';
15import * as Diff from 'diff';
16import * as fs from 'fs';
17import * as path from 'path';
18import { writeSecureFile, mkdirSecure } from './file-permissions';
19import { TEMP_DIR } from './platform';
20import { resolveConfig } from './config';
21import { filterSessionCookies } from './session-persist';
22import type { Frame } from 'playwright';
24/** Tokenize a pipe segment respecting double-quoted strings. */
25function tokenizePipeSegment(segment: string): string[] {
26 const tokens: string[] = [];
27 let current = '';
28 let inQuote = false;
29 for (let i = 0; i < segment.length; i++) {
30 const ch = segment[i];
31 if (ch === '"') {
32 inQuote = !inQuote;
33 } else if (ch === ' ' && !inQuote) {
34 if (current) { tokens.push(current); current = ''; }
35 } else {
36 current += ch;
37 }
38 }
39 if (current) tokens.push(current);
40 return tokens;
43// ─── PDF flag parsing (make-pdf contract) ─────────────────────────────
44//
45// The $B pdf command grew from a 2-line wrapper (format: 'A4') into a real
46// PDF engine frontend. make-pdf/dist/pdf shells out to `browse pdf` with
47// this flag set, so the contract here has to be stable.
48//
49// Mutex rules enforced:
50// --format vs --width/--height
51// --margins vs any --margin-*
52// --page-numbers vs --footer-template (page-numbers writes the footer itself)
53//
54// Units for dimensions: "1in" | "72pt" | "25mm" | "2.54cm". Bare numbers
55// are interpreted as pixels (Playwright's default), which is almost never
56// what callers want — we warn but don't reject.
57//
58// Large payloads: header/footer HTML and custom CSS can exceed Windows'
59// 8191-char CreateProcess cap via argv. Callers pass `--from-file <path>`
60// to a JSON file holding the full options. make-pdf always uses this path.
61interface ParsedPdfArgs {
62 output: string;
63 format?: string;
64 width?: string;
65 height?: string;
66 marginTop?: string;
67 marginRight?: string;
68 marginBottom?: string;
69 marginLeft?: string;
70 headerTemplate?: string;
71 footerTemplate?: string;
72 pageNumbers?: boolean;
73 tagged?: boolean;
74 outline?: boolean;
75 printBackground?: boolean;
76 preferCSSPageSize?: boolean;
77 toc?: boolean;
80function parsePdfArgs(args: string[]): ParsedPdfArgs {
81 // --from-file short-circuits argv parsing entirely
82 for (let i = 0; i < args.length; i++) {
83 if (args[i] === '--from-file') {
84 const payloadPath = args[++i];
85 if (!payloadPath) throw new Error('pdf: --from-file requires a path');
86 return parsePdfFromFile(payloadPath);
87 }
88 }
90 const result: ParsedPdfArgs = {
91 output: `${TEMP_DIR}/browse-page.pdf`,
92 };
94 let margins: string | undefined;
95 const positional: string[] = [];
97 for (let i = 0; i < args.length; i++) {
98 const a = args[i];
99 if (a === '--format') { result.format = requireValue(args, ++i, 'format'); }
100 else if (a === '--page-size') { result.format = requireValue(args, ++i, 'page-size'); }
101 else if (a === '--width') { result.width = requireValue(args, ++i, 'width'); }
102 else if (a === '--height') { result.height = requireValue(args, ++i, 'height'); }
103 else if (a === '--margins') { margins = requireValue(args, ++i, 'margins'); }
104 else if (a === '--margin-top') { result.marginTop = requireValue(args, ++i, 'margin-top'); }
105 else if (a === '--margin-right') { result.marginRight = requireValue(args, ++i, 'margin-right'); }
106 else if (a === '--margin-bottom') { result.marginBottom = requireValue(args, ++i, 'margin-bottom'); }
107 else if (a === '--margin-left') { result.marginLeft = requireValue(args, ++i, 'margin-left'); }
108 else if (a === '--header-template') { result.headerTemplate = requireValue(args, ++i, 'header-template'); }
109 else if (a === '--footer-template') { result.footerTemplate = requireValue(args, ++i, 'footer-template'); }
110 else if (a === '--page-numbers') { result.pageNumbers = true; }
111 else if (a === '--tagged') { result.tagged = true; }
112 else if (a === '--outline') { result.outline = true; }
113 else if (a === '--print-background') { result.printBackground = true; }
114 else if (a === '--prefer-css-page-size') { result.preferCSSPageSize = true; }
115 else if (a === '--toc') { result.toc = true; }
116 else if (a.startsWith('--')) { throw new Error(`Unknown pdf flag: ${a}`); }
117 else { positional.push(a); }
118 }
120 if (positional.length > 0) result.output = positional[0];
122 if (margins !== undefined) {
123 if (result.marginTop || result.marginRight || result.marginBottom || result.marginLeft) {
124 throw new Error('pdf: --margins is mutex with --margin-top/--margin-right/--margin-bottom/--margin-left');
125 }
126 result.marginTop = result.marginRight = result.marginBottom = result.marginLeft = margins;
127 }
129 if (result.format && (result.width || result.height)) {
130 throw new Error('pdf: --format is mutex with --width/--height');
131 }
132 if (result.pageNumbers && result.footerTemplate) {
133 throw new Error('pdf: --page-numbers is mutex with --footer-template (page-numbers writes the footer itself)');
134 }
136 return result;
139export function parsePdfFromFile(payloadPath: string): ParsedPdfArgs {
140 // Parity with load-html --from-file (browse/src/write-commands.ts) and
141 // the direct load-html <file> path: every caller-supplied file path
142 // must pass validateReadPath so the safe-dirs policy can't be skirted
143 // by routing reads through the --from-file shortcut.
144 try {
145 validateReadPath(path.resolve(payloadPath));
146 } catch {
147 throw new Error(
148 `pdf: --from-file ${payloadPath} must be under ${SAFE_DIRECTORIES.join(' or ')} (security policy). Copy the payload into the project tree or /tmp first.`
149 );
150 }
151 const raw = fs.readFileSync(payloadPath, 'utf8');
152 let json: any;
153 try {
154 json = JSON.parse(raw);
155 } catch (err) {
156 const msg = err instanceof Error ? err.message : String(err);
157 throw new Error(`pdf: --from-file ${payloadPath} is not valid JSON (${msg}).`);
158 }
159 if (json === null || typeof json !== 'object' || Array.isArray(json)) {
160 throw new Error(`pdf: --from-file ${payloadPath} must be a JSON object, got ${Array.isArray(json) ? 'array' : typeof json}.`);
161 }
162 const out: ParsedPdfArgs = {
163 output: json.output || `${TEMP_DIR}/browse-page.pdf`,
164 format: json.format,
165 width: json.width,
166 height: json.height,
167 marginTop: json.marginTop,
168 marginRight: json.marginRight,
169 marginBottom: json.marginBottom,
170 marginLeft: json.marginLeft,
171 headerTemplate: json.headerTemplate,
172 footerTemplate: json.footerTemplate,
173 pageNumbers: json.pageNumbers === true,
174 tagged: json.tagged === true,
175 outline: json.outline === true,
176 printBackground: json.printBackground === true,
177 preferCSSPageSize: json.preferCSSPageSize === true,
178 toc: json.toc === true,
179 };
180 return out;
183function requireValue(args: string[], i: number, flag: string): string {
184 const v = args[i];
185 if (v === undefined || v.startsWith('--')) {
186 throw new Error(`pdf: --${flag} requires a value`);
187 }
188 return v;
191function buildPdfOptions(parsed: ParsedPdfArgs): Record<string, unknown> {
192 const opts: Record<string, unknown> = {};
194 // Page size
195 if (parsed.format) {
196 opts.format = parsed.format.charAt(0).toUpperCase() + parsed.format.slice(1).toLowerCase();
197 } else if (parsed.width && parsed.height) {
198 opts.width = parsed.width;
199 opts.height = parsed.height;
200 } else {
201 opts.format = 'Letter';
202 }
204 // Margins
205 const margin: Record<string, string> = {};
206 if (parsed.marginTop) margin.top = parsed.marginTop;
207 if (parsed.marginRight) margin.right = parsed.marginRight;
208 if (parsed.marginBottom) margin.bottom = parsed.marginBottom;
209 if (parsed.marginLeft) margin.left = parsed.marginLeft;
210 if (Object.keys(margin).length > 0) opts.margin = margin;
212 // Header/footer
213 const displayHeaderFooter =
214 !!parsed.headerTemplate || !!parsed.footerTemplate || parsed.pageNumbers === true;
215 if (displayHeaderFooter) {
216 opts.displayHeaderFooter = true;
217 // Provide minimum empty templates when only one is set, otherwise Chromium
218 // emits its default ugly URL/date in the other slot.
219 if (parsed.headerTemplate !== undefined) opts.headerTemplate = parsed.headerTemplate;
220 else if (parsed.pageNumbers || parsed.footerTemplate) opts.headerTemplate = '<div></div>';
222 if (parsed.pageNumbers) {
223 opts.footerTemplate = [
224 '<div style="font-size:9pt; font-family:Helvetica,Arial,sans-serif; color:#666; ',
225 'width:100%; text-align:center;">',
226 '<span class="pageNumber"></span> of <span class="totalPages"></span>',
227 '</div>',
228 ].join('');
229 } else if (parsed.footerTemplate !== undefined) {
230 opts.footerTemplate = parsed.footerTemplate;
231 } else {
232 opts.footerTemplate = '<div></div>';
233 }
234 }
236 if (parsed.tagged === true) opts.tagged = true;
237 if (parsed.outline === true) opts.outline = true;
238 if (parsed.printBackground === true) opts.printBackground = true;
239 if (parsed.preferCSSPageSize === true) opts.preferCSSPageSize = true;
241 return opts;
244/** Options passed from handleCommandInternal for chain routing */
245export interface MetaCommandOpts {
246 chainDepth?: number;
247 /** Callback to route subcommands through the full security pipeline (handleCommandInternal) */
248 executeCommand?: (body: { command: string; args?: string[]; tabId?: number }, tokenInfo?: TokenInfo | null) => Promise<{ status: number; result: string; json?: boolean }>;
249 /** The port the daemon is listening on (needed by `$B skill run` to point spawned scripts at the daemon). */
250 daemonPort?: number;
253export async function handleMetaCommand(
254 command: string,
255 args: string[],
256 bm: BrowserManager,
257 shutdown: () => Promise<void> | void,
258 tokenInfo?: TokenInfo | null,
259 opts?: MetaCommandOpts,
260): Promise<string> {
261 // Per-tab operations read the active session where they run, so status,
262 // stop and restart still answer when every tab was closed (#3167).
263 switch (command) {
264 // ─── Tabs ──────────────────────────────────────────
265 case 'tabs': {
266 const tabs = await bm.getTabListWithTitles();
267 return tabs.map(t =>
268 `${t.active ? '→ ' : ' '}[${t.id}] ${t.busy ? '(not responding)' : t.title || '(untitled)'} — ${t.url}`
269 ).join('\n');
270 }
272 case 'tab': {
273 const id = parseInt(args[0], 10);
274 if (isNaN(id)) throw new Error('Usage: browse tab <id>');
275 bm.switchTab(id);
276 return `Switched to tab ${id}`;
277 }
279 case 'newtab': {
280 // --json returns structured output (machine-parseable). Other flag-like
281 // tokens are treated as the url. make-pdf always passes --json.
282 let url: string | undefined;
283 let jsonMode = false;
284 for (const a of args) {
285 if (a === '--json') { jsonMode = true; }
286 else if (!url) { url = a; }
287 }
288 const id = await bm.newTab(url);
289 if (jsonMode) {
290 return JSON.stringify({ tabId: id, url: url ?? null });
291 }
292 return `Opened tab ${id}${url ? ` → ${url}` : ''}`;
293 }
295 case 'closetab': {
296 const id = args[0] ? parseInt(args[0], 10) : undefined;
297 await bm.closeTab(id);
298 return `Closed tab${id ? ` ${id}` : ''}`;
299 }
301 case 'tab-each': {
302 // Fan out a single command across every open tab. Returns a JSON
303 // object: { results: [{tabId, url, title, status, output}], total }.
304 // Restores the originally active tab when done so the user's view
305 // doesn't shift under them.
306 //
307 // Usage: $B tab-each <command> [args...]
308 // $B tab-each snapshot -i → snapshot every tab
309 // $B tab-each text → grab clean text from every tab
310 // $B tab-each goto https://x.y → load the same URL in every tab
311 if (args.length === 0) {
312 throw new Error(
313 'Usage: browse tab-each <command> [args...]\n' +
314 'Example: browse tab-each snapshot -i'
315 );
316 }
318 const innerRaw = args[0];
319 const innerName = canonicalizeCommand(innerRaw);
320 const innerArgs = args.slice(1);
322 // Scope check the inner command before fanning out, so a single
323 // permission failure aborts the whole batch instead of partially
324 // mutating tabs.
325 if (tokenInfo && tokenInfo.clientId !== 'root' && !checkScope(tokenInfo, innerName)) {
326 throw new Error(
327 `tab-each rejected: subcommand "${innerRaw}" not allowed by your token scope (${tokenInfo.scopes.join(', ')}).`
328 );
329 }
331 const tabs = await bm.getTabListWithTitles();
332 const originalActive = tabs.find(t => t.active)?.id ?? bm.getActiveTabId();
334 const executeCmd = opts?.executeCommand;
335 const results: Array<{
336 tabId: number;
337 url: string;
338 title: string;
339 status: number;
340 output: string;
341 }> = [];
343 try {
344 for (const tab of tabs) {
345 // Skip chrome:// internal pages — they aren't useful targets and
346 // many commands fail outright on them.
347 if (tab.url.startsWith('chrome://') || tab.url.startsWith('chrome-extension://')) {
348 results.push({
349 tabId: tab.id,
350 url: tab.url,
351 title: tab.title || '',
352 status: 0,
353 output: 'skipped: internal page',
354 });
355 continue;
356 }
357 // Switch to the tab. Don't pull focus away — we're a background
358 // operation; the user shouldn't see the OS window jump.
359 bm.switchTab(tab.id, { bringToFront: false });
361 let status = 0;
362 let output = '';
363 if (executeCmd) {
364 const r = await executeCmd(
365 { command: innerName, args: innerArgs, tabId: tab.id },
366 tokenInfo,
367 );
368 status = r.status;
369 output = r.result;
370 if (status !== 200) {
371 try { output = JSON.parse(output).error || output; } catch (err: any) { if (!(err instanceof SyntaxError)) throw err; }
372 }
373 } else {
374 // Fallback path (CLI / test harness without a server context).
375 // We don't recurse through read/write/meta directly here because
376 // tab-each is only meaningful with the live server; surface a
377 // clear error.
378 status = 500;
379 output = 'tab-each requires the browse server (no executeCommand context)';
380 }
382 results.push({
383 tabId: tab.id,
384 url: tab.url,
385 title: tab.title || '',
386 status,
387 output,
388 });
389 }
390 } finally {
391 // Restore the original active tab so the user's view is unchanged.
392 try { bm.switchTab(originalActive, { bringToFront: false }); } catch {}
393 }
395 return JSON.stringify({
396 command: innerName,
397 args: innerArgs,
398 total: results.length,
399 results,
400 }, null, 2);
401 }
403 // ─── Server Control ────────────────────────────────
404 case 'status': {
405 const tabs = bm.getTabCount();
406 const mode = bm.getConnectionMode();
407 const activeTab = await bm.probeActiveTab();
408 return [
409 `Status: healthy`,
410 `Mode: ${mode}`,
411 `URL: ${bm.getCurrentUrl()}`,
412 `Tabs: ${tabs}`,
413 `PID: ${process.pid}`,
414 // #3247: name the busy tab and the ways out that keep the session.
415 ...(activeTab && !activeTab.responsive
416 ? [`Active tab ${activeTab.id}: not responding. Switch with: browse tab <id>, or close it: browse closetab ${activeTab.id}`]
417 : []),
418 ].join('\n');
419 }
421 case 'url': {
422 return bm.getCurrentUrl();
423 }
425 case 'stop': {
426 // Return the acknowledgement before closing the listener. Shutting down
427 // inline resets the CLI's fetch, which it reasonably interprets as a
428 // crash and then restarts the daemon it was asked to stop.
429 setTimeout(() => { void shutdown(); }, 25).unref?.();
430 return 'Server stopped';
431 }
433 case 'restart': {
434 // Signal that we want a restart — the CLI will detect exit and restart
435 console.log('[browse] Restart requested. Exiting for CLI to restart.');
436 setTimeout(() => { void shutdown(); }, 25).unref?.();
437 return 'Restarting...';
438 }
440 // ─── Visual ────────────────────────────────────────
441 case 'screenshot': {
442 // Parse priority: flags (--viewport, --clip, --base64) → selector (@ref, CSS) → output path
443 const page = bm.getPage();
444 let outputPath = `${TEMP_DIR}/browse-screenshot.png`;
445 let clipRect: { x: number; y: number; width: number; height: number } | undefined;
446 let targetSelector: string | undefined;
447 let viewportOnly = false;
448 let base64Mode = false;
450 const remaining: string[] = [];
451 let flagSelector: string | undefined;
452 for (let i = 0; i < args.length; i++) {
453 const inline = /^(--clip|--selector)=(.*)$/s.exec(args[i]);
454 const flag = inline ? inline[1] : args[i];
455 const flagValue = () => (inline ? inline[2] : args[++i]);
456 if (flag === '--viewport') {
457 viewportOnly = true;
458 } else if (flag === '--base64') {
459 base64Mode = true;
460 } else if (flag === '--selector') {
461 flagSelector = flagValue();
462 if (!flagSelector) throw new Error('Usage: screenshot --selector <css> [path]');
463 } else if (flag === '--clip') {
464 const coords = flagValue();
465 if (!coords) throw new Error('Usage: screenshot --clip x,y,w,h [path]');
466 const parts = coords.split(',').map(Number);
467 if (parts.length !== 4 || parts.some(isNaN))
468 throw new Error('Usage: screenshot --clip x,y,width,height — all must be numbers');
469 clipRect = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
470 } else if (args[i].startsWith('--')) {
471 throw new Error(`Unknown screenshot flag: ${args[i]}`);
472 } else {
473 remaining.push(args[i]);
474 }
475 }
477 // Separate target (selector/@ref) from output path
478 for (const arg of remaining) {
479 // File paths containing / and ending with an image/pdf extension are never CSS selectors
480 const isFilePath = arg.includes('/') && /\.(png|jpe?g|webp|pdf)$/i.test(arg);
481 if (isFilePath) {
482 outputPath = arg;
483 } else if (arg.startsWith('@e') || arg.startsWith('@c') || arg.startsWith('.') || arg.startsWith('#') || arg.includes('[')) {
484 targetSelector = arg;
485 } else {
486 outputPath = arg;
487 }
488 }
490 // --selector flag takes precedence; conflict with positional selector.
491 if (flagSelector !== undefined) {
492 if (targetSelector !== undefined) {
493 throw new Error('--selector conflicts with positional selector — choose one');
494 }
495 targetSelector = flagSelector;
496 }
498 validateOutputPath(outputPath);
500 if (clipRect && targetSelector) {
501 throw new Error('Cannot use --clip with a selector/ref — choose one');
502 }
503 if (viewportOnly && clipRect) {
504 throw new Error('Cannot use --viewport with --clip — choose one');
505 }
507 // --base64 mode: capture to buffer instead of disk
508 if (base64Mode) {
509 let buffer: Buffer;
510 if (targetSelector) {
511 const resolved = await bm.resolveRef(targetSelector);
512 const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
513 buffer = await locator.screenshot({ timeout: 5000 });
514 } else if (clipRect) {
515 buffer = await page.screenshot({ clip: clipRect });
516 } else {
517 buffer = await page.screenshot({ fullPage: !viewportOnly });
518 // Guard the most common API-bricking case (fullPage). Element /
519 // clip captures usually stay within the cap; we still guard the
520 // path-mode below for fullPage writes.
521 ({ buffer } = await guardScreenshotBuffer(buffer));
522 }
523 if (buffer.length > 10 * 1024 * 1024) {
524 throw new Error('Screenshot too large for --base64 (>10MB). Use disk path instead.');
525 }
526 return `data:image/png;base64,${buffer.toString('base64')}`;
527 }
529 if (targetSelector) {
530 const resolved = await bm.resolveRef(targetSelector);
531 const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
532 await locator.screenshot({ path: outputPath, timeout: 5000 });
533 return `Screenshot saved (element): ${outputPath}`;
534 }
536 if (clipRect) {
537 await page.screenshot({ path: outputPath, clip: clipRect });
538 return `Screenshot saved (clip ${clipRect.x},${clipRect.y},${clipRect.width},${clipRect.height}): ${outputPath}`;
539 }
541 await page.screenshot({ path: outputPath, fullPage: !viewportOnly });
542 if (!viewportOnly) await guardScreenshotPath(outputPath);
543 return `Screenshot saved${viewportOnly ? ' (viewport)' : ''}: ${outputPath}`;
544 }
546 case 'pdf': {
547 const page = bm.getPage();
548 const parsed = parsePdfArgs(args);
549 validateOutputPath(parsed.output);
551 // If --toc: wait up to 3s for Paged.js to signal by setting
552 // window.__pagedjsAfterFired = true. If the polyfill isn't injected
553 // (make-pdf v1 ships without Paged.js; TOC renders without page
554 // numbers), we fall through silently — callers that require strict
555 // TOC pagination should pass --require-paged-js too.
556 if (parsed.toc) {
557 const deadline = Date.now() + 3000;
558 let ready = false;
559 while (Date.now() < deadline) {
560 try {
561 ready = await page.evaluate('!!window.__pagedjsAfterFired');
562 } catch { /* tab may still be hydrating */ }
563 if (ready) break;
564 await new Promise(r => setTimeout(r, 150));
565 }
566 // Intentionally non-fatal. Paged.js is optional in v1.
567 }
569 const opts = buildPdfOptions(parsed);
570 opts.path = parsed.output;
571 await page.pdf(opts);
573 return `PDF saved: ${parsed.output}`;
574 }
576 case 'responsive': {
577 const page = bm.getPage();
578 const prefix = args[0] || `${TEMP_DIR}/browse-responsive`;
579 validateOutputPath(prefix);
580 const viewports = [
581 { name: 'mobile', width: 375, height: 812 },
582 { name: 'tablet', width: 768, height: 1024 },
583 { name: 'desktop', width: 1280, height: 720 },
584 ];
585 const originalViewport = page.viewportSize();
586 const results: string[] = [];
588 for (const vp of viewports) {
589 await page.setViewportSize({ width: vp.width, height: vp.height });
590 const screenshotPath = `${prefix}-${vp.name}.png`;
591 validateOutputPath(screenshotPath);
592 await page.screenshot({ path: screenshotPath, fullPage: true });
593 await guardScreenshotPath(screenshotPath);
594 results.push(`${vp.name} (${vp.width}x${vp.height}): ${screenshotPath}`);
595 }
597 // Restore original viewport
598 if (originalViewport) {
599 await page.setViewportSize(originalViewport);
600 }
602 return results.join('\n');
603 }
605 // ─── Chain ─────────────────────────────────────────
606 case 'chain': {
607 // Read JSON array from args[0] (if provided) or expect it was passed as body
608 const jsonStr = args[0];
609 if (!jsonStr) throw new Error(
610 'Usage: echo \'[["goto","url"],["text"]]\' | browse chain\n' +
611 ' or: browse chain \'goto url | click @e5 | snapshot -ic\''
612 );
614 let rawCommands: string[][];
615 try {
616 rawCommands = JSON.parse(jsonStr);
617 if (!Array.isArray(rawCommands)) throw new Error('not array');
618 } catch (err: any) {
619 // Fallback: pipe-delimited format "goto url | click @e5 | snapshot -ic"
620 if (!(err instanceof SyntaxError) && err?.message !== 'not array') throw err;
621 rawCommands = jsonStr.split(' | ')
622 .filter(seg => seg.trim().length > 0)
623 .map(seg => tokenizePipeSegment(seg.trim()));
624 }
626 // Canonicalize aliases across the whole chain. Pair canonical name with the raw
627 // input so result labels + error messages reflect what the user typed, but every
628 // dispatch path (scope check, WRITE_COMMANDS.has, watch blocking, handler lookup)
629 // uses the canonical name. Otherwise `chain '[["setcontent","/tmp/x.html"]]'`
630 // bypasses prevalidation or runs under the wrong command set.
631 const commands = rawCommands.map(cmd => {
632 const [rawName, ...cmdArgs] = cmd;
633 const name = canonicalizeCommand(rawName);
634 return { rawName, name, args: cmdArgs };
635 });
637 // Pre-validate ALL subcommands against the token's scope before executing any.
638 // Uses canonical name so aliases don't bypass scope checks.
639 if (tokenInfo && tokenInfo.clientId !== 'root') {
640 for (const c of commands) {
641 if (!checkScope(tokenInfo, c.name)) {
642 throw new Error(
643 `Chain rejected: subcommand "${c.rawName}" not allowed by your token scope (${tokenInfo.scopes.join(', ')}). ` +
644 `All subcommands must be within scope.`
645 );
646 }
647 }
648 }
650 // Route each subcommand through handleCommandInternal for full security:
651 // scope, domain, tab ownership, content wrapping — all enforced per subcommand.
652 // Chain-specific options: skip rate check (chain = 1 request), skip activity
653 // events (chain emits 1 event), increment chain depth (recursion guard).
654 const executeCmd = opts?.executeCommand;
655 const results: string[] = [];
656 let lastWasWrite = false;
658 if (executeCmd) {
659 // Full security pipeline via handleCommandInternal.
660 // Pass rawName so the server's own canonicalization is a no-op (already canonical).
661 for (const c of commands) {
662 const cr = await executeCmd(
663 { command: c.name, args: c.args },
664 tokenInfo,
665 );
666 const label = c.rawName === c.name ? c.name : `${c.rawName}→${c.name}`;
667 if (cr.status === 200) {
668 results.push(`[${label}] ${cr.result}`);
669 } else {
670 // Parse error from JSON result
671 let errMsg = cr.result;
672 try { errMsg = JSON.parse(cr.result).error || cr.result; } catch (err: any) { if (!(err instanceof SyntaxError)) throw err; }
673 results.push(`[${label}] ERROR: ${errMsg}`);
674 }
675 lastWasWrite = WRITE_COMMANDS.has(c.name);
676 }
677 } else {
678 // No fallback dispatcher. The old direct-dispatch branch here
679 // re-implemented command routing WITHOUT the server pipeline's
680 // security gates (scope, domain, tab ownership, rate limit, hidden
681 // element stripping, scoped-token enveloping, JS-origin assertion).
682 // It was unreachable in production (server.ts always passes
683 // executeCommand) and one boolean away from being live.
684 throw new Error('chain requires the browse server (no executeCommand context)');
685 }
687 // Wait for network to settle after write commands before returning
688 if (lastWasWrite) {
689 await bm.getPage().waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
690 }
692 return results.join('\n\n');
693 }
695 // ─── Diff ──────────────────────────────────────────
696 case 'diff': {
697 const [url1, url2] = args;
698 if (!url1 || !url2) throw new Error('Usage: browse diff <url1> <url2>');
700 const page = bm.getPage();
701 const normalizedUrl1 = await validateNavigationUrl(url1);
702 await page.goto(normalizedUrl1, { waitUntil: 'domcontentloaded', timeout: 15000 });
703 const text1 = await getCleanText(page);
705 const normalizedUrl2 = await validateNavigationUrl(url2);
706 await page.goto(normalizedUrl2, { waitUntil: 'domcontentloaded', timeout: 15000 });
707 const text2 = await getCleanText(page);
709 const changes = Diff.diffLines(text1, text2);
710 const output: string[] = [`--- ${url1}`, `+++ ${url2}`, ''];
712 for (const part of changes) {
713 const prefix = part.added ? '+' : part.removed ? '-' : ' ';
714 const lines = part.value.split('\n').filter(l => l.length > 0);
715 for (const line of lines) {
716 output.push(`${prefix} ${line}`);
717 }
718 }
720 return wrapUntrustedContent(output.join('\n'), `diff: ${url1} vs ${url2}`);
721 }
723 // ─── Snapshot ─────────────────────────────────────
724 case 'snapshot': {
725 const isScoped = tokenInfo && tokenInfo.clientId !== 'root';
726 const snapshotResult = await handleSnapshot(args, bm.getActiveSession(), {
727 splitForScoped: !!isScoped,
728 });
729 // Scoped tokens get split format (refs outside envelope); root gets basic wrapping
730 if (isScoped) {
731 return snapshotResult; // already has envelope from split format
732 }
733 return wrapUntrustedContent(snapshotResult, bm.getCurrentUrl());
734 }
736 // ─── Handoff ────────────────────────────────────
737 case 'handoff': {
738 const message = args.join(' ') || 'User takeover requested';
739 return await bm.handoff(message);
740 }
742 case 'resume': {
743 const session = bm.getActiveSession();
744 bm.resume();
745 // Re-snapshot to capture current page state after human interaction
746 const isScoped2 = tokenInfo && tokenInfo.clientId !== 'root';
747 const snapshot = await handleSnapshot(['-i'], session, { splitForScoped: !!isScoped2 });
748 if (isScoped2) {
749 return `RESUMED\n${snapshot}`;
750 }
751 return `RESUMED\n${wrapUntrustedContent(snapshot, bm.getCurrentUrl())}`;
752 }
754 // ─── Headed Mode ──────────────────────────────────────
755 case 'connect': {
756 // connect is handled as a pre-server command in cli.ts
757 // If we get here, server is already running — tell the user
758 if (bm.getConnectionMode() === 'headed') {
759 return 'Already in headed mode with extension.';
760 }
761 return 'The connect command must be run from the CLI (not sent to a running server). Run: $B connect';
762 }
764 case 'disconnect': {
765 if (bm.getConnectionMode() !== 'headed') {
766 return 'Not in headed mode — nothing to disconnect.';
767 }
768 // Signal that we want a restart in headless mode
769 console.log('[browse] Disconnecting headed browser. Restarting in headless mode.');
770 await shutdown();
771 return 'Disconnected. Server will restart in headless mode on next command.';
772 }
774 case 'focus': {
775 if (bm.getConnectionMode() !== 'headed') {
776 return 'focus requires headed mode. Run `$B connect` first.';
777 }
778 try {
779 const { execSync } = await import('child_process');
780 // Try common Chromium-based browser app names to bring to foreground
781 const appNames = ['Comet', 'Google Chrome', 'Arc', 'Brave Browser', 'Microsoft Edge'];
782 let activated = false;
783 for (const appName of appNames) {
784 try {
785 execSync(`osascript -e 'tell application "${appName}" to activate'`, { stdio: 'pipe', timeout: 3000, windowsHide: true });
786 activated = true;
787 break;
788 } catch (err: any) {
789 // Try next browser — osascript fails if app not found or AppleScript errors
790 if (err?.status === undefined && !err?.message?.includes('Command failed')) throw err;
791 }
792 }
794 if (!activated) {
795 return 'Could not bring browser to foreground. macOS only.';
796 }
798 // If a ref was passed, scroll it into view
799 if (args.length > 0 && args[0].startsWith('@')) {
800 try {
801 const resolved = await bm.resolveRef(args[0]);
802 if ('locator' in resolved) {
803 await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 });
804 return `Browser activated. Scrolled ${args[0]} into view.`;
805 }
806 } catch (err: any) {
807 // Ref not found or element gone — still activated the browser
808 if (!err?.message?.includes('not found') && !err?.message?.includes('closed') && !err?.message?.includes('Target') && !err?.message?.includes('timeout')) throw err;
809 }
810 }
812 return 'Browser window activated.';
813 } catch (err: any) {
814 return `focus failed: ${err.message}. macOS only.`;
815 }
816 }
818 // ─── Watch ──────────────────────────────────────────
819 case 'watch': {
820 if (args[0] === 'stop') {
821 if (!bm.isWatching()) return 'Not currently watching.';
822 const result = bm.stopWatch();
823 const durationSec = Math.round(result.duration / 1000);
824 const lastSnapshot = result.snapshots.length > 0
825 ? wrapUntrustedContent(result.snapshots[result.snapshots.length - 1], bm.getCurrentUrl())
826 : '(none)';
827 return [
828 `WATCH STOPPED (${durationSec}s, ${result.snapshots.length} snapshots)`,
829 '',
830 'Last snapshot:',
831 lastSnapshot,
832 ].join('\n');
833 }
835 if (bm.isWatching()) return 'Already watching. Run `$B watch stop` to stop.';
836 if (bm.getConnectionMode() !== 'headed') {
837 return 'watch requires headed mode. Run `$B connect` first.';
838 }
840 bm.startWatch();
841 return 'WATCHING — observing user browsing. Periodic snapshots every 5s.\nRun `$B watch stop` to stop and get summary.';
842 }
844 // ─── Inbox ──────────────────────────────────────────
845 case 'inbox': {
846 const { execSync } = await import('child_process');
847 let gitRoot: string;
848 try {
849 gitRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }).trim();
850 } catch (err: any) {
851 // execSync throws with exit status on non-git directories
852 if (err?.status === undefined && !err?.message?.includes('Command failed')) throw err;
853 return 'Not in a git repository — cannot locate inbox.';
854 }
856 const inboxDir = path.join(gitRoot, '.context', 'sidebar-inbox');
857 if (!fs.existsSync(inboxDir)) return 'Inbox empty.';
859 const files = fs.readdirSync(inboxDir)
860 .filter(f => f.endsWith('.json') && !f.startsWith('.'))
861 .sort()
862 .reverse(); // newest first
864 if (files.length === 0) return 'Inbox empty.';
866 const messages: { timestamp: string; url: string; userMessage: string }[] = [];
867 for (const file of files) {
868 try {
869 const data = JSON.parse(fs.readFileSync(path.join(inboxDir, file), 'utf-8'));
870 messages.push({
871 timestamp: data.timestamp || '',
872 url: data.page?.url || 'unknown',
873 userMessage: data.userMessage || '',
874 });
875 } catch (err: any) {
876 // Skip malformed JSON or unreadable files
877 if (!(err instanceof SyntaxError) && err?.code !== 'ENOENT' && err?.code !== 'EACCES') throw err;
878 }
879 }
881 if (messages.length === 0) return 'Inbox empty.';
883 const lines: string[] = [];
884 lines.push(`SIDEBAR INBOX (${messages.length} message${messages.length === 1 ? '' : 's'})`);
885 lines.push('────────────────────────────────');
887 for (const msg of messages) {
888 const ts = msg.timestamp ? `[${msg.timestamp}]` : '[unknown]';
889 lines.push(`${ts} ${wrapUntrustedContent(msg.url, 'inbox-url')}`);
890 lines.push(` "${wrapUntrustedContent(msg.userMessage, 'inbox-message')}"`);
891 lines.push('');
892 }
894 lines.push('────────────────────────────────');
896 // Handle --clear flag
897 if (args.includes('--clear')) {
898 for (const file of files) {
899 try { fs.unlinkSync(path.join(inboxDir, file)); } catch (err: any) { if (err?.code !== 'ENOENT') throw err; }
900 }
901 lines.push(`Cleared ${files.length} message${files.length === 1 ? '' : 's'}.`);
902 }
904 return lines.join('\n');
905 }
907 // ─── State ────────────────────────────────────────
908 case 'state': {
909 const [action, name] = args;
910 if (!action || !name) throw new Error('Usage: state save|load <name>');
912 // Sanitize name: alphanumeric + hyphens + underscores only
913 if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
914 throw new Error('State name must be alphanumeric (a-z, 0-9, _, -)');
915 }
917 const config = resolveConfig();
918 const stateDir = path.join(config.stateDir, 'browse-states');
919 mkdirSecure(stateDir);
920 const statePath = path.join(stateDir, `${name}.json`);
922 if (action === 'save') {
923 const state = await bm.saveState();
924 // V1: cookies + URLs only (not localStorage — breaks on load-before-navigate)
925 const saveData = {
926 version: 1,
927 savedAt: new Date().toISOString(),
928 cookies: state.cookies,
929 pages: state.pages.map(p => ({ url: p.url, isActive: p.isActive })),
930 };
931 writeSecureFile(statePath, JSON.stringify(saveData, null, 2));
932 return `State saved: ${statePath} (${state.cookies.length} cookies, ${state.pages.length} pages)\n⚠️ Cookies stored in plaintext. Delete when no longer needed.`;
933 }
935 if (action === 'load') {
936 if (!fs.existsSync(statePath)) throw new Error(`State not found: ${statePath}`);
937 const data = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
938 if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) {
939 throw new Error('Invalid state file: expected cookies and pages arrays');
940 }
941 // Validate and filter cookies via the shared hygiene filter in
942 // session-persist.ts (isInternalCookieDomain): rejects malformed
943 // cookies and internal-network domains — localhost, *.internal,
944 // loopback literals (127.x, ::1), and link-local/cloud-metadata
945 // (169.254.x) — that a tampered state file could use to reach local
946 // services or the metadata endpoint.
947 const validatedCookies = filterSessionCookies(data.cookies);
948 if (validatedCookies.length < data.cookies.length) {
949 console.warn(`[browse] Filtered ${data.cookies.length - validatedCookies.length} invalid cookies from state file`);
950 }
951 // Warn on state files older than 7 days
952 if (data.savedAt) {
953 const ageMs = Date.now() - new Date(data.savedAt).getTime();
954 const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
955 if (ageMs > SEVEN_DAYS) {
956 console.warn(`[browse] Warning: State file is ${Math.round(ageMs / 86400000)} days old. Consider re-saving.`);
957 }
958 }
959 // Close existing pages, then restore (replace, not merge)
960 bm.setFrame(null);
961 await bm.closeAllPages();
962 // Allowlist disk-loaded page fields — NEVER accept loadedHtml, loadedHtmlWaitUntil,
963 // or owner from disk. Those are in-memory-only invariants; allowing them would let
964 // a tampered state file smuggle HTML past load-html's safe-dirs + magic-byte + size
965 // checks, or forge tab ownership for cross-agent authorization bypass.
966 await bm.restoreState({
967 cookies: validatedCookies,
968 pages: data.pages.map((p: any) => ({
969 url: typeof p.url === 'string' ? p.url : '',
970 isActive: Boolean(p.isActive),
971 storage: null,
972 })),
973 });
974 return `State loaded: ${data.cookies.length} cookies, ${data.pages.length} pages`;
975 }
977 throw new Error('Usage: state save|load <name>');
978 }
980 // ─── Frame ───────────────────────────────────────
981 case 'frame': {
982 const target = args[0];
983 if (!target) throw new Error('Usage: frame <selector|@ref|--name name|--url pattern|main>');
985 if (target === 'main') {
986 bm.setFrame(null);
987 bm.clearRefs();
988 return 'Switched to main frame';
989 }
991 const page = bm.getPage();
992 let frame: Frame | null = null;
994 if (target === '--name') {
995 if (!args[1]) throw new Error('Usage: frame --name <name>');
996 frame = page.frame({ name: args[1] });
997 } else if (target === '--url') {
998 if (!args[1]) throw new Error('Usage: frame --url <pattern>');
999 frame = page.frame({ url: new RegExp(escapeRegExp(args[1])) });
1000 } else {
1001 // CSS selector or @ref for the iframe element
1002 const resolved = await bm.resolveRef(target);
1003 const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
1004 const elementHandle = await locator.elementHandle({ timeout: 5000 });
1005 frame = await elementHandle?.contentFrame() ?? null;
1006 await elementHandle?.dispose();
1009 if (!frame) throw new Error(`Frame not found: ${target}`);
1010 bm.setFrame(frame);
1011 bm.clearRefs();
1012 return `Switched to frame: ${frame.url()}`;
1015 // ─── UX Audit ─────────────────────────────────────
1016 case 'ux-audit': {
1017 const page = bm.getPage();
1019 // Extract page structure for UX behavioral analysis
1020 // Agent interprets the data and applies Krug's 6 usability tests
1021 // Uses textContent (not innerText) to avoid layout computation on large DOMs
1022 const data = await page.evaluate(() => {
1023 const HEADING_CAP = 50;
1024 const INTERACTIVE_CAP = 200;
1025 const TEXT_BLOCK_CAP = 50;
1027 // Site ID: logo or brand element
1028 const logoEl = document.querySelector('[class*="logo"], [id*="logo"], header img, [aria-label*="home"], a[href="/"]');
1029 const siteId = logoEl ? {
1030 found: true,
1031 text: (logoEl.textContent || '').trim().slice(0, 100),
1032 tag: logoEl.tagName,
1033 alt: (logoEl as HTMLImageElement).alt || null,
1034 } : { found: false, text: null, tag: null, alt: null };
1036 // Page name: main heading
1037 const h1 = document.querySelector('h1');
1038 const pageName = h1 ? {
1039 found: true,
1040 text: h1.textContent?.trim().slice(0, 200) || '',
1041 } : { found: false, text: null };
1043 // Navigation: primary nav elements
1044 const navEls = document.querySelectorAll('nav, [role="navigation"]');
1045 const navItems: Array<{ text: string; links: number }> = [];
1046 navEls.forEach((nav, i) => {
1047 if (i >= 5) return;
1048 const links = nav.querySelectorAll('a');
1049 navItems.push({
1050 text: (nav.getAttribute('aria-label') || `nav-${i}`).slice(0, 50),
1051 links: links.length,
1052 });
1053 });
1055 // "You are here" indicator: current/active nav items
1056 // Scoped to nav containers to avoid false positives from animation classes
1057 const activeNavItems = document.querySelectorAll('nav [aria-current], nav .active, nav .current, [role="navigation"] [aria-current], [role="navigation"] .active, [role="navigation"] .current');
1058 const youAreHere = Array.from(activeNavItems).slice(0, 5).map(el => ({
1059 text: (el.textContent || '').trim().slice(0, 50),
1060 tag: el.tagName,
1061 }));
1063 // Search: search box presence
1064 const searchEl = document.querySelector('input[type="search"], [role="search"], input[name*="search"], input[placeholder*="search" i], input[aria-label*="search" i]');
1065 const search = { found: !!searchEl };
1067 // Breadcrumbs
1068 const breadcrumbEl = document.querySelector('[aria-label*="breadcrumb" i], .breadcrumb, .breadcrumbs, [class*="breadcrumb"]');
1069 const breadcrumbs = breadcrumbEl ? {
1070 found: true,
1071 items: Array.from(breadcrumbEl.querySelectorAll('a, span, li')).slice(0, 10).map(el => (el.textContent || '').trim().slice(0, 30)),
1072 } : { found: false, items: [] };
1074 // Headings: heading hierarchy
1075 const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6')).slice(0, HEADING_CAP).map(h => ({
1076 tag: h.tagName,
1077 text: (h.textContent || '').trim().slice(0, 80),
1078 size: getComputedStyle(h).fontSize,
1079 }));
1081 // Interactive elements: buttons, links, inputs
1082 const interactiveEls = Array.from(document.querySelectorAll('a, button, input, select, textarea, [role="button"], [tabindex]')).slice(0, INTERACTIVE_CAP);
1083 const interactive = interactiveEls.map(el => {
1084 const rect = el.getBoundingClientRect();
1085 return {
1086 tag: el.tagName,
1087 text: (el.textContent || (el as HTMLInputElement).placeholder || '').trim().slice(0, 50),
1088 type: (el as HTMLInputElement).type || null,
1089 role: el.getAttribute('role'),
1090 w: Math.round(rect.width),
1091 h: Math.round(rect.height),
1092 visible: rect.width > 0 && rect.height > 0,
1093 };
1094 }).filter(el => el.visible);
1096 // Text blocks: paragraphs and large text areas
1097 const textBlocks = Array.from(document.querySelectorAll('p, [class*="description"], [class*="intro"], [class*="welcome"], [class*="hero"] p, main p')).slice(0, TEXT_BLOCK_CAP).map(el => ({
1098 text: (el.textContent || '').trim().slice(0, 200),
1099 wordCount: (el.textContent || '').trim().split(/\s+/).filter(Boolean).length,
1100 }));
1102 // Total visible text word count (textContent avoids layout computation)
1103 const bodyText = (document.body?.textContent || '').trim();
1104 const totalWords = bodyText.split(/\s+/).filter(Boolean).length;
1106 return {
1107 url: window.location.href,
1108 title: document.title,
1109 siteId,
1110 pageName,
1111 navigation: navItems,
1112 youAreHere,
1113 search,
1114 breadcrumbs,
1115 headings,
1116 interactive,
1117 textBlocks,
1118 totalWords,
1119 };
1120 });
1122 return JSON.stringify(data, null, 2);
1125 case 'domain-skill': {
1126 return await handleDomainSkillCommand(args, bm);
1129 case 'skill': {
1130 const port = opts?.daemonPort;
1131 if (port === undefined) {
1132 throw new Error('skill command requires daemonPort in MetaCommandOpts (server bug)');
1134 return await handleSkillCommand(args, { port });
1137 case 'cdp': {
1138 // Lazy import — cdp-bridge introduces module deps we don't want loaded
1139 // for projects that never use the CDP escape hatch.
1140 const { handleCdpCommand } = await import('./cdp-commands');
1141 return await handleCdpCommand(args, bm);
1144 case 'memory': {
1145 // Lazy import — pulls in cdp-bridge + memory-snapshot + buffer accessors
1146 // that aren't useful for projects that never run the diagnostic.
1147 const { handleMemoryCommand } = await import('./memory-command');
1148 return await handleMemoryCommand(args, bm);
1151 default:
1152 throw new Error(`Unknown meta command: ${command}`);