/** * Write commands — navigate and interact with pages (side effects) * * goto, back, forward, reload, click, fill, select, hover, type, * press, scroll, wait, viewport, cookie, header, useragent */ import type { BrowserManager } from './browser-manager'; import { findInstalledBrowsers, importCookies } from './cookie-import-browser'; import * as fs from 'fs'; import * as path from 'path'; export async function handleWriteCommand( command: string, args: string[], bm: BrowserManager ): Promise { const page = bm.getPage(); switch (command) { case 'goto': { const url = args[0]; if (!url) throw new Error('Usage: browse goto '); const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); const status = response?.status() || 'unknown'; return `Navigated to ${url} (${status})`; } case 'back': { await page.goBack({ waitUntil: 'domcontentloaded', timeout: 15000 }); return `Back → ${page.url()}`; } case 'forward': { await page.goForward({ waitUntil: 'domcontentloaded', timeout: 15000 }); return `Forward → ${page.url()}`; } case 'reload': { await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 }); return `Reloaded ${page.url()}`; } case 'click': { const selector = args[0]; if (!selector) throw new Error('Usage: browse click '); const resolved = bm.resolveRef(selector); if ('locator' in resolved) { await resolved.locator.click({ timeout: 5000 }); } else { await page.click(resolved.selector, { timeout: 5000 }); } // Wait briefly for any navigation/DOM update await page.waitForLoadState('domcontentloaded').catch(() => {}); return `Clicked ${selector} → now at ${page.url()}`; } case 'fill': { const [selector, ...valueParts] = args; const value = valueParts.join(' '); if (!selector || !value) throw new Error('Usage: browse fill '); const resolved = bm.resolveRef(selector); if ('locator' in resolved) { await resolved.locator.fill(value, { timeout: 5000 }); } else { await page.fill(resolved.selector, value, { timeout: 5000 }); } return `Filled ${selector}`; } case 'select': { const [selector, ...valueParts] = args; const value = valueParts.join(' '); if (!selector || !value) throw new Error('Usage: browse select '); const resolved = bm.resolveRef(selector); if ('locator' in resolved) { await resolved.locator.selectOption(value, { timeout: 5000 }); } else { await page.selectOption(resolved.selector, value, { timeout: 5000 }); } return `Selected "${value}" in ${selector}`; } case 'hover': { const selector = args[0]; if (!selector) throw new Error('Usage: browse hover '); const resolved = bm.resolveRef(selector); if ('locator' in resolved) { await resolved.locator.hover({ timeout: 5000 }); } else { await page.hover(resolved.selector, { timeout: 5000 }); } return `Hovered ${selector}`; } case 'type': { const text = args.join(' '); if (!text) throw new Error('Usage: browse type '); await page.keyboard.type(text); return `Typed ${text.length} characters`; } case 'press': { const key = args[0]; if (!key) throw new Error('Usage: browse press (e.g., Enter, Tab, Escape)'); await page.keyboard.press(key); return `Pressed ${key}`; } case 'scroll': { const selector = args[0]; if (selector) { const resolved = bm.resolveRef(selector); if ('locator' in resolved) { await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 }); } else { await page.locator(resolved.selector).scrollIntoViewIfNeeded({ timeout: 5000 }); } return `Scrolled ${selector} into view`; } await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); return 'Scrolled to bottom'; } case 'wait': { const selector = args[0]; if (!selector) throw new Error('Usage: browse wait '); if (selector === '--networkidle') { const timeout = args[1] ? parseInt(args[1], 10) : 15000; await page.waitForLoadState('networkidle', { timeout }); return 'Network idle'; } if (selector === '--load') { await page.waitForLoadState('load'); return 'Page loaded'; } if (selector === '--domcontentloaded') { await page.waitForLoadState('domcontentloaded'); return 'DOM content loaded'; } const timeout = args[1] ? parseInt(args[1], 10) : 15000; const resolved = bm.resolveRef(selector); if ('locator' in resolved) { await resolved.locator.waitFor({ state: 'visible', timeout }); } else { await page.waitForSelector(resolved.selector, { timeout }); } return `Element ${selector} appeared`; } case 'viewport': { const size = args[0]; if (!size || !size.includes('x')) throw new Error('Usage: browse viewport (e.g., 375x812)'); const [w, h] = size.split('x').map(Number); await bm.setViewport(w, h); return `Viewport set to ${w}x${h}`; } case 'cookie': { const cookieStr = args[0]; if (!cookieStr || !cookieStr.includes('=')) throw new Error('Usage: browse cookie ='); const eq = cookieStr.indexOf('='); const name = cookieStr.slice(0, eq); const value = cookieStr.slice(eq + 1); const url = new URL(page.url()); await page.context().addCookies([{ name, value, domain: url.hostname, path: '/', }]); return `Cookie set: ${name}=****`; } case 'header': { const headerStr = args[0]; if (!headerStr || !headerStr.includes(':')) throw new Error('Usage: browse header :'); const sep = headerStr.indexOf(':'); const name = headerStr.slice(0, sep).trim(); const value = headerStr.slice(sep + 1).trim(); await bm.setExtraHeader(name, value); const sensitiveHeaders = ['authorization', 'cookie', 'set-cookie', 'x-api-key', 'x-auth-token']; const redactedValue = sensitiveHeaders.includes(name.toLowerCase()) ? '****' : value; return `Header set: ${name}: ${redactedValue}`; } case 'useragent': { const ua = args.join(' '); if (!ua) throw new Error('Usage: browse useragent '); bm.setUserAgent(ua); const error = await bm.recreateContext(); if (error) { return `User agent set to "${ua}" but: ${error}`; } return `User agent set: ${ua}`; } case 'upload': { const [selector, ...filePaths] = args; if (!selector || filePaths.length === 0) throw new Error('Usage: browse upload [file2...]'); // Validate all files exist before upload for (const fp of filePaths) { if (!fs.existsSync(fp)) throw new Error(`File not found: ${fp}`); } const resolved = bm.resolveRef(selector); if ('locator' in resolved) { await resolved.locator.setInputFiles(filePaths); } else { await page.locator(resolved.selector).setInputFiles(filePaths); } const fileInfo = filePaths.map(fp => { const stat = fs.statSync(fp); return `${path.basename(fp)} (${stat.size}B)`; }).join(', '); return `Uploaded: ${fileInfo}`; } case 'dialog-accept': { const text = args.length > 0 ? args.join(' ') : null; bm.setDialogAutoAccept(true); bm.setDialogPromptText(text); return text ? `Dialogs will be accepted with text: "${text}"` : 'Dialogs will be accepted'; } case 'dialog-dismiss': { bm.setDialogAutoAccept(false); bm.setDialogPromptText(null); return 'Dialogs will be dismissed'; } case 'cookie-import': { const filePath = args[0]; if (!filePath) throw new Error('Usage: browse cookie-import '); // Path validation — prevent reading arbitrary files if (path.isAbsolute(filePath)) { const safeDirs = ['/tmp', process.cwd()]; const resolved = path.resolve(filePath); if (!safeDirs.some(dir => resolved === dir || resolved.startsWith(dir + '/'))) { throw new Error(`Path must be within: ${safeDirs.join(', ')}`); } } if (path.normalize(filePath).includes('..')) { throw new Error('Path traversal sequences (..) are not allowed'); } if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`); const raw = fs.readFileSync(filePath, 'utf-8'); let cookies: any[]; try { cookies = JSON.parse(raw); } catch { throw new Error(`Invalid JSON in ${filePath}`); } if (!Array.isArray(cookies)) throw new Error('Cookie file must contain a JSON array'); // Auto-fill domain from current page URL when missing (consistent with cookie command) const pageUrl = new URL(page.url()); const defaultDomain = pageUrl.hostname; for (const c of cookies) { if (!c.name || c.value === undefined) throw new Error('Each cookie must have "name" and "value" fields'); if (!c.domain) c.domain = defaultDomain; if (!c.path) c.path = '/'; } await page.context().addCookies(cookies); return `Loaded ${cookies.length} cookies from ${filePath}`; } case 'cookie-import-browser': { // Two modes: // 1. Direct CLI import: cookie-import-browser --domain // 2. Open picker UI: cookie-import-browser [browser] const browserArg = args[0]; const domainIdx = args.indexOf('--domain'); if (domainIdx !== -1 && domainIdx + 1 < args.length) { // Direct import mode — no UI const domain = args[domainIdx + 1]; const browser = browserArg || 'comet'; const result = await importCookies(browser, [domain]); if (result.cookies.length > 0) { await page.context().addCookies(result.cookies); } const msg = [`Imported ${result.count} cookies for ${domain} from ${browser}`]; if (result.failed > 0) msg.push(`(${result.failed} failed to decrypt)`); return msg.join(' '); } // Picker UI mode — open in user's browser const port = bm.serverPort; if (!port) throw new Error('Server port not available'); const browsers = findInstalledBrowsers(); if (browsers.length === 0) { throw new Error('No Chromium browsers found. Supported: Comet, Chrome, Arc, Brave, Edge'); } const pickerUrl = `http://127.0.0.1:${port}/cookie-picker`; try { Bun.spawn(['open', pickerUrl], { stdout: 'ignore', stderr: 'ignore' }); } catch { // open may fail silently — URL is in the message below } return `Cookie picker opened at ${pickerUrl}\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect domains to import, then close the picker when done.`; } default: throw new Error(`Unknown write command: ${command}`); } }