mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-11 15:07:26 +08:00
* fix: remove auth token from /health, secure extension bootstrap (CRITICAL-02 + HIGH-03) - Remove token from /health response (was leaked to any localhost process) - Write .auth.json to extension dir for Manifest V3 bootstrap - sidebar-agent reads token from state file via BROWSE_STATE_FILE env var - Remove getToken handler from extension (token via health broadcast) - Extension loads token before first health poll to prevent race condition * fix: require auth on cookie-picker data routes (CRITICAL-01) - Add Bearer token auth gate on all /cookie-picker/* data/action routes - GET /cookie-picker HTML page stays unauthenticated (UI shell) - Token embedded in served HTML for picker's fetch calls - CORS preflight now allows Authorization header * fix: add state file TTL and plaintext cookie warning (HIGH-02) - Add savedAt timestamp to state save output - Warn on load if state file older than 7 days - Auto-delete stale state files (>7 days) on server startup - Warning about plaintext cookie storage in save message * fix: innerHTML XSS in extension content script and sidepanel (MEDIUM-01) - content.js: replace innerHTML with createElement/textContent for ref panel - sidepanel.js: escape entry.command with escapeHtml() in activity feed - Both found by security audit + Codex adversarial red team * fix: symlink bypass in validateReadPath (MEDIUM-02) - Always resolve to absolute path first (fixes relative path bypass) - Use realpathSync to follow symlinks before boundary check - Throw on non-ENOENT realpathSync failures (explicit over silent) - Resolve SAFE_DIRECTORIES through realpathSync (macOS /tmp → /private/tmp) - Resolve directory part for non-existent files (ENOENT with symlinked parent) * fix: freeze hook symlink bypass and prefix collision (MEDIUM-03) - Add POSIX-portable path resolution (cd + pwd -P, works on macOS) - Fix prefix collision: /project-evil no longer matches /project freeze dir - Use trailing slash in boundary check to require directory boundary * fix: shell script injection in gstack-config and telemetry (MEDIUM-04) - gstack-config: validate keys (alphanumeric+underscore only) - gstack-config: use grep -F (fixed string) instead of -E (regex) - gstack-config: escape sed special chars in values, drop newlines - gstack-telemetry-log: sanitize REPO_SLUG and BRANCH via json_safe() * test: 20 security tests for audit remediation - server-auth: verify token removed from /health, auth on /refs, /activity/* - cookie-picker: auth required on data routes, HTML page unauthenticated - path-validation: symlink bypass blocked, realpathSync failure throws - gstack-config: regex key rejected, sed special chars preserved - state-ttl: savedAt timestamp, 7-day TTL warning - telemetry: branch/repo with quotes don't corrupt JSON - adversarial: sidepanel escapes entry.command, freeze prefix collision * chore: bump version and changelog (v0.13.1.0) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: tone down changelog — defense in depth, not catastrophic bugs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
551 lines
21 KiB
TypeScript
551 lines
21 KiB
TypeScript
/**
|
||
* 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 } 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> | void
|
||
): Promise<string> {
|
||
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 <id>');
|
||
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);
|
||
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 <url1> <url2>');
|
||
|
||
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 output.join('\n');
|
||
}
|
||
|
||
// ─── Snapshot ─────────────────────────────────────
|
||
case 'snapshot': {
|
||
return await handleSnapshot(args, bm);
|
||
}
|
||
|
||
// ─── 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${snapshot}`;
|
||
}
|
||
|
||
// ─── 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);
|
||
return [
|
||
`WATCH STOPPED (${durationSec}s, ${result.snapshots.length} snapshots)`,
|
||
'',
|
||
'Last snapshot:',
|
||
result.snapshots.length > 0 ? result.snapshots[result.snapshots.length - 1] : '(none)',
|
||
].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 <name>');
|
||
|
||
// 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 <name>');
|
||
}
|
||
|
||
// ─── Frame ───────────────────────────────────────
|
||
case 'frame': {
|
||
const target = args[0];
|
||
if (!target) throw new Error('Usage: frame <selector|@ref|--name name|--url pattern|main>');
|
||
|
||
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 <name>');
|
||
frame = page.frame({ name: args[1] });
|
||
} else if (target === '--url') {
|
||
if (!args[1]) throw new Error('Usage: frame --url <pattern>');
|
||
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}`);
|
||
}
|
||
}
|