mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-06 13:45:35 +02:00
7450b5160b
* 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}`);
|
|
}
|
|
}
|