background.js
1 //#region src/protocol.ts 2 /** Default daemon port */ 3 var DAEMON_PORT = 19825; 4 var DAEMON_HOST = "localhost"; 5 var DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; 6 /** Lightweight health-check endpoint — probed before each WebSocket attempt. */ 7 var DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; 8 /** Base reconnect delay for extension WebSocket (ms) */ 9 var WS_RECONNECT_BASE_DELAY = 2e3; 10 /** Max reconnect delay (ms) — kept short since daemon is long-lived */ 11 var WS_RECONNECT_MAX_DELAY = 5e3; 12 //#endregion 13 //#region src/cdp.ts 14 /** 15 * CDP execution via chrome.debugger API. 16 * 17 * chrome.debugger only needs the "debugger" permission — no host_permissions. 18 * It can attach to any http/https tab. Avoid chrome:// and chrome-extension:// 19 * tabs (resolveTabId in background.ts filters them). 20 */ 21 var attached = /* @__PURE__ */ new Set(); 22 var networkCaptures = /* @__PURE__ */ new Map(); 23 /** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */ 24 function isDebuggableUrl$1(url) { 25 if (!url) return true; 26 return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); 27 } 28 async function ensureAttached(tabId, aggressiveRetry = false) { 29 try { 30 const tab = await chrome.tabs.get(tabId); 31 if (!isDebuggableUrl$1(tab.url)) { 32 attached.delete(tabId); 33 throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`); 34 } 35 } catch (e) { 36 if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e; 37 attached.delete(tabId); 38 throw new Error(`Tab ${tabId} no longer exists`); 39 } 40 if (attached.has(tabId)) try { 41 await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { 42 expression: "1", 43 returnByValue: true 44 }); 45 return; 46 } catch { 47 attached.delete(tabId); 48 } 49 const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2; 50 const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500; 51 let lastError = ""; 52 for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) try { 53 try { 54 await chrome.debugger.detach({ tabId }); 55 } catch {} 56 await chrome.debugger.attach({ tabId }, "1.3"); 57 lastError = ""; 58 break; 59 } catch (e) { 60 lastError = e instanceof Error ? e.message : String(e); 61 if (attempt < MAX_ATTACH_RETRIES) { 62 console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`); 63 await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); 64 try { 65 const tab = await chrome.tabs.get(tabId); 66 if (!isDebuggableUrl$1(tab.url)) { 67 lastError = `Tab URL changed to ${tab.url} during retry`; 68 break; 69 } 70 } catch { 71 lastError = `Tab ${tabId} no longer exists`; 72 } 73 } 74 } 75 if (lastError) { 76 let finalUrl = "unknown"; 77 let finalWindowId = "unknown"; 78 try { 79 const tab = await chrome.tabs.get(tabId); 80 finalUrl = tab.url ?? "undefined"; 81 finalWindowId = String(tab.windowId); 82 } catch {} 83 console.warn(`[opencli] attach failed for tab ${tabId}: url=${finalUrl}, windowId=${finalWindowId}, error=${lastError}`); 84 const hint = lastError.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : ""; 85 throw new Error(`attach failed: ${lastError}${hint}`); 86 } 87 attached.add(tabId); 88 try { 89 await chrome.debugger.sendCommand({ tabId }, "Runtime.enable"); 90 } catch {} 91 } 92 async function evaluate(tabId, expression, aggressiveRetry = false) { 93 const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2; 94 for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) try { 95 await ensureAttached(tabId, aggressiveRetry); 96 const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { 97 expression, 98 returnByValue: true, 99 awaitPromise: true 100 }); 101 if (result.exceptionDetails) { 102 const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; 103 throw new Error(errMsg); 104 } 105 return result.result?.value; 106 } catch (e) { 107 const msg = e instanceof Error ? e.message : String(e); 108 const isNavigateError = msg.includes("Inspected target navigated") || msg.includes("Target closed"); 109 if ((isNavigateError || msg.includes("attach failed") || msg.includes("Debugger is not attached") || msg.includes("chrome-extension://")) && attempt < MAX_EVAL_RETRIES) { 110 attached.delete(tabId); 111 const retryMs = isNavigateError ? 200 : 500; 112 await new Promise((resolve) => setTimeout(resolve, retryMs)); 113 continue; 114 } 115 throw e; 116 } 117 throw new Error("evaluate: max retries exhausted"); 118 } 119 var evaluateAsync = evaluate; 120 /** 121 * Capture a screenshot via CDP Page.captureScreenshot. 122 * Returns base64-encoded image data. 123 */ 124 async function screenshot(tabId, options = {}) { 125 await ensureAttached(tabId); 126 const format = options.format ?? "png"; 127 if (options.fullPage) { 128 const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics"); 129 const size = metrics.cssContentSize || metrics.contentSize; 130 if (size) await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { 131 mobile: false, 132 width: Math.ceil(size.width), 133 height: Math.ceil(size.height), 134 deviceScaleFactor: 1 135 }); 136 } 137 try { 138 const params = { format }; 139 if (format === "jpeg" && options.quality !== void 0) params.quality = Math.max(0, Math.min(100, options.quality)); 140 return (await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params)).data; 141 } finally { 142 if (options.fullPage) await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => {}); 143 } 144 } 145 /** 146 * Set local file paths on a file input element via CDP DOM.setFileInputFiles. 147 * This bypasses the need to send large base64 payloads through the message channel — 148 * Chrome reads the files directly from the local filesystem. 149 * 150 * @param tabId - Target tab ID 151 * @param files - Array of absolute local file paths 152 * @param selector - CSS selector to find the file input (optional, defaults to first file input) 153 */ 154 async function setFileInputFiles(tabId, files, selector) { 155 await ensureAttached(tabId); 156 await chrome.debugger.sendCommand({ tabId }, "DOM.enable"); 157 const doc = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument"); 158 const query = selector || "input[type=\"file\"]"; 159 const result = await chrome.debugger.sendCommand({ tabId }, "DOM.querySelector", { 160 nodeId: doc.root.nodeId, 161 selector: query 162 }); 163 if (!result.nodeId) throw new Error(`No element found matching selector: ${query}`); 164 await chrome.debugger.sendCommand({ tabId }, "DOM.setFileInputFiles", { 165 files, 166 nodeId: result.nodeId 167 }); 168 } 169 async function insertText(tabId, text) { 170 await ensureAttached(tabId); 171 await chrome.debugger.sendCommand({ tabId }, "Input.insertText", { text }); 172 } 173 function normalizeCapturePatterns(pattern) { 174 return String(pattern || "").split("|").map((part) => part.trim()).filter(Boolean); 175 } 176 function shouldCaptureUrl(url, patterns) { 177 if (!url) return false; 178 if (!patterns.length) return true; 179 return patterns.some((pattern) => url.includes(pattern)); 180 } 181 function normalizeHeaders(headers) { 182 if (!headers || typeof headers !== "object") return {}; 183 const out = {}; 184 for (const [key, value] of Object.entries(headers)) out[String(key)] = String(value); 185 return out; 186 } 187 function getOrCreateNetworkCaptureEntry(tabId, requestId, fallback) { 188 const state = networkCaptures.get(tabId); 189 if (!state) return null; 190 const existingIndex = state.requestToIndex.get(requestId); 191 if (existingIndex !== void 0) return state.entries[existingIndex] || null; 192 const url = fallback?.url || ""; 193 if (!shouldCaptureUrl(url, state.patterns)) return null; 194 const entry = { 195 kind: "cdp", 196 url, 197 method: fallback?.method || "GET", 198 requestHeaders: fallback?.requestHeaders || {}, 199 timestamp: Date.now() 200 }; 201 state.entries.push(entry); 202 state.requestToIndex.set(requestId, state.entries.length - 1); 203 return entry; 204 } 205 async function startNetworkCapture(tabId, pattern) { 206 await ensureAttached(tabId); 207 await chrome.debugger.sendCommand({ tabId }, "Network.enable"); 208 networkCaptures.set(tabId, { 209 patterns: normalizeCapturePatterns(pattern), 210 entries: [], 211 requestToIndex: /* @__PURE__ */ new Map() 212 }); 213 } 214 async function readNetworkCapture(tabId) { 215 const state = networkCaptures.get(tabId); 216 if (!state) return []; 217 const entries = state.entries.slice(); 218 state.entries = []; 219 state.requestToIndex.clear(); 220 return entries; 221 } 222 function hasActiveNetworkCapture(tabId) { 223 return networkCaptures.has(tabId); 224 } 225 async function detach(tabId) { 226 if (!attached.has(tabId)) return; 227 attached.delete(tabId); 228 networkCaptures.delete(tabId); 229 try { 230 await chrome.debugger.detach({ tabId }); 231 } catch {} 232 } 233 function registerListeners() { 234 chrome.tabs.onRemoved.addListener((tabId) => { 235 attached.delete(tabId); 236 networkCaptures.delete(tabId); 237 }); 238 chrome.debugger.onDetach.addListener((source) => { 239 if (source.tabId) { 240 attached.delete(source.tabId); 241 networkCaptures.delete(source.tabId); 242 } 243 }); 244 chrome.tabs.onUpdated.addListener(async (tabId, info) => { 245 if (info.url && !isDebuggableUrl$1(info.url)) await detach(tabId); 246 }); 247 chrome.debugger.onEvent.addListener(async (source, method, params) => { 248 const tabId = source.tabId; 249 if (!tabId) return; 250 const state = networkCaptures.get(tabId); 251 if (!state) return; 252 if (method === "Network.requestWillBeSent") { 253 const requestId = String(params?.requestId || ""); 254 const request = params?.request; 255 const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { 256 url: request?.url, 257 method: request?.method, 258 requestHeaders: normalizeHeaders(request?.headers) 259 }); 260 if (!entry) return; 261 entry.requestBodyKind = request?.hasPostData ? "string" : "empty"; 262 entry.requestBodyPreview = String(request?.postData || "").slice(0, 4e3); 263 try { 264 const postData = await chrome.debugger.sendCommand({ tabId }, "Network.getRequestPostData", { requestId }); 265 if (postData?.postData) { 266 entry.requestBodyKind = "string"; 267 entry.requestBodyPreview = postData.postData.slice(0, 4e3); 268 } 269 } catch {} 270 return; 271 } 272 if (method === "Network.responseReceived") { 273 const requestId = String(params?.requestId || ""); 274 const response = params?.response; 275 const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { url: response?.url }); 276 if (!entry) return; 277 entry.responseStatus = response?.status; 278 entry.responseContentType = response?.mimeType || ""; 279 entry.responseHeaders = normalizeHeaders(response?.headers); 280 return; 281 } 282 if (method === "Network.loadingFinished") { 283 const requestId = String(params?.requestId || ""); 284 const stateEntryIndex = state.requestToIndex.get(requestId); 285 if (stateEntryIndex === void 0) return; 286 const entry = state.entries[stateEntryIndex]; 287 if (!entry) return; 288 try { 289 const body = await chrome.debugger.sendCommand({ tabId }, "Network.getResponseBody", { requestId }); 290 if (typeof body?.body === "string") entry.responsePreview = body.base64Encoded ? `base64:${body.body.slice(0, 4e3)}` : body.body.slice(0, 4e3); 291 } catch {} 292 } 293 }); 294 } 295 //#endregion 296 //#region src/identity.ts 297 /** 298 * Page identity mapping — targetId ↔ tabId. 299 * 300 * targetId is the cross-layer page identity (CDP target UUID). 301 * tabId is an internal Chrome Tabs API routing detail — never exposed outside the extension. 302 * 303 * Lifecycle: 304 * - Cache populated lazily via chrome.debugger.getTargets() 305 * - Evicted on tab close (chrome.tabs.onRemoved) 306 * - Miss triggers full refresh; refresh miss → hard error (no guessing) 307 */ 308 var targetToTab = /* @__PURE__ */ new Map(); 309 var tabToTarget = /* @__PURE__ */ new Map(); 310 /** 311 * Resolve targetId for a given tabId. 312 * Returns cached value if available; on miss, refreshes from chrome.debugger.getTargets(). 313 * Throws if no targetId can be found (page may have been destroyed). 314 */ 315 async function resolveTargetId(tabId) { 316 const cached = tabToTarget.get(tabId); 317 if (cached) return cached; 318 await refreshMappings(); 319 const result = tabToTarget.get(tabId); 320 if (!result) throw new Error(`No targetId for tab ${tabId} — page may have been closed`); 321 return result; 322 } 323 /** 324 * Resolve tabId for a given targetId. 325 * Returns cached value if available; on miss, refreshes from chrome.debugger.getTargets(). 326 * Throws if no tabId can be found — never falls back to guessing. 327 */ 328 async function resolveTabId$1(targetId) { 329 const cached = targetToTab.get(targetId); 330 if (cached !== void 0) return cached; 331 await refreshMappings(); 332 const result = targetToTab.get(targetId); 333 if (result === void 0) throw new Error(`Page not found: ${targetId} — stale page identity`); 334 return result; 335 } 336 /** 337 * Remove mappings for a closed tab. 338 * Called from chrome.tabs.onRemoved listener. 339 */ 340 function evictTab(tabId) { 341 const targetId = tabToTarget.get(tabId); 342 if (targetId) targetToTab.delete(targetId); 343 tabToTarget.delete(tabId); 344 } 345 /** 346 * Full refresh of targetId ↔ tabId mappings from chrome.debugger.getTargets(). 347 */ 348 async function refreshMappings() { 349 const targets = await chrome.debugger.getTargets(); 350 targetToTab.clear(); 351 tabToTarget.clear(); 352 for (const t of targets) if (t.type === "page" && t.tabId !== void 0) { 353 targetToTab.set(t.id, t.tabId); 354 tabToTarget.set(t.tabId, t.id); 355 } 356 } 357 //#endregion 358 //#region src/background.ts 359 var ws = null; 360 var reconnectTimer = null; 361 var reconnectAttempts = 0; 362 var _origLog = console.log.bind(console); 363 var _origWarn = console.warn.bind(console); 364 var _origError = console.error.bind(console); 365 function forwardLog(level, args) { 366 if (!ws || ws.readyState !== WebSocket.OPEN) return; 367 try { 368 const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); 369 ws.send(JSON.stringify({ 370 type: "log", 371 level, 372 msg, 373 ts: Date.now() 374 })); 375 } catch {} 376 } 377 console.log = (...args) => { 378 _origLog(...args); 379 forwardLog("info", args); 380 }; 381 console.warn = (...args) => { 382 _origWarn(...args); 383 forwardLog("warn", args); 384 }; 385 console.error = (...args) => { 386 _origError(...args); 387 forwardLog("error", args); 388 }; 389 /** 390 * Probe the daemon via its /ping HTTP endpoint before attempting a WebSocket 391 * connection. fetch() failures are silently catchable; new WebSocket() is not 392 * — Chrome logs ERR_CONNECTION_REFUSED to the extension error page before any 393 * JS handler can intercept it. By keeping the probe inside connect() every 394 * call site remains unchanged and the guard can never be accidentally skipped. 395 */ 396 async function connect() { 397 if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; 398 try { 399 if (!(await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) })).ok) return; 400 } catch { 401 return; 402 } 403 try { 404 ws = new WebSocket(DAEMON_WS_URL); 405 } catch { 406 scheduleReconnect(); 407 return; 408 } 409 ws.onopen = () => { 410 console.log("[opencli] Connected to daemon"); 411 reconnectAttempts = 0; 412 if (reconnectTimer) { 413 clearTimeout(reconnectTimer); 414 reconnectTimer = null; 415 } 416 ws?.send(JSON.stringify({ 417 type: "hello", 418 version: chrome.runtime.getManifest().version, 419 compatRange: ">=1.7.0" 420 })); 421 }; 422 ws.onmessage = async (event) => { 423 try { 424 const result = await handleCommand(JSON.parse(event.data)); 425 ws?.send(JSON.stringify(result)); 426 } catch (err) { 427 console.error("[opencli] Message handling error:", err); 428 } 429 }; 430 ws.onclose = () => { 431 console.log("[opencli] Disconnected from daemon"); 432 ws = null; 433 scheduleReconnect(); 434 }; 435 ws.onerror = () => { 436 ws?.close(); 437 }; 438 } 439 /** 440 * After MAX_EAGER_ATTEMPTS (reaching 60s backoff), stop scheduling reconnects. 441 * The keepalive alarm (~24s) will still call connect() periodically, but at a 442 * much lower frequency — reducing console noise when the daemon is not running. 443 */ 444 var MAX_EAGER_ATTEMPTS = 6; 445 function scheduleReconnect() { 446 if (reconnectTimer) return; 447 reconnectAttempts++; 448 if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; 449 const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); 450 reconnectTimer = setTimeout(() => { 451 reconnectTimer = null; 452 connect(); 453 }, delay); 454 } 455 var automationSessions = /* @__PURE__ */ new Map(); 456 var IDLE_TIMEOUT_DEFAULT = 3e4; 457 var IDLE_TIMEOUT_INTERACTIVE = 6e5; 458 /** Per-workspace custom timeout overrides set via command.idleTimeout */ 459 var workspaceTimeoutOverrides = /* @__PURE__ */ new Map(); 460 function getIdleTimeout(workspace) { 461 const override = workspaceTimeoutOverrides.get(workspace); 462 if (override !== void 0) return override; 463 if (workspace.startsWith("browser:") || workspace.startsWith("operate:")) return IDLE_TIMEOUT_INTERACTIVE; 464 return IDLE_TIMEOUT_DEFAULT; 465 } 466 var windowFocused = false; 467 function getWorkspaceKey(workspace) { 468 return workspace?.trim() || "default"; 469 } 470 function resetWindowIdleTimer(workspace) { 471 const session = automationSessions.get(workspace); 472 if (!session) return; 473 if (session.idleTimer) clearTimeout(session.idleTimer); 474 const timeout = getIdleTimeout(workspace); 475 session.idleDeadlineAt = Date.now() + timeout; 476 session.idleTimer = setTimeout(async () => { 477 const current = automationSessions.get(workspace); 478 if (!current) return; 479 if (!current.owned) { 480 console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`); 481 workspaceTimeoutOverrides.delete(workspace); 482 automationSessions.delete(workspace); 483 return; 484 } 485 try { 486 await chrome.windows.remove(current.windowId); 487 console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout, ${timeout / 1e3}s)`); 488 } catch {} 489 workspaceTimeoutOverrides.delete(workspace); 490 automationSessions.delete(workspace); 491 }, timeout); 492 } 493 /** Get or create the dedicated automation window. 494 * @param initialUrl — if provided (http/https), used as the initial page instead of about:blank. 495 * This avoids an extra blank-page→target-domain navigation on first command. 496 */ 497 async function getAutomationWindow(workspace, initialUrl) { 498 const existing = automationSessions.get(workspace); 499 if (existing) try { 500 await chrome.windows.get(existing.windowId); 501 return existing.windowId; 502 } catch { 503 automationSessions.delete(workspace); 504 } 505 const startUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; 506 const win = await chrome.windows.create({ 507 url: startUrl, 508 focused: windowFocused, 509 width: 1280, 510 height: 900, 511 type: "normal" 512 }); 513 const session = { 514 windowId: win.id, 515 idleTimer: null, 516 idleDeadlineAt: Date.now() + getIdleTimeout(workspace), 517 owned: true, 518 preferredTabId: null 519 }; 520 automationSessions.set(workspace, session); 521 console.log(`[opencli] Created automation window ${session.windowId} (${workspace}, start=${startUrl})`); 522 resetWindowIdleTimer(workspace); 523 const tabs = await chrome.tabs.query({ windowId: win.id }); 524 if (tabs[0]?.id) await new Promise((resolve) => { 525 const timeout = setTimeout(resolve, 500); 526 const listener = (tabId, info) => { 527 if (tabId === tabs[0].id && info.status === "complete") { 528 chrome.tabs.onUpdated.removeListener(listener); 529 clearTimeout(timeout); 530 resolve(); 531 } 532 }; 533 if (tabs[0].status === "complete") { 534 clearTimeout(timeout); 535 resolve(); 536 } else chrome.tabs.onUpdated.addListener(listener); 537 }); 538 return session.windowId; 539 } 540 chrome.windows.onRemoved.addListener(async (windowId) => { 541 for (const [workspace, session] of automationSessions.entries()) if (session.windowId === windowId) { 542 console.log(`[opencli] Automation window closed (${workspace})`); 543 if (session.idleTimer) clearTimeout(session.idleTimer); 544 automationSessions.delete(workspace); 545 workspaceTimeoutOverrides.delete(workspace); 546 } 547 }); 548 chrome.tabs.onRemoved.addListener((tabId) => { 549 evictTab(tabId); 550 }); 551 var initialized = false; 552 function initialize() { 553 if (initialized) return; 554 initialized = true; 555 chrome.alarms.create("keepalive", { periodInMinutes: .4 }); 556 registerListeners(); 557 connect(); 558 console.log("[opencli] OpenCLI extension initialized"); 559 } 560 chrome.runtime.onInstalled.addListener(() => { 561 initialize(); 562 }); 563 chrome.runtime.onStartup.addListener(() => { 564 initialize(); 565 }); 566 chrome.alarms.onAlarm.addListener((alarm) => { 567 if (alarm.name === "keepalive") connect(); 568 }); 569 chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { 570 if (msg?.type === "getStatus") sendResponse({ 571 connected: ws?.readyState === WebSocket.OPEN, 572 reconnecting: reconnectTimer !== null 573 }); 574 return false; 575 }); 576 async function handleCommand(cmd) { 577 const workspace = getWorkspaceKey(cmd.workspace); 578 windowFocused = cmd.windowFocused === true; 579 if (cmd.idleTimeout != null && cmd.idleTimeout > 0) workspaceTimeoutOverrides.set(workspace, cmd.idleTimeout * 1e3); 580 resetWindowIdleTimer(workspace); 581 try { 582 switch (cmd.action) { 583 case "exec": return await handleExec(cmd, workspace); 584 case "navigate": return await handleNavigate(cmd, workspace); 585 case "tabs": return await handleTabs(cmd, workspace); 586 case "cookies": return await handleCookies(cmd); 587 case "screenshot": return await handleScreenshot(cmd, workspace); 588 case "close-window": return await handleCloseWindow(cmd, workspace); 589 case "cdp": return await handleCdp(cmd, workspace); 590 case "sessions": return await handleSessions(cmd); 591 case "set-file-input": return await handleSetFileInput(cmd, workspace); 592 case "insert-text": return await handleInsertText(cmd, workspace); 593 case "bind-current": return await handleBindCurrent(cmd, workspace); 594 case "network-capture-start": return await handleNetworkCaptureStart(cmd, workspace); 595 case "network-capture-read": return await handleNetworkCaptureRead(cmd, workspace); 596 default: return { 597 id: cmd.id, 598 ok: false, 599 error: `Unknown action: ${cmd.action}` 600 }; 601 } 602 } catch (err) { 603 return { 604 id: cmd.id, 605 ok: false, 606 error: err instanceof Error ? err.message : String(err) 607 }; 608 } 609 } 610 /** Internal blank page used when no user URL is provided. */ 611 var BLANK_PAGE = "about:blank"; 612 /** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */ 613 function isDebuggableUrl(url) { 614 if (!url) return true; 615 return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); 616 } 617 /** Check if a URL is safe for user-facing navigation (http/https only). */ 618 function isSafeNavigationUrl(url) { 619 return url.startsWith("http://") || url.startsWith("https://"); 620 } 621 /** Minimal URL normalization for same-page comparison: root slash + default port only. */ 622 function normalizeUrlForComparison(url) { 623 if (!url) return ""; 624 try { 625 const parsed = new URL(url); 626 if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") parsed.port = ""; 627 const pathname = parsed.pathname === "/" ? "" : parsed.pathname; 628 return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`; 629 } catch { 630 return url; 631 } 632 } 633 function isTargetUrl(currentUrl, targetUrl) { 634 return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); 635 } 636 function matchesDomain(url, domain) { 637 if (!url) return false; 638 try { 639 const parsed = new URL(url); 640 return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`); 641 } catch { 642 return false; 643 } 644 } 645 function matchesBindCriteria(tab, cmd) { 646 if (!tab.id || !isDebuggableUrl(tab.url)) return false; 647 if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false; 648 if (cmd.matchPathPrefix) try { 649 if (!new URL(tab.url).pathname.startsWith(cmd.matchPathPrefix)) return false; 650 } catch { 651 return false; 652 } 653 return true; 654 } 655 function setWorkspaceSession(workspace, session) { 656 const existing = automationSessions.get(workspace); 657 if (existing?.idleTimer) clearTimeout(existing.idleTimer); 658 automationSessions.set(workspace, { 659 ...session, 660 idleTimer: null, 661 idleDeadlineAt: Date.now() + getIdleTimeout(workspace) 662 }); 663 } 664 /** 665 * Resolve tabId from command's page (targetId). 666 * Returns undefined if no page identity is provided. 667 */ 668 async function resolveCommandTabId(cmd) { 669 if (cmd.page) return resolveTabId$1(cmd.page); 670 } 671 /** 672 * Resolve target tab in the automation window, returning both the tabId and 673 * the Tab object (when available) so callers can skip a redundant chrome.tabs.get(). 674 */ 675 async function resolveTab(tabId, workspace, initialUrl) { 676 if (tabId !== void 0) try { 677 const tab = await chrome.tabs.get(tabId); 678 const session = automationSessions.get(workspace); 679 const matchesSession = session ? session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId : false; 680 if (isDebuggableUrl(tab.url) && matchesSession) return { 681 tabId, 682 tab 683 }; 684 if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) { 685 console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`); 686 try { 687 await chrome.tabs.move(tabId, { 688 windowId: session.windowId, 689 index: -1 690 }); 691 const moved = await chrome.tabs.get(tabId); 692 if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) return { 693 tabId, 694 tab: moved 695 }; 696 } catch (moveErr) { 697 console.warn(`[opencli] Failed to move tab back: ${moveErr}`); 698 } 699 } else if (!isDebuggableUrl(tab.url)) console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); 700 } catch { 701 console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); 702 } 703 const existingSession = automationSessions.get(workspace); 704 if (existingSession?.preferredTabId !== null) try { 705 const preferredTab = await chrome.tabs.get(existingSession.preferredTabId); 706 if (isDebuggableUrl(preferredTab.url)) return { 707 tabId: preferredTab.id, 708 tab: preferredTab 709 }; 710 } catch { 711 automationSessions.delete(workspace); 712 } 713 const windowId = await getAutomationWindow(workspace, initialUrl); 714 const tabs = await chrome.tabs.query({ windowId }); 715 const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url)); 716 if (debuggableTab?.id) return { 717 tabId: debuggableTab.id, 718 tab: debuggableTab 719 }; 720 const reuseTab = tabs.find((t) => t.id); 721 if (reuseTab?.id) { 722 await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE }); 723 await new Promise((resolve) => setTimeout(resolve, 300)); 724 try { 725 const updated = await chrome.tabs.get(reuseTab.id); 726 if (isDebuggableUrl(updated.url)) return { 727 tabId: reuseTab.id, 728 tab: updated 729 }; 730 console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`); 731 } catch {} 732 } 733 const newTab = await chrome.tabs.create({ 734 windowId, 735 url: BLANK_PAGE, 736 active: true 737 }); 738 if (!newTab.id) throw new Error("Failed to create tab in automation window"); 739 return { 740 tabId: newTab.id, 741 tab: newTab 742 }; 743 } 744 /** Build a page-scoped success result with targetId resolved from tabId */ 745 async function pageScopedResult(id, tabId, data) { 746 return { 747 id, 748 ok: true, 749 data, 750 page: await resolveTargetId(tabId) 751 }; 752 } 753 /** Convenience wrapper returning just the tabId (used by most handlers) */ 754 async function resolveTabId(tabId, workspace, initialUrl) { 755 return (await resolveTab(tabId, workspace, initialUrl)).tabId; 756 } 757 async function listAutomationTabs(workspace) { 758 const session = automationSessions.get(workspace); 759 if (!session) return []; 760 if (session.preferredTabId !== null) try { 761 return [await chrome.tabs.get(session.preferredTabId)]; 762 } catch { 763 automationSessions.delete(workspace); 764 return []; 765 } 766 try { 767 return await chrome.tabs.query({ windowId: session.windowId }); 768 } catch { 769 automationSessions.delete(workspace); 770 return []; 771 } 772 } 773 async function listAutomationWebTabs(workspace) { 774 return (await listAutomationTabs(workspace)).filter((tab) => isDebuggableUrl(tab.url)); 775 } 776 async function handleExec(cmd, workspace) { 777 if (!cmd.code) return { 778 id: cmd.id, 779 ok: false, 780 error: "Missing code" 781 }; 782 const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); 783 try { 784 const aggressive = workspace.startsWith("browser:") || workspace.startsWith("operate:"); 785 const data = await evaluateAsync(tabId, cmd.code, aggressive); 786 return pageScopedResult(cmd.id, tabId, data); 787 } catch (err) { 788 return { 789 id: cmd.id, 790 ok: false, 791 error: err instanceof Error ? err.message : String(err) 792 }; 793 } 794 } 795 async function handleNavigate(cmd, workspace) { 796 if (!cmd.url) return { 797 id: cmd.id, 798 ok: false, 799 error: "Missing url" 800 }; 801 if (!isSafeNavigationUrl(cmd.url)) return { 802 id: cmd.id, 803 ok: false, 804 error: "Blocked URL scheme -- only http:// and https:// are allowed" 805 }; 806 const resolved = await resolveTab(await resolveCommandTabId(cmd), workspace, cmd.url); 807 const tabId = resolved.tabId; 808 const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId); 809 const beforeNormalized = normalizeUrlForComparison(beforeTab.url); 810 const targetUrl = cmd.url; 811 if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) return pageScopedResult(cmd.id, tabId, { 812 title: beforeTab.title, 813 url: beforeTab.url, 814 timedOut: false 815 }); 816 if (!hasActiveNetworkCapture(tabId)) await detach(tabId); 817 await chrome.tabs.update(tabId, { url: targetUrl }); 818 let timedOut = false; 819 await new Promise((resolve) => { 820 let settled = false; 821 let checkTimer = null; 822 let timeoutTimer = null; 823 const finish = () => { 824 if (settled) return; 825 settled = true; 826 chrome.tabs.onUpdated.removeListener(listener); 827 if (checkTimer) clearTimeout(checkTimer); 828 if (timeoutTimer) clearTimeout(timeoutTimer); 829 resolve(); 830 }; 831 const isNavigationDone = (url) => { 832 return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized; 833 }; 834 const listener = (id, info, tab) => { 835 if (id !== tabId) return; 836 if (info.status === "complete" && isNavigationDone(tab.url ?? info.url)) finish(); 837 }; 838 chrome.tabs.onUpdated.addListener(listener); 839 checkTimer = setTimeout(async () => { 840 try { 841 const currentTab = await chrome.tabs.get(tabId); 842 if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) finish(); 843 } catch {} 844 }, 100); 845 timeoutTimer = setTimeout(() => { 846 timedOut = true; 847 console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); 848 finish(); 849 }, 15e3); 850 }); 851 let tab = await chrome.tabs.get(tabId); 852 const session = automationSessions.get(workspace); 853 if (session && tab.windowId !== session.windowId) { 854 console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${session.windowId}`); 855 try { 856 await chrome.tabs.move(tabId, { 857 windowId: session.windowId, 858 index: -1 859 }); 860 tab = await chrome.tabs.get(tabId); 861 } catch (moveErr) { 862 console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`); 863 } 864 } 865 return pageScopedResult(cmd.id, tabId, { 866 title: tab.title, 867 url: tab.url, 868 timedOut 869 }); 870 } 871 async function handleTabs(cmd, workspace) { 872 switch (cmd.op) { 873 case "list": { 874 const tabs = await listAutomationWebTabs(workspace); 875 const data = await Promise.all(tabs.map(async (t, i) => { 876 let page; 877 try { 878 page = t.id ? await resolveTargetId(t.id) : void 0; 879 } catch {} 880 return { 881 index: i, 882 page, 883 url: t.url, 884 title: t.title, 885 active: t.active 886 }; 887 })); 888 return { 889 id: cmd.id, 890 ok: true, 891 data 892 }; 893 } 894 case "new": { 895 if (cmd.url && !isSafeNavigationUrl(cmd.url)) return { 896 id: cmd.id, 897 ok: false, 898 error: "Blocked URL scheme -- only http:// and https:// are allowed" 899 }; 900 const windowId = await getAutomationWindow(workspace); 901 const tab = await chrome.tabs.create({ 902 windowId, 903 url: cmd.url ?? BLANK_PAGE, 904 active: true 905 }); 906 if (!tab.id) return { 907 id: cmd.id, 908 ok: false, 909 error: "Failed to create tab" 910 }; 911 return pageScopedResult(cmd.id, tab.id, { url: tab.url }); 912 } 913 case "close": { 914 if (cmd.index !== void 0) { 915 const target = (await listAutomationWebTabs(workspace))[cmd.index]; 916 if (!target?.id) return { 917 id: cmd.id, 918 ok: false, 919 error: `Tab index ${cmd.index} not found` 920 }; 921 const closedPage = await resolveTargetId(target.id).catch(() => void 0); 922 await chrome.tabs.remove(target.id); 923 await detach(target.id); 924 return { 925 id: cmd.id, 926 ok: true, 927 data: { closed: closedPage } 928 }; 929 } 930 const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); 931 const closedPage = await resolveTargetId(tabId).catch(() => void 0); 932 await chrome.tabs.remove(tabId); 933 await detach(tabId); 934 return { 935 id: cmd.id, 936 ok: true, 937 data: { closed: closedPage } 938 }; 939 } 940 case "select": { 941 if (cmd.index === void 0 && cmd.page === void 0) return { 942 id: cmd.id, 943 ok: false, 944 error: "Missing index or page" 945 }; 946 const cmdTabId = await resolveCommandTabId(cmd); 947 if (cmdTabId !== void 0) { 948 const session = automationSessions.get(workspace); 949 let tab; 950 try { 951 tab = await chrome.tabs.get(cmdTabId); 952 } catch { 953 return { 954 id: cmd.id, 955 ok: false, 956 error: `Page no longer exists` 957 }; 958 } 959 if (!session || tab.windowId !== session.windowId) return { 960 id: cmd.id, 961 ok: false, 962 error: `Page is not in the automation window` 963 }; 964 await chrome.tabs.update(cmdTabId, { active: true }); 965 return pageScopedResult(cmd.id, cmdTabId, { selected: true }); 966 } 967 const target = (await listAutomationWebTabs(workspace))[cmd.index]; 968 if (!target?.id) return { 969 id: cmd.id, 970 ok: false, 971 error: `Tab index ${cmd.index} not found` 972 }; 973 await chrome.tabs.update(target.id, { active: true }); 974 return pageScopedResult(cmd.id, target.id, { selected: true }); 975 } 976 default: return { 977 id: cmd.id, 978 ok: false, 979 error: `Unknown tabs op: ${cmd.op}` 980 }; 981 } 982 } 983 async function handleCookies(cmd) { 984 if (!cmd.domain && !cmd.url) return { 985 id: cmd.id, 986 ok: false, 987 error: "Cookie scope required: provide domain or url to avoid dumping all cookies" 988 }; 989 const details = {}; 990 if (cmd.domain) details.domain = cmd.domain; 991 if (cmd.url) details.url = cmd.url; 992 const data = (await chrome.cookies.getAll(details)).map((c) => ({ 993 name: c.name, 994 value: c.value, 995 domain: c.domain, 996 path: c.path, 997 secure: c.secure, 998 httpOnly: c.httpOnly, 999 expirationDate: c.expirationDate 1000 })); 1001 return { 1002 id: cmd.id, 1003 ok: true, 1004 data 1005 }; 1006 } 1007 async function handleScreenshot(cmd, workspace) { 1008 const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); 1009 try { 1010 const data = await screenshot(tabId, { 1011 format: cmd.format, 1012 quality: cmd.quality, 1013 fullPage: cmd.fullPage 1014 }); 1015 return pageScopedResult(cmd.id, tabId, data); 1016 } catch (err) { 1017 return { 1018 id: cmd.id, 1019 ok: false, 1020 error: err instanceof Error ? err.message : String(err) 1021 }; 1022 } 1023 } 1024 /** CDP methods permitted via the 'cdp' passthrough action. */ 1025 var CDP_ALLOWLIST = new Set([ 1026 "Accessibility.getFullAXTree", 1027 "DOM.getDocument", 1028 "DOM.getBoxModel", 1029 "DOM.getContentQuads", 1030 "DOM.querySelectorAll", 1031 "DOM.scrollIntoViewIfNeeded", 1032 "DOMSnapshot.captureSnapshot", 1033 "Input.dispatchMouseEvent", 1034 "Input.dispatchKeyEvent", 1035 "Input.insertText", 1036 "Page.getLayoutMetrics", 1037 "Page.captureScreenshot", 1038 "Runtime.enable", 1039 "Emulation.setDeviceMetricsOverride", 1040 "Emulation.clearDeviceMetricsOverride" 1041 ]); 1042 async function handleCdp(cmd, workspace) { 1043 if (!cmd.cdpMethod) return { 1044 id: cmd.id, 1045 ok: false, 1046 error: "Missing cdpMethod" 1047 }; 1048 if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) return { 1049 id: cmd.id, 1050 ok: false, 1051 error: `CDP method not permitted: ${cmd.cdpMethod}` 1052 }; 1053 const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); 1054 try { 1055 await ensureAttached(tabId, workspace.startsWith("browser:") || workspace.startsWith("operate:")); 1056 const data = await chrome.debugger.sendCommand({ tabId }, cmd.cdpMethod, cmd.cdpParams ?? {}); 1057 return pageScopedResult(cmd.id, tabId, data); 1058 } catch (err) { 1059 return { 1060 id: cmd.id, 1061 ok: false, 1062 error: err instanceof Error ? err.message : String(err) 1063 }; 1064 } 1065 } 1066 async function handleCloseWindow(cmd, workspace) { 1067 const session = automationSessions.get(workspace); 1068 if (session) { 1069 if (session.owned) try { 1070 await chrome.windows.remove(session.windowId); 1071 } catch {} 1072 if (session.idleTimer) clearTimeout(session.idleTimer); 1073 workspaceTimeoutOverrides.delete(workspace); 1074 automationSessions.delete(workspace); 1075 } 1076 return { 1077 id: cmd.id, 1078 ok: true, 1079 data: { closed: true } 1080 }; 1081 } 1082 async function handleSetFileInput(cmd, workspace) { 1083 if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) return { 1084 id: cmd.id, 1085 ok: false, 1086 error: "Missing or empty files array" 1087 }; 1088 const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); 1089 try { 1090 await setFileInputFiles(tabId, cmd.files, cmd.selector); 1091 return pageScopedResult(cmd.id, tabId, { count: cmd.files.length }); 1092 } catch (err) { 1093 return { 1094 id: cmd.id, 1095 ok: false, 1096 error: err instanceof Error ? err.message : String(err) 1097 }; 1098 } 1099 } 1100 async function handleInsertText(cmd, workspace) { 1101 if (typeof cmd.text !== "string") return { 1102 id: cmd.id, 1103 ok: false, 1104 error: "Missing text payload" 1105 }; 1106 const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); 1107 try { 1108 await insertText(tabId, cmd.text); 1109 return pageScopedResult(cmd.id, tabId, { inserted: true }); 1110 } catch (err) { 1111 return { 1112 id: cmd.id, 1113 ok: false, 1114 error: err instanceof Error ? err.message : String(err) 1115 }; 1116 } 1117 } 1118 async function handleNetworkCaptureStart(cmd, workspace) { 1119 const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); 1120 try { 1121 await startNetworkCapture(tabId, cmd.pattern); 1122 return pageScopedResult(cmd.id, tabId, { started: true }); 1123 } catch (err) { 1124 return { 1125 id: cmd.id, 1126 ok: false, 1127 error: err instanceof Error ? err.message : String(err) 1128 }; 1129 } 1130 } 1131 async function handleNetworkCaptureRead(cmd, workspace) { 1132 const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); 1133 try { 1134 const data = await readNetworkCapture(tabId); 1135 return pageScopedResult(cmd.id, tabId, data); 1136 } catch (err) { 1137 return { 1138 id: cmd.id, 1139 ok: false, 1140 error: err instanceof Error ? err.message : String(err) 1141 }; 1142 } 1143 } 1144 async function handleSessions(cmd) { 1145 const now = Date.now(); 1146 const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({ 1147 workspace, 1148 windowId: session.windowId, 1149 tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length, 1150 idleMsRemaining: Math.max(0, session.idleDeadlineAt - now) 1151 }))); 1152 return { 1153 id: cmd.id, 1154 ok: true, 1155 data 1156 }; 1157 } 1158 async function handleBindCurrent(cmd, workspace) { 1159 const activeTabs = await chrome.tabs.query({ 1160 active: true, 1161 lastFocusedWindow: true 1162 }); 1163 const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true }); 1164 const allTabs = await chrome.tabs.query({}); 1165 const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? allTabs.find((tab) => matchesBindCriteria(tab, cmd)); 1166 if (!boundTab?.id) return { 1167 id: cmd.id, 1168 ok: false, 1169 error: cmd.matchDomain || cmd.matchPathPrefix ? `No visible tab matching ${cmd.matchDomain ?? "domain"}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ""}` : "No active debuggable tab found" 1170 }; 1171 setWorkspaceSession(workspace, { 1172 windowId: boundTab.windowId, 1173 owned: false, 1174 preferredTabId: boundTab.id 1175 }); 1176 resetWindowIdleTimer(workspace); 1177 console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`); 1178 return pageScopedResult(cmd.id, boundTab.id, { 1179 url: boundTab.url, 1180 title: boundTab.title, 1181 workspace 1182 }); 1183 } 1184 //#endregion