/** * Meta commands — tabs, server control, screenshots, chain, diff, snapshot */ import type { BrowserManager } from './browser-manager'; import { handleSnapshot } from './snapshot'; import { getCleanText } from './read-commands'; import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent } from './commands'; import { validateNavigationUrl } from './url-validation'; import * as Diff from 'diff'; import * as fs from 'fs'; import * as path from 'path'; import { TEMP_DIR, isPathWithin } from './platform'; import { resolveConfig } from './config'; import type { Frame } from 'playwright'; // Security: Path validation to prevent path traversal attacks const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()]; export function validateOutputPath(filePath: string): void { const resolved = path.resolve(filePath); const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir)); if (!isSafe) { throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`); } } /** Tokenize a pipe segment respecting double-quoted strings. */ function tokenizePipeSegment(segment: string): string[] { const tokens: string[] = []; let current = ''; let inQuote = false; for (let i = 0; i < segment.length; i++) { const ch = segment[i]; if (ch === '"') { inQuote = !inQuote; } else if (ch === ' ' && !inQuote) { if (current) { tokens.push(current); current = ''; } } else { current += ch; } } if (current) tokens.push(current); return tokens; } export async function handleMetaCommand( command: string, args: string[], bm: BrowserManager, shutdown: () => Promise | void ): Promise { switch (command) { // ─── Tabs ────────────────────────────────────────── case 'tabs': { const tabs = await bm.getTabListWithTitles(); return tabs.map(t => `${t.active ? '→ ' : ' '}[${t.id}] ${t.title || '(untitled)'} — ${t.url}` ).join('\n'); } case 'tab': { const id = parseInt(args[0], 10); if (isNaN(id)) throw new Error('Usage: browse tab '); bm.switchTab(id); return `Switched to tab ${id}`; } case 'newtab': { const url = args[0]; const id = await bm.newTab(url); return `Opened tab ${id}${url ? ` → ${url}` : ''}`; } case 'closetab': { const id = args[0] ? parseInt(args[0], 10) : undefined; await bm.closeTab(id); return `Closed tab${id ? ` ${id}` : ''}`; } // ─── Server Control ──────────────────────────────── case 'status': { const page = bm.getPage(); const tabs = bm.getTabCount(); const mode = bm.getConnectionMode(); return [ `Status: healthy`, `Mode: ${mode}`, `URL: ${page.url()}`, `Tabs: ${tabs}`, `PID: ${process.pid}`, ].join('\n'); } case 'url': { return bm.getCurrentUrl(); } case 'stop': { await shutdown(); return 'Server stopped'; } case 'restart': { // Signal that we want a restart — the CLI will detect exit and restart console.log('[browse] Restart requested. Exiting for CLI to restart.'); await shutdown(); return 'Restarting...'; } // ─── Visual ──────────────────────────────────────── case 'screenshot': { // Parse priority: flags (--viewport, --clip) → selector (@ref, CSS) → output path const page = bm.getPage(); let outputPath = `${TEMP_DIR}/browse-screenshot.png`; let clipRect: { x: number; y: number; width: number; height: number } | undefined; let targetSelector: string | undefined; let viewportOnly = false; const remaining: string[] = []; for (let i = 0; i < args.length; i++) { if (args[i] === '--viewport') { viewportOnly = true; } else if (args[i] === '--clip') { const coords = args[++i]; if (!coords) throw new Error('Usage: screenshot --clip x,y,w,h [path]'); const parts = coords.split(',').map(Number); if (parts.length !== 4 || parts.some(isNaN)) throw new Error('Usage: screenshot --clip x,y,width,height — all must be numbers'); clipRect = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }; } else if (args[i].startsWith('--')) { throw new Error(`Unknown screenshot flag: ${args[i]}`); } else { remaining.push(args[i]); } } // Separate target (selector/@ref) from output path for (const arg of remaining) { // File paths containing / and ending with an image/pdf extension are never CSS selectors const isFilePath = arg.includes('/') && /\.(png|jpe?g|webp|pdf)$/i.test(arg); if (isFilePath) { outputPath = arg; } else if (arg.startsWith('@e') || arg.startsWith('@c') || arg.startsWith('.') || arg.startsWith('#') || arg.includes('[')) { targetSelector = arg; } else { outputPath = arg; } } validateOutputPath(outputPath); if (clipRect && targetSelector) { throw new Error('Cannot use --clip with a selector/ref — choose one'); } if (viewportOnly && clipRect) { throw new Error('Cannot use --viewport with --clip — choose one'); } if (targetSelector) { const resolved = await bm.resolveRef(targetSelector); const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector); await locator.screenshot({ path: outputPath, timeout: 5000 }); return `Screenshot saved (element): ${outputPath}`; } if (clipRect) { await page.screenshot({ path: outputPath, clip: clipRect }); return `Screenshot saved (clip ${clipRect.x},${clipRect.y},${clipRect.width},${clipRect.height}): ${outputPath}`; } await page.screenshot({ path: outputPath, fullPage: !viewportOnly }); return `Screenshot saved${viewportOnly ? ' (viewport)' : ''}: ${outputPath}`; } case 'pdf': { const page = bm.getPage(); const pdfPath = args[0] || `${TEMP_DIR}/browse-page.pdf`; validateOutputPath(pdfPath); await page.pdf({ path: pdfPath, format: 'A4' }); return `PDF saved: ${pdfPath}`; } case 'responsive': { const page = bm.getPage(); const prefix = args[0] || `${TEMP_DIR}/browse-responsive`; validateOutputPath(prefix); const viewports = [ { name: 'mobile', width: 375, height: 812 }, { name: 'tablet', width: 768, height: 1024 }, { name: 'desktop', width: 1280, height: 720 }, ]; const originalViewport = page.viewportSize(); const results: string[] = []; for (const vp of viewports) { await page.setViewportSize({ width: vp.width, height: vp.height }); const path = `${prefix}-${vp.name}.png`; await page.screenshot({ path, fullPage: true }); results.push(`${vp.name} (${vp.width}x${vp.height}): ${path}`); } // Restore original viewport if (originalViewport) { await page.setViewportSize(originalViewport); } return results.join('\n'); } // ─── Chain ───────────────────────────────────────── case 'chain': { // Read JSON array from args[0] (if provided) or expect it was passed as body const jsonStr = args[0]; if (!jsonStr) throw new Error( 'Usage: echo \'[["goto","url"],["text"]]\' | browse chain\n' + ' or: browse chain \'goto url | click @e5 | snapshot -ic\'' ); let commands: string[][]; try { commands = JSON.parse(jsonStr); if (!Array.isArray(commands)) throw new Error('not array'); } catch { // Fallback: pipe-delimited format "goto url | click @e5 | snapshot -ic" commands = jsonStr.split(' | ') .filter(seg => seg.trim().length > 0) .map(seg => tokenizePipeSegment(seg.trim())); } const results: string[] = []; const { handleReadCommand } = await import('./read-commands'); const { handleWriteCommand } = await import('./write-commands'); let lastWasWrite = false; for (const cmd of commands) { const [name, ...cmdArgs] = cmd; try { let result: string; if (WRITE_COMMANDS.has(name)) { result = await handleWriteCommand(name, cmdArgs, bm); lastWasWrite = true; } else if (READ_COMMANDS.has(name)) { result = await handleReadCommand(name, cmdArgs, bm); if (PAGE_CONTENT_COMMANDS.has(name)) { result = wrapUntrustedContent(result, bm.getCurrentUrl()); } lastWasWrite = false; } else if (META_COMMANDS.has(name)) { result = await handleMetaCommand(name, cmdArgs, bm, shutdown); lastWasWrite = false; } else { throw new Error(`Unknown command: ${name}`); } results.push(`[${name}] ${result}`); } catch (err: any) { results.push(`[${name}] ERROR: ${err.message}`); } } // Wait for network to settle after write commands before returning if (lastWasWrite) { await bm.getPage().waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {}); } return results.join('\n\n'); } // ─── Diff ────────────────────────────────────────── case 'diff': { const [url1, url2] = args; if (!url1 || !url2) throw new Error('Usage: browse diff '); const page = bm.getPage(); await validateNavigationUrl(url1); await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 }); const text1 = await getCleanText(page); await validateNavigationUrl(url2); await page.goto(url2, { waitUntil: 'domcontentloaded', timeout: 15000 }); const text2 = await getCleanText(page); const changes = Diff.diffLines(text1, text2); const output: string[] = [`--- ${url1}`, `+++ ${url2}`, '']; for (const part of changes) { const prefix = part.added ? '+' : part.removed ? '-' : ' '; const lines = part.value.split('\n').filter(l => l.length > 0); for (const line of lines) { output.push(`${prefix} ${line}`); } } return wrapUntrustedContent(output.join('\n'), `diff: ${url1} vs ${url2}`); } // ─── Snapshot ───────────────────────────────────── case 'snapshot': { const snapshotResult = await handleSnapshot(args, bm); return wrapUntrustedContent(snapshotResult, bm.getCurrentUrl()); } // ─── Handoff ──────────────────────────────────── case 'handoff': { const message = args.join(' ') || 'User takeover requested'; return await bm.handoff(message); } case 'resume': { bm.resume(); // Re-snapshot to capture current page state after human interaction const snapshot = await handleSnapshot(['-i'], bm); return `RESUMED\n${wrapUntrustedContent(snapshot, bm.getCurrentUrl())}`; } // ─── Headed Mode ────────────────────────────────────── case 'connect': { // connect is handled as a pre-server command in cli.ts // If we get here, server is already running — tell the user if (bm.getConnectionMode() === 'headed') { return 'Already in headed mode with extension.'; } return 'The connect command must be run from the CLI (not sent to a running server). Run: $B connect'; } case 'disconnect': { if (bm.getConnectionMode() !== 'headed') { return 'Not in headed mode — nothing to disconnect.'; } // Signal that we want a restart in headless mode console.log('[browse] Disconnecting headed browser. Restarting in headless mode.'); await shutdown(); return 'Disconnected. Server will restart in headless mode on next command.'; } case 'focus': { if (bm.getConnectionMode() !== 'headed') { return 'focus requires headed mode. Run `$B connect` first.'; } try { const { execSync } = await import('child_process'); // Try common Chromium-based browser app names to bring to foreground const appNames = ['Comet', 'Google Chrome', 'Arc', 'Brave Browser', 'Microsoft Edge']; let activated = false; for (const appName of appNames) { try { execSync(`osascript -e 'tell application "${appName}" to activate'`, { stdio: 'pipe', timeout: 3000 }); activated = true; break; } catch { // Try next browser } } if (!activated) { return 'Could not bring browser to foreground. macOS only.'; } // If a ref was passed, scroll it into view if (args.length > 0 && args[0].startsWith('@')) { try { const resolved = await bm.resolveRef(args[0]); if ('locator' in resolved) { await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 }); return `Browser activated. Scrolled ${args[0]} into view.`; } } catch { // Ref not found — still activated the browser } } return 'Browser window activated.'; } catch (err: any) { return `focus failed: ${err.message}. macOS only.`; } } // ─── Watch ────────────────────────────────────────── case 'watch': { if (args[0] === 'stop') { if (!bm.isWatching()) return 'Not currently watching.'; const result = bm.stopWatch(); const durationSec = Math.round(result.duration / 1000); const lastSnapshot = result.snapshots.length > 0 ? wrapUntrustedContent(result.snapshots[result.snapshots.length - 1], bm.getCurrentUrl()) : '(none)'; return [ `WATCH STOPPED (${durationSec}s, ${result.snapshots.length} snapshots)`, '', 'Last snapshot:', lastSnapshot, ].join('\n'); } if (bm.isWatching()) return 'Already watching. Run `$B watch stop` to stop.'; if (bm.getConnectionMode() !== 'headed') { return 'watch requires headed mode. Run `$B connect` first.'; } bm.startWatch(); return 'WATCHING — observing user browsing. Periodic snapshots every 5s.\nRun `$B watch stop` to stop and get summary.'; } // ─── Inbox ────────────────────────────────────────── case 'inbox': { const { execSync } = await import('child_process'); let gitRoot: string; try { gitRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); } catch { return 'Not in a git repository — cannot locate inbox.'; } const inboxDir = path.join(gitRoot, '.context', 'sidebar-inbox'); if (!fs.existsSync(inboxDir)) return 'Inbox empty.'; const files = fs.readdirSync(inboxDir) .filter(f => f.endsWith('.json') && !f.startsWith('.')) .sort() .reverse(); // newest first if (files.length === 0) return 'Inbox empty.'; const messages: { timestamp: string; url: string; userMessage: string }[] = []; for (const file of files) { try { const data = JSON.parse(fs.readFileSync(path.join(inboxDir, file), 'utf-8')); messages.push({ timestamp: data.timestamp || '', url: data.page?.url || 'unknown', userMessage: data.userMessage || '', }); } catch { // Skip malformed files } } if (messages.length === 0) return 'Inbox empty.'; const lines: string[] = []; lines.push(`SIDEBAR INBOX (${messages.length} message${messages.length === 1 ? '' : 's'})`); lines.push('────────────────────────────────'); for (const msg of messages) { const ts = msg.timestamp ? `[${msg.timestamp}]` : '[unknown]'; lines.push(`${ts} ${msg.url}`); lines.push(` "${msg.userMessage}"`); lines.push(''); } lines.push('────────────────────────────────'); // Handle --clear flag if (args.includes('--clear')) { for (const file of files) { try { fs.unlinkSync(path.join(inboxDir, file)); } catch {} } lines.push(`Cleared ${files.length} message${files.length === 1 ? '' : 's'}.`); } return lines.join('\n'); } // ─── State ──────────────────────────────────────── case 'state': { const [action, name] = args; if (!action || !name) throw new Error('Usage: state save|load '); // Sanitize name: alphanumeric + hyphens + underscores only if (!/^[a-zA-Z0-9_-]+$/.test(name)) { throw new Error('State name must be alphanumeric (a-z, 0-9, _, -)'); } const config = resolveConfig(); const stateDir = path.join(config.stateDir, 'browse-states'); fs.mkdirSync(stateDir, { recursive: true }); const statePath = path.join(stateDir, `${name}.json`); if (action === 'save') { const state = await bm.saveState(); // V1: cookies + URLs only (not localStorage — breaks on load-before-navigate) const saveData = { version: 1, savedAt: new Date().toISOString(), cookies: state.cookies, pages: state.pages.map(p => ({ url: p.url, isActive: p.isActive })), }; fs.writeFileSync(statePath, JSON.stringify(saveData, null, 2), { mode: 0o600 }); return `State saved: ${statePath} (${state.cookies.length} cookies, ${state.pages.length} pages)\n⚠️ Cookies stored in plaintext. Delete when no longer needed.`; } if (action === 'load') { if (!fs.existsSync(statePath)) throw new Error(`State not found: ${statePath}`); const data = JSON.parse(fs.readFileSync(statePath, 'utf-8')); if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) { throw new Error('Invalid state file: expected cookies and pages arrays'); } // Warn on state files older than 7 days if (data.savedAt) { const ageMs = Date.now() - new Date(data.savedAt).getTime(); const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000; if (ageMs > SEVEN_DAYS) { console.warn(`[browse] Warning: State file is ${Math.round(ageMs / 86400000)} days old. Consider re-saving.`); } } // Close existing pages, then restore (replace, not merge) bm.setFrame(null); await bm.closeAllPages(); await bm.restoreState({ cookies: data.cookies, pages: data.pages.map((p: any) => ({ ...p, storage: null })), }); return `State loaded: ${data.cookies.length} cookies, ${data.pages.length} pages`; } throw new Error('Usage: state save|load '); } // ─── Frame ─────────────────────────────────────── case 'frame': { const target = args[0]; if (!target) throw new Error('Usage: frame '); if (target === 'main') { bm.setFrame(null); bm.clearRefs(); return 'Switched to main frame'; } const page = bm.getPage(); let frame: Frame | null = null; if (target === '--name') { if (!args[1]) throw new Error('Usage: frame --name '); frame = page.frame({ name: args[1] }); } else if (target === '--url') { if (!args[1]) throw new Error('Usage: frame --url '); frame = page.frame({ url: new RegExp(args[1]) }); } else { // CSS selector or @ref for the iframe element const resolved = await bm.resolveRef(target); const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector); const elementHandle = await locator.elementHandle({ timeout: 5000 }); frame = await elementHandle?.contentFrame() ?? null; await elementHandle?.dispose(); } if (!frame) throw new Error(`Frame not found: ${target}`); bm.setFrame(frame); bm.clearRefs(); return `Switched to frame: ${frame.url()}`; } default: throw new Error(`Unknown meta command: ${command}`); } }