background.ts
1 /** 2 * OpenCLI — Service Worker (background script). 3 * 4 * Connects to the opencli daemon via WebSocket, receives commands, 5 * dispatches them to Chrome APIs (debugger/tabs/cookies), returns results. 6 */ 7 8 declare const __OPENCLI_COMPAT_RANGE__: string; 9 10 import type { Command, Result } from './protocol'; 11 import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; 12 import * as executor from './cdp'; 13 import * as identity from './identity'; 14 15 let ws: WebSocket | null = null; 16 let reconnectTimer: ReturnType<typeof setTimeout> | null = null; 17 let reconnectAttempts = 0; 18 19 // ─── Console log forwarding ────────────────────────────────────────── 20 // Hook console.log/warn/error to forward logs to daemon via WebSocket. 21 22 const _origLog = console.log.bind(console); 23 const _origWarn = console.warn.bind(console); 24 const _origError = console.error.bind(console); 25 26 function forwardLog(level: 'info' | 'warn' | 'error', args: unknown[]): void { 27 if (!ws || ws.readyState !== WebSocket.OPEN) return; 28 try { 29 const msg = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '); 30 ws.send(JSON.stringify({ type: 'log', level, msg, ts: Date.now() })); 31 } catch { /* don't recurse */ } 32 } 33 34 console.log = (...args: unknown[]) => { _origLog(...args); forwardLog('info', args); }; 35 console.warn = (...args: unknown[]) => { _origWarn(...args); forwardLog('warn', args); }; 36 console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error', args); }; 37 38 // ─── WebSocket connection ──────────────────────────────────────────── 39 40 /** 41 * Probe the daemon via its /ping HTTP endpoint before attempting a WebSocket 42 * connection. fetch() failures are silently catchable; new WebSocket() is not 43 * — Chrome logs ERR_CONNECTION_REFUSED to the extension error page before any 44 * JS handler can intercept it. By keeping the probe inside connect() every 45 * call site remains unchanged and the guard can never be accidentally skipped. 46 */ 47 async function connect(): Promise<void> { 48 if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; 49 50 try { 51 const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1000) }); 52 if (!res.ok) return; // unexpected response — not our daemon 53 } catch { 54 return; // daemon not running — skip WebSocket to avoid console noise 55 } 56 57 try { 58 ws = new WebSocket(DAEMON_WS_URL); 59 } catch { 60 scheduleReconnect(); 61 return; 62 } 63 64 ws.onopen = () => { 65 console.log('[opencli] Connected to daemon'); 66 reconnectAttempts = 0; // Reset on successful connection 67 if (reconnectTimer) { 68 clearTimeout(reconnectTimer); 69 reconnectTimer = null; 70 } 71 // Send version + compatibility range so the daemon can report mismatches to the CLI 72 ws?.send(JSON.stringify({ 73 type: 'hello', 74 version: chrome.runtime.getManifest().version, 75 compatRange: __OPENCLI_COMPAT_RANGE__, 76 })); 77 }; 78 79 ws.onmessage = async (event) => { 80 try { 81 const command = JSON.parse(event.data as string) as Command; 82 const result = await handleCommand(command); 83 ws?.send(JSON.stringify(result)); 84 } catch (err) { 85 console.error('[opencli] Message handling error:', err); 86 } 87 }; 88 89 ws.onclose = () => { 90 console.log('[opencli] Disconnected from daemon'); 91 ws = null; 92 scheduleReconnect(); 93 }; 94 95 ws.onerror = () => { 96 ws?.close(); 97 }; 98 } 99 100 /** 101 * After MAX_EAGER_ATTEMPTS (reaching 60s backoff), stop scheduling reconnects. 102 * The keepalive alarm (~24s) will still call connect() periodically, but at a 103 * much lower frequency — reducing console noise when the daemon is not running. 104 */ 105 const MAX_EAGER_ATTEMPTS = 6; // 2s, 4s, 8s, 16s, 32s, 60s — then stop 106 107 function scheduleReconnect(): void { 108 if (reconnectTimer) return; 109 reconnectAttempts++; 110 if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; // let keepalive alarm handle it 111 const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); 112 reconnectTimer = setTimeout(() => { 113 reconnectTimer = null; 114 void connect(); 115 }, delay); 116 } 117 118 // ─── Automation window isolation ───────────────────────────────────── 119 // All opencli operations happen in a dedicated Chrome window so the 120 // user's active browsing session is never touched. 121 // The window auto-closes after a period of idle (no commands). 122 // Interactive workspaces (browser:*, operate:*) get a longer timeout (10 min) 123 // since users type commands manually; adapter workspaces keep a short 30s timeout. 124 125 type AutomationSession = { 126 windowId: number; 127 idleTimer: ReturnType<typeof setTimeout> | null; 128 idleDeadlineAt: number; 129 owned: boolean; 130 preferredTabId: number | null; 131 }; 132 133 const automationSessions = new Map<string, AutomationSession>(); 134 const IDLE_TIMEOUT_DEFAULT = 30_000; // 30s — adapter-driven automation 135 const IDLE_TIMEOUT_INTERACTIVE = 600_000; // 10min — human-paced browser:* / operate:* 136 137 /** Per-workspace custom timeout overrides set via command.idleTimeout */ 138 const workspaceTimeoutOverrides = new Map<string, number>(); 139 140 function getIdleTimeout(workspace: string): number { 141 const override = workspaceTimeoutOverrides.get(workspace); 142 if (override !== undefined) return override; 143 if (workspace.startsWith('browser:') || workspace.startsWith('operate:')) { 144 return IDLE_TIMEOUT_INTERACTIVE; 145 } 146 return IDLE_TIMEOUT_DEFAULT; 147 } 148 149 let windowFocused = false; // set per-command from daemon's OPENCLI_WINDOW_FOCUSED 150 151 function getWorkspaceKey(workspace?: string): string { 152 return workspace?.trim() || 'default'; 153 } 154 155 function resetWindowIdleTimer(workspace: string): void { 156 const session = automationSessions.get(workspace); 157 if (!session) return; 158 if (session.idleTimer) clearTimeout(session.idleTimer); 159 const timeout = getIdleTimeout(workspace); 160 session.idleDeadlineAt = Date.now() + timeout; 161 session.idleTimer = setTimeout(async () => { 162 const current = automationSessions.get(workspace); 163 if (!current) return; 164 if (!current.owned) { 165 console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`); 166 workspaceTimeoutOverrides.delete(workspace); 167 automationSessions.delete(workspace); 168 return; 169 } 170 try { 171 await chrome.windows.remove(current.windowId); 172 console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout, ${timeout / 1000}s)`); 173 } catch { 174 // Already gone 175 } 176 workspaceTimeoutOverrides.delete(workspace); 177 automationSessions.delete(workspace); 178 }, timeout); 179 } 180 181 /** Get or create the dedicated automation window. 182 * @param initialUrl — if provided (http/https), used as the initial page instead of about:blank. 183 * This avoids an extra blank-page→target-domain navigation on first command. 184 */ 185 async function getAutomationWindow(workspace: string, initialUrl?: string): Promise<number> { 186 // Check if our window is still alive 187 const existing = automationSessions.get(workspace); 188 if (existing) { 189 try { 190 await chrome.windows.get(existing.windowId); 191 return existing.windowId; 192 } catch { 193 // Window was closed by user 194 automationSessions.delete(workspace); 195 } 196 } 197 198 // Use the target URL directly if it's a safe navigation URL, otherwise fall back to about:blank. 199 const startUrl = (initialUrl && isSafeNavigationUrl(initialUrl)) ? initialUrl : BLANK_PAGE; 200 201 // Note: Do NOT set `state` parameter here. Chrome 146+ rejects 'normal' as an invalid 202 // state value for windows.create(). The window defaults to 'normal' state anyway. 203 const win = await chrome.windows.create({ 204 url: startUrl, 205 focused: windowFocused, 206 width: 1280, 207 height: 900, 208 type: 'normal', 209 }); 210 const session: AutomationSession = { 211 windowId: win.id!, 212 idleTimer: null, 213 idleDeadlineAt: Date.now() + getIdleTimeout(workspace), 214 owned: true, 215 preferredTabId: null, 216 }; 217 automationSessions.set(workspace, session); 218 console.log(`[opencli] Created automation window ${session.windowId} (${workspace}, start=${startUrl})`); 219 resetWindowIdleTimer(workspace); 220 // Wait for the initial tab to finish loading instead of a fixed 200ms sleep. 221 const tabs = await chrome.tabs.query({ windowId: win.id! }); 222 if (tabs[0]?.id) { 223 await new Promise<void>((resolve) => { 224 const timeout = setTimeout(resolve, 500); // fallback cap 225 const listener = (tabId: number, info: chrome.tabs.TabChangeInfo) => { 226 if (tabId === tabs[0].id && info.status === 'complete') { 227 chrome.tabs.onUpdated.removeListener(listener); 228 clearTimeout(timeout); 229 resolve(); 230 } 231 }; 232 // Check if already complete before listening 233 if (tabs[0].status === 'complete') { 234 clearTimeout(timeout); 235 resolve(); 236 } else { 237 chrome.tabs.onUpdated.addListener(listener); 238 } 239 }); 240 } 241 return session.windowId; 242 } 243 244 // Clean up when the automation window is closed 245 chrome.windows.onRemoved.addListener(async (windowId) => { 246 for (const [workspace, session] of automationSessions.entries()) { 247 if (session.windowId === windowId) { 248 console.log(`[opencli] Automation window closed (${workspace})`); 249 if (session.idleTimer) clearTimeout(session.idleTimer); 250 automationSessions.delete(workspace); 251 workspaceTimeoutOverrides.delete(workspace); 252 } 253 } 254 }); 255 256 // Evict identity mappings when tabs are closed 257 chrome.tabs.onRemoved.addListener((tabId) => { 258 identity.evictTab(tabId); 259 }); 260 261 // ─── Lifecycle events ──────────────────────────────────────────────── 262 263 let initialized = false; 264 265 function initialize(): void { 266 if (initialized) return; 267 initialized = true; 268 chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds 269 executor.registerListeners(); 270 executor.registerFrameTracking(); 271 void connect(); 272 console.log('[opencli] OpenCLI extension initialized'); 273 } 274 275 chrome.runtime.onInstalled.addListener(() => { 276 initialize(); 277 }); 278 279 chrome.runtime.onStartup.addListener(() => { 280 initialize(); 281 }); 282 283 chrome.alarms.onAlarm.addListener((alarm) => { 284 if (alarm.name === 'keepalive') void connect(); 285 }); 286 287 // ─── Popup status API ─────────────────────────────────────────────── 288 289 chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { 290 if (msg?.type === 'getStatus') { 291 sendResponse({ 292 connected: ws?.readyState === WebSocket.OPEN, 293 reconnecting: reconnectTimer !== null, 294 }); 295 } 296 return false; 297 }); 298 299 // ─── Command dispatcher ───────────────────────────────────────────── 300 301 async function handleCommand(cmd: Command): Promise<Result> { 302 const workspace = getWorkspaceKey(cmd.workspace); 303 windowFocused = cmd.windowFocused === true; 304 // Apply custom idle timeout if specified in the command 305 if (cmd.idleTimeout != null && cmd.idleTimeout > 0) { 306 workspaceTimeoutOverrides.set(workspace, cmd.idleTimeout * 1000); 307 } 308 // Reset idle timer on every command (window stays alive while active) 309 resetWindowIdleTimer(workspace); 310 try { 311 switch (cmd.action) { 312 case 'exec': 313 return await handleExec(cmd, workspace); 314 case 'navigate': 315 return await handleNavigate(cmd, workspace); 316 case 'tabs': 317 return await handleTabs(cmd, workspace); 318 case 'cookies': 319 return await handleCookies(cmd); 320 case 'screenshot': 321 return await handleScreenshot(cmd, workspace); 322 case 'close-window': 323 return await handleCloseWindow(cmd, workspace); 324 case 'cdp': 325 return await handleCdp(cmd, workspace); 326 case 'sessions': 327 return await handleSessions(cmd); 328 case 'set-file-input': 329 return await handleSetFileInput(cmd, workspace); 330 case 'insert-text': 331 return await handleInsertText(cmd, workspace); 332 case 'bind-current': 333 return await handleBindCurrent(cmd, workspace); 334 case 'network-capture-start': 335 return await handleNetworkCaptureStart(cmd, workspace); 336 case 'network-capture-read': 337 return await handleNetworkCaptureRead(cmd, workspace); 338 case 'frames': 339 return await handleFrames(cmd, workspace); 340 default: 341 return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` }; 342 } 343 } catch (err) { 344 return { 345 id: cmd.id, 346 ok: false, 347 error: err instanceof Error ? err.message : String(err), 348 }; 349 } 350 } 351 352 // ─── Action handlers ───────────────────────────────────────────────── 353 354 /** Internal blank page used when no user URL is provided. */ 355 const BLANK_PAGE = 'about:blank'; 356 357 /** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */ 358 function isDebuggableUrl(url?: string): boolean { 359 if (!url) return true; // empty/undefined = tab still loading, allow it 360 return url.startsWith('http://') || url.startsWith('https://') || url === 'about:blank' || url.startsWith('data:'); 361 } 362 363 /** Check if a URL is safe for user-facing navigation (http/https only). */ 364 function isSafeNavigationUrl(url: string): boolean { 365 return url.startsWith('http://') || url.startsWith('https://'); 366 } 367 368 /** Minimal URL normalization for same-page comparison: root slash + default port only. */ 369 function normalizeUrlForComparison(url?: string): string { 370 if (!url) return ''; 371 try { 372 const parsed = new URL(url); 373 if ((parsed.protocol === 'https:' && parsed.port === '443') || (parsed.protocol === 'http:' && parsed.port === '80')) { 374 parsed.port = ''; 375 } 376 const pathname = parsed.pathname === '/' ? '' : parsed.pathname; 377 return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`; 378 } catch { 379 return url; 380 } 381 } 382 383 function isTargetUrl(currentUrl: string | undefined, targetUrl: string): boolean { 384 return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); 385 } 386 387 function matchesDomain(url: string | undefined, domain: string): boolean { 388 if (!url) return false; 389 try { 390 const parsed = new URL(url); 391 return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`); 392 } catch { 393 return false; 394 } 395 } 396 397 function matchesBindCriteria(tab: chrome.tabs.Tab, cmd: Command): boolean { 398 if (!tab.id || !isDebuggableUrl(tab.url)) return false; 399 if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false; 400 if (cmd.matchPathPrefix) { 401 try { 402 const parsed = new URL(tab.url!); 403 if (!parsed.pathname.startsWith(cmd.matchPathPrefix)) return false; 404 } catch { 405 return false; 406 } 407 } 408 return true; 409 } 410 411 function getUrlOrigin(url: string | undefined): string | null { 412 if (!url) return null; 413 try { 414 return new URL(url).origin; 415 } catch { 416 return null; 417 } 418 } 419 420 function enumerateCrossOriginFrames(tree: any): Array<{ index: number; frameId: string; url: string; name: string }> { 421 const frames: Array<{ index: number; frameId: string; url: string; name: string }> = []; 422 423 function collect(node: any, accessibleOrigin: string | null) { 424 for (const child of (node.childFrames || [])) { 425 const frame = child.frame; 426 const frameUrl = frame.url || frame.unreachableUrl || ''; 427 const frameOrigin = getUrlOrigin(frameUrl); 428 429 // Mirror dom-snapshot's [F#] rules: 430 // - same-origin frames expand inline and do not get an [F#] slot 431 // - cross-origin / blocked frames get one slot and stop recursion there 432 if (accessibleOrigin && frameOrigin && frameOrigin === accessibleOrigin) { 433 collect(child, frameOrigin); 434 continue; 435 } 436 437 frames.push({ 438 index: frames.length, 439 frameId: frame.id, 440 url: frameUrl, 441 name: frame.name || '', 442 }); 443 } 444 } 445 446 const rootFrame = tree?.frameTree?.frame; 447 const rootUrl = rootFrame?.url || rootFrame?.unreachableUrl || ''; 448 collect(tree.frameTree, getUrlOrigin(rootUrl)); 449 return frames; 450 } 451 452 function setWorkspaceSession(workspace: string, session: Omit<AutomationSession, 'idleTimer' | 'idleDeadlineAt'>): void { 453 const existing = automationSessions.get(workspace); 454 if (existing?.idleTimer) clearTimeout(existing.idleTimer); 455 automationSessions.set(workspace, { 456 ...session, 457 idleTimer: null, 458 idleDeadlineAt: Date.now() + getIdleTimeout(workspace), 459 }); 460 } 461 462 /** 463 * Resolve tabId from command's page (targetId). 464 * Returns undefined if no page identity is provided. 465 */ 466 async function resolveCommandTabId(cmd: Command): Promise<number | undefined> { 467 if (cmd.page) return identity.resolveTabId(cmd.page); 468 return undefined; 469 } 470 471 type ResolvedTab = { tabId: number; tab: chrome.tabs.Tab | null }; 472 473 /** 474 * Resolve target tab in the automation window, returning both the tabId and 475 * the Tab object (when available) so callers can skip a redundant chrome.tabs.get(). 476 */ 477 async function resolveTab(tabId: number | undefined, workspace: string, initialUrl?: string): Promise<ResolvedTab> { 478 // Even when an explicit tabId is provided, validate it is still debuggable. 479 if (tabId !== undefined) { 480 try { 481 const tab = await chrome.tabs.get(tabId); 482 const session = automationSessions.get(workspace); 483 const matchesSession = session 484 ? (session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId) 485 : false; 486 if (isDebuggableUrl(tab.url) && matchesSession) return { tabId, tab }; 487 if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) { 488 // Tab drifted to another window but content is still valid. 489 // Try to move it back instead of abandoning it. 490 console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`); 491 try { 492 await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 }); 493 const moved = await chrome.tabs.get(tabId); 494 if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) { 495 return { tabId, tab: moved }; 496 } 497 } catch (moveErr) { 498 console.warn(`[opencli] Failed to move tab back: ${moveErr}`); 499 } 500 } else if (!isDebuggableUrl(tab.url)) { 501 console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); 502 } 503 } catch { 504 console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); 505 } 506 } 507 508 const existingSession = automationSessions.get(workspace); 509 if (existingSession?.preferredTabId !== null) { 510 try { 511 const preferredTab = await chrome.tabs.get(existingSession.preferredTabId); 512 if (isDebuggableUrl(preferredTab.url)) return { tabId: preferredTab.id!, tab: preferredTab }; 513 } catch { 514 automationSessions.delete(workspace); 515 } 516 } 517 518 // Get (or create) the automation window 519 const windowId = await getAutomationWindow(workspace, initialUrl); 520 521 // Prefer an existing debuggable tab 522 const tabs = await chrome.tabs.query({ windowId }); 523 const debuggableTab = tabs.find(t => t.id && isDebuggableUrl(t.url)); 524 if (debuggableTab?.id) return { tabId: debuggableTab.id, tab: debuggableTab }; 525 526 // No debuggable tab — another extension may have hijacked the tab URL. 527 const reuseTab = tabs.find(t => t.id); 528 if (reuseTab?.id) { 529 await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE }); 530 await new Promise(resolve => setTimeout(resolve, 300)); 531 try { 532 const updated = await chrome.tabs.get(reuseTab.id); 533 if (isDebuggableUrl(updated.url)) return { tabId: reuseTab.id, tab: updated }; 534 console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`); 535 } catch { 536 // Tab was closed during navigation 537 } 538 } 539 540 // Fallback: create a new tab 541 const newTab = await chrome.tabs.create({ windowId, url: BLANK_PAGE, active: true }); 542 if (!newTab.id) throw new Error('Failed to create tab in automation window'); 543 return { tabId: newTab.id, tab: newTab }; 544 } 545 546 /** Build a page-scoped success result with targetId resolved from tabId */ 547 async function pageScopedResult(id: string, tabId: number, data?: unknown): Promise<Result> { 548 const page = await identity.resolveTargetId(tabId); 549 return { id, ok: true, data, page }; 550 } 551 552 /** Convenience wrapper returning just the tabId (used by most handlers) */ 553 async function resolveTabId(tabId: number | undefined, workspace: string, initialUrl?: string): Promise<number> { 554 const resolved = await resolveTab(tabId, workspace, initialUrl); 555 return resolved.tabId; 556 } 557 558 async function listAutomationTabs(workspace: string): Promise<chrome.tabs.Tab[]> { 559 const session = automationSessions.get(workspace); 560 if (!session) return []; 561 if (session.preferredTabId !== null) { 562 try { 563 return [await chrome.tabs.get(session.preferredTabId)]; 564 } catch { 565 automationSessions.delete(workspace); 566 return []; 567 } 568 } 569 try { 570 return await chrome.tabs.query({ windowId: session.windowId }); 571 } catch { 572 automationSessions.delete(workspace); 573 return []; 574 } 575 } 576 577 async function listAutomationWebTabs(workspace: string): Promise<chrome.tabs.Tab[]> { 578 const tabs = await listAutomationTabs(workspace); 579 return tabs.filter((tab) => isDebuggableUrl(tab.url)); 580 } 581 582 async function handleExec(cmd: Command, workspace: string): Promise<Result> { 583 if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' }; 584 const cmdTabId = await resolveCommandTabId(cmd); 585 const tabId = await resolveTabId(cmdTabId, workspace); 586 try { 587 const aggressive = workspace.startsWith('browser:') || workspace.startsWith('operate:'); 588 if (cmd.frameIndex != null) { 589 const tree = await executor.getFrameTree(tabId); 590 const frames = enumerateCrossOriginFrames(tree); 591 if (cmd.frameIndex < 0 || cmd.frameIndex >= frames.length) { 592 return { id: cmd.id, ok: false, error: `Frame index ${cmd.frameIndex} out of range (${frames.length} cross-origin frames available)` }; 593 } 594 const data = await executor.evaluateInFrame(tabId, cmd.code, frames[cmd.frameIndex].frameId, aggressive); 595 return pageScopedResult(cmd.id, tabId, data); 596 } 597 const data = await executor.evaluateAsync(tabId, cmd.code, aggressive); 598 return pageScopedResult(cmd.id, tabId, data); 599 } catch (err) { 600 return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; 601 } 602 } 603 604 async function handleFrames(cmd: Command, workspace: string): Promise<Result> { 605 const cmdTabId = await resolveCommandTabId(cmd); 606 const tabId = await resolveTabId(cmdTabId, workspace); 607 try { 608 const tree = await executor.getFrameTree(tabId); 609 return { id: cmd.id, ok: true, data: enumerateCrossOriginFrames(tree) }; 610 } catch (err) { 611 return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; 612 } 613 } 614 615 async function handleNavigate(cmd: Command, workspace: string): Promise<Result> { 616 if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' }; 617 if (!isSafeNavigationUrl(cmd.url)) { 618 return { id: cmd.id, ok: false, error: 'Blocked URL scheme -- only http:// and https:// are allowed' }; 619 } 620 // Pass target URL so that first-time window creation can start on the right domain 621 const cmdTabId = await resolveCommandTabId(cmd); 622 const resolved = await resolveTab(cmdTabId, workspace, cmd.url); 623 const tabId = resolved.tabId; 624 625 const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId); 626 const beforeNormalized = normalizeUrlForComparison(beforeTab.url); 627 const targetUrl = cmd.url; 628 629 // Fast-path: tab is already at the target URL and fully loaded. 630 if (beforeTab.status === 'complete' && isTargetUrl(beforeTab.url, targetUrl)) { 631 return pageScopedResult(cmd.id, tabId, { title: beforeTab.title, url: beforeTab.url, timedOut: false }); 632 } 633 634 // Detach any existing debugger before top-level navigation unless network 635 // capture is already armed on this tab. Otherwise we would clear the capture 636 // state right before the page load we are trying to observe. 637 // Some sites (observed on creator.xiaohongshu.com flows) can invalidate the 638 // current inspected target during navigation, which leaves a stale CDP attach 639 // state and causes the next Runtime.evaluate to fail with 640 // "Inspected target navigated or closed". Resetting here forces a clean 641 // re-attach after navigation when capture is not active. 642 if (!executor.hasActiveNetworkCapture(tabId)) { 643 await executor.detach(tabId); 644 } 645 646 await chrome.tabs.update(tabId, { url: targetUrl }); 647 648 // Wait until navigation completes. Resolve when status is 'complete' AND either: 649 // - the URL matches the target (handles same-URL / canonicalized navigations), OR 650 // - the URL differs from the pre-navigation URL (handles redirects). 651 let timedOut = false; 652 await new Promise<void>((resolve) => { 653 let settled = false; 654 let checkTimer: ReturnType<typeof setTimeout> | null = null; 655 let timeoutTimer: ReturnType<typeof setTimeout> | null = null; 656 657 const finish = () => { 658 if (settled) return; 659 settled = true; 660 chrome.tabs.onUpdated.removeListener(listener); 661 if (checkTimer) clearTimeout(checkTimer); 662 if (timeoutTimer) clearTimeout(timeoutTimer); 663 resolve(); 664 }; 665 666 const isNavigationDone = (url: string | undefined): boolean => { 667 return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized; 668 }; 669 670 const listener = (id: number, info: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => { 671 if (id !== tabId) return; 672 if (info.status === 'complete' && isNavigationDone(tab.url ?? info.url)) { 673 finish(); 674 } 675 }; 676 chrome.tabs.onUpdated.addListener(listener); 677 678 // Also check if the tab already navigated (e.g. instant cache hit) 679 checkTimer = setTimeout(async () => { 680 try { 681 const currentTab = await chrome.tabs.get(tabId); 682 if (currentTab.status === 'complete' && isNavigationDone(currentTab.url)) { 683 finish(); 684 } 685 } catch { /* tab gone */ } 686 }, 100); 687 688 // Timeout fallback with warning 689 timeoutTimer = setTimeout(() => { 690 timedOut = true; 691 console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); 692 finish(); 693 }, 15000); 694 }); 695 696 let tab = await chrome.tabs.get(tabId); 697 698 // Post-navigation drift detection: if the tab moved to another window 699 // during navigation (e.g. a tab-management extension regrouped it), 700 // try to move it back to maintain session isolation. 701 const session = automationSessions.get(workspace); 702 if (session && tab.windowId !== session.windowId) { 703 console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${session.windowId}`); 704 try { 705 await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 }); 706 tab = await chrome.tabs.get(tabId); 707 } catch (moveErr) { 708 console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`); 709 } 710 } 711 712 return pageScopedResult(cmd.id, tabId, { title: tab.title, url: tab.url, timedOut }); 713 } 714 715 async function handleTabs(cmd: Command, workspace: string): Promise<Result> { 716 switch (cmd.op) { 717 case 'list': { 718 const tabs = await listAutomationWebTabs(workspace); 719 const data = await Promise.all(tabs.map(async (t, i) => { 720 let page: string | undefined; 721 try { page = t.id ? await identity.resolveTargetId(t.id) : undefined; } catch { /* skip */ } 722 return { index: i, page, url: t.url, title: t.title, active: t.active }; 723 })); 724 return { id: cmd.id, ok: true, data }; 725 } 726 case 'new': { 727 if (cmd.url && !isSafeNavigationUrl(cmd.url)) { 728 return { id: cmd.id, ok: false, error: 'Blocked URL scheme -- only http:// and https:// are allowed' }; 729 } 730 const windowId = await getAutomationWindow(workspace); 731 const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true }); 732 if (!tab.id) return { id: cmd.id, ok: false, error: 'Failed to create tab' }; 733 return pageScopedResult(cmd.id, tab.id, { url: tab.url }); 734 } 735 case 'close': { 736 if (cmd.index !== undefined) { 737 const tabs = await listAutomationWebTabs(workspace); 738 const target = tabs[cmd.index]; 739 if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; 740 const closedPage = await identity.resolveTargetId(target.id).catch(() => undefined); 741 await chrome.tabs.remove(target.id); 742 await executor.detach(target.id); 743 return { id: cmd.id, ok: true, data: { closed: closedPage } }; 744 } 745 const cmdTabId = await resolveCommandTabId(cmd); 746 const tabId = await resolveTabId(cmdTabId, workspace); 747 const closedPage = await identity.resolveTargetId(tabId).catch(() => undefined); 748 await chrome.tabs.remove(tabId); 749 await executor.detach(tabId); 750 return { id: cmd.id, ok: true, data: { closed: closedPage } }; 751 } 752 case 'select': { 753 if (cmd.index === undefined && cmd.page === undefined) 754 return { id: cmd.id, ok: false, error: 'Missing index or page' }; 755 const cmdTabId = await resolveCommandTabId(cmd); 756 if (cmdTabId !== undefined) { 757 const session = automationSessions.get(workspace); 758 let tab: chrome.tabs.Tab; 759 try { 760 tab = await chrome.tabs.get(cmdTabId); 761 } catch { 762 return { id: cmd.id, ok: false, error: `Page no longer exists` }; 763 } 764 if (!session || tab.windowId !== session.windowId) { 765 return { id: cmd.id, ok: false, error: `Page is not in the automation window` }; 766 } 767 await chrome.tabs.update(cmdTabId, { active: true }); 768 return pageScopedResult(cmd.id, cmdTabId, { selected: true }); 769 } 770 const tabs = await listAutomationWebTabs(workspace); 771 const target = tabs[cmd.index!]; 772 if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; 773 await chrome.tabs.update(target.id, { active: true }); 774 return pageScopedResult(cmd.id, target.id, { selected: true }); 775 } 776 default: 777 return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` }; 778 } 779 } 780 781 async function handleCookies(cmd: Command): Promise<Result> { 782 if (!cmd.domain && !cmd.url) { 783 return { id: cmd.id, ok: false, error: 'Cookie scope required: provide domain or url to avoid dumping all cookies' }; 784 } 785 const details: chrome.cookies.GetAllDetails = {}; 786 if (cmd.domain) details.domain = cmd.domain; 787 if (cmd.url) details.url = cmd.url; 788 const cookies = await chrome.cookies.getAll(details); 789 const data = cookies.map((c) => ({ 790 name: c.name, 791 value: c.value, 792 domain: c.domain, 793 path: c.path, 794 secure: c.secure, 795 httpOnly: c.httpOnly, 796 expirationDate: c.expirationDate, 797 })); 798 return { id: cmd.id, ok: true, data }; 799 } 800 801 async function handleScreenshot(cmd: Command, workspace: string): Promise<Result> { 802 const cmdTabId = await resolveCommandTabId(cmd); 803 const tabId = await resolveTabId(cmdTabId, workspace); 804 try { 805 const data = await executor.screenshot(tabId, { 806 format: cmd.format, 807 quality: cmd.quality, 808 fullPage: cmd.fullPage, 809 }); 810 return pageScopedResult(cmd.id, tabId, data); 811 } catch (err) { 812 return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; 813 } 814 } 815 816 /** CDP methods permitted via the 'cdp' passthrough action. */ 817 const CDP_ALLOWLIST = new Set([ 818 // Agent DOM context 819 'Accessibility.getFullAXTree', 820 'DOM.getDocument', 821 'DOM.getBoxModel', 822 'DOM.getContentQuads', 823 'DOM.querySelectorAll', 824 'DOM.scrollIntoViewIfNeeded', 825 'DOMSnapshot.captureSnapshot', 826 // Native input events 827 'Input.dispatchMouseEvent', 828 'Input.dispatchKeyEvent', 829 'Input.insertText', 830 // Page metrics & screenshots 831 'Page.getLayoutMetrics', 832 'Page.captureScreenshot', 833 'Page.getFrameTree', 834 // Runtime.enable needed for CDP attach setup (Runtime.evaluate goes through 'exec' action) 835 'Runtime.enable', 836 // Emulation (used by screenshot full-page) 837 'Emulation.setDeviceMetricsOverride', 838 'Emulation.clearDeviceMetricsOverride', 839 ]); 840 841 async function handleCdp(cmd: Command, workspace: string): Promise<Result> { 842 if (!cmd.cdpMethod) return { id: cmd.id, ok: false, error: 'Missing cdpMethod' }; 843 if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) { 844 return { id: cmd.id, ok: false, error: `CDP method not permitted: ${cmd.cdpMethod}` }; 845 } 846 const cmdTabId = await resolveCommandTabId(cmd); 847 const tabId = await resolveTabId(cmdTabId, workspace); 848 try { 849 const aggressive = workspace.startsWith('browser:') || workspace.startsWith('operate:'); 850 await executor.ensureAttached(tabId, aggressive); 851 const data = await chrome.debugger.sendCommand( 852 { tabId }, 853 cmd.cdpMethod, 854 cmd.cdpParams ?? {}, 855 ); 856 return pageScopedResult(cmd.id, tabId, data); 857 } catch (err) { 858 return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; 859 } 860 } 861 862 async function handleCloseWindow(cmd: Command, workspace: string): Promise<Result> { 863 const session = automationSessions.get(workspace); 864 if (session) { 865 if (session.owned) { 866 try { 867 await chrome.windows.remove(session.windowId); 868 } catch { 869 // Window may already be closed 870 } 871 } 872 if (session.idleTimer) clearTimeout(session.idleTimer); 873 workspaceTimeoutOverrides.delete(workspace); 874 automationSessions.delete(workspace); 875 } 876 return { id: cmd.id, ok: true, data: { closed: true } }; 877 } 878 879 async function handleSetFileInput(cmd: Command, workspace: string): Promise<Result> { 880 if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) { 881 return { id: cmd.id, ok: false, error: 'Missing or empty files array' }; 882 } 883 const cmdTabId = await resolveCommandTabId(cmd); 884 const tabId = await resolveTabId(cmdTabId, workspace); 885 try { 886 await executor.setFileInputFiles(tabId, cmd.files, cmd.selector); 887 return pageScopedResult(cmd.id, tabId, { count: cmd.files.length }); 888 } catch (err) { 889 return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; 890 } 891 } 892 893 async function handleInsertText(cmd: Command, workspace: string): Promise<Result> { 894 if (typeof cmd.text !== 'string') { 895 return { id: cmd.id, ok: false, error: 'Missing text payload' }; 896 } 897 const cmdTabId = await resolveCommandTabId(cmd); 898 const tabId = await resolveTabId(cmdTabId, workspace); 899 try { 900 await executor.insertText(tabId, cmd.text); 901 return pageScopedResult(cmd.id, tabId, { inserted: true }); 902 } catch (err) { 903 return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; 904 } 905 } 906 907 async function handleNetworkCaptureStart(cmd: Command, workspace: string): Promise<Result> { 908 const cmdTabId = await resolveCommandTabId(cmd); 909 const tabId = await resolveTabId(cmdTabId, workspace); 910 try { 911 await executor.startNetworkCapture(tabId, cmd.pattern); 912 return pageScopedResult(cmd.id, tabId, { started: true }); 913 } catch (err) { 914 return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; 915 } 916 } 917 918 async function handleNetworkCaptureRead(cmd: Command, workspace: string): Promise<Result> { 919 const cmdTabId = await resolveCommandTabId(cmd); 920 const tabId = await resolveTabId(cmdTabId, workspace); 921 try { 922 const data = await executor.readNetworkCapture(tabId); 923 return pageScopedResult(cmd.id, tabId, data); 924 } catch (err) { 925 return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; 926 } 927 } 928 929 async function handleSessions(cmd: Command): Promise<Result> { 930 const now = Date.now(); 931 const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({ 932 workspace, 933 windowId: session.windowId, 934 tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length, 935 idleMsRemaining: Math.max(0, session.idleDeadlineAt - now), 936 }))); 937 return { id: cmd.id, ok: true, data }; 938 } 939 940 async function handleBindCurrent(cmd: Command, workspace: string): Promise<Result> { 941 const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); 942 const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true }); 943 const allTabs = await chrome.tabs.query({}); 944 const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd)) 945 ?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd)) 946 ?? allTabs.find((tab) => matchesBindCriteria(tab, cmd)); 947 if (!boundTab?.id) { 948 return { 949 id: cmd.id, 950 ok: false, 951 error: cmd.matchDomain || cmd.matchPathPrefix 952 ? `No visible tab matching ${cmd.matchDomain ?? 'domain'}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ''}` 953 : 'No active debuggable tab found', 954 }; 955 } 956 957 setWorkspaceSession(workspace, { 958 windowId: boundTab.windowId, 959 owned: false, 960 preferredTabId: boundTab.id, 961 }); 962 resetWindowIdleTimer(workspace); 963 console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`); 964 return pageScopedResult(cmd.id, boundTab.id, { 965 url: boundTab.url, 966 title: boundTab.title, 967 workspace, 968 }); 969 } 970 971 export const __test__ = { 972 handleExec, 973 handleNavigate, 974 isTargetUrl, 975 handleTabs, 976 handleSessions, 977 handleBindCurrent, 978 resolveTabId, 979 resetWindowIdleTimer, 980 handleCommand, 981 getIdleTimeout, 982 workspaceTimeoutOverrides, 983 getSession: (workspace: string = 'default') => automationSessions.get(workspace) ?? null, 984 getAutomationWindowId: (workspace: string = 'default') => automationSessions.get(workspace)?.windowId ?? null, 985 setAutomationWindowId: (workspace: string, windowId: number | null) => { 986 if (windowId === null) { 987 const session = automationSessions.get(workspace); 988 if (session?.idleTimer) clearTimeout(session.idleTimer); 989 automationSessions.delete(workspace); 990 return; 991 } 992 setWorkspaceSession(workspace, { 993 windowId, 994 owned: true, 995 preferredTabId: null, 996 }); 997 }, 998 setSession: (workspace: string, session: { windowId: number; owned: boolean; preferredTabId: number | null }) => { 999 setWorkspaceSession(workspace, session); 1000 }, 1001 };