diff --git a/extension/dist/background.js b/extension/dist/background.js index 0cb0a2762..4b77c79ff 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -1,1184 +1,1204 @@ -//#region src/protocol.ts -/** Default daemon port */ -var DAEMON_PORT = 19825; -var DAEMON_HOST = "localhost"; -var DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; -/** Lightweight health-check endpoint — probed before each WebSocket attempt. */ -var DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; -/** Base reconnect delay for extension WebSocket (ms) */ -var WS_RECONNECT_BASE_DELAY = 2e3; -/** Max reconnect delay (ms) — kept short since daemon is long-lived */ -var WS_RECONNECT_MAX_DELAY = 5e3; -//#endregion -//#region src/cdp.ts -/** -* CDP execution via chrome.debugger API. -* -* chrome.debugger only needs the "debugger" permission — no host_permissions. -* It can attach to any http/https tab. Avoid chrome:// and chrome-extension:// -* tabs (resolveTabId in background.ts filters them). -*/ -var attached = /* @__PURE__ */ new Set(); -var networkCaptures = /* @__PURE__ */ new Map(); -/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */ +const DAEMON_PORT = 19825; +const DAEMON_HOST = "localhost"; +const WS_RECONNECT_BASE_DELAY = 2e3; +const WS_RECONNECT_MAX_DELAY = 5e3; + +const attached = /* @__PURE__ */ new Set(); +const tabFrameContexts = /* @__PURE__ */ new Map(); +const networkCaptures = /* @__PURE__ */ new Map(); function isDebuggableUrl$1(url) { - if (!url) return true; - return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); + if (!url) return true; + return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); } async function ensureAttached(tabId, aggressiveRetry = false) { - try { - const tab = await chrome.tabs.get(tabId); - if (!isDebuggableUrl$1(tab.url)) { - attached.delete(tabId); - throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`); - } - } catch (e) { - if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e; - attached.delete(tabId); - throw new Error(`Tab ${tabId} no longer exists`); - } - if (attached.has(tabId)) try { - await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { - expression: "1", - returnByValue: true - }); - return; - } catch { - attached.delete(tabId); - } - const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2; - const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500; - let lastError = ""; - for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) try { - try { - await chrome.debugger.detach({ tabId }); - } catch {} - await chrome.debugger.attach({ tabId }, "1.3"); - lastError = ""; - break; - } catch (e) { - lastError = e instanceof Error ? e.message : String(e); - if (attempt < MAX_ATTACH_RETRIES) { - console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`); - await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); - try { - const tab = await chrome.tabs.get(tabId); - if (!isDebuggableUrl$1(tab.url)) { - lastError = `Tab URL changed to ${tab.url} during retry`; - break; - } - } catch { - lastError = `Tab ${tabId} no longer exists`; - } - } - } - if (lastError) { - let finalUrl = "unknown"; - let finalWindowId = "unknown"; - try { - const tab = await chrome.tabs.get(tabId); - finalUrl = tab.url ?? "undefined"; - finalWindowId = String(tab.windowId); - } catch {} - console.warn(`[opencli] attach failed for tab ${tabId}: url=${finalUrl}, windowId=${finalWindowId}, error=${lastError}`); - const hint = lastError.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : ""; - throw new Error(`attach failed: ${lastError}${hint}`); - } - attached.add(tabId); - try { - await chrome.debugger.sendCommand({ tabId }, "Runtime.enable"); - } catch {} + try { + const tab = await chrome.tabs.get(tabId); + if (!isDebuggableUrl$1(tab.url)) { + attached.delete(tabId); + throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`); + } + } catch (e) { + if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e; + attached.delete(tabId); + throw new Error(`Tab ${tabId} no longer exists`); + } + if (attached.has(tabId)) { + try { + await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { + expression: "1", + returnByValue: true + }); + return; + } catch { + attached.delete(tabId); + } + } + const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2; + const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500; + let lastError = ""; + for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) { + try { + try { + await chrome.debugger.detach({ tabId }); + } catch { + } + await chrome.debugger.attach({ tabId }, "1.3"); + lastError = ""; + break; + } catch (e) { + lastError = e instanceof Error ? e.message : String(e); + if (attempt < MAX_ATTACH_RETRIES) { + console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`); + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); + try { + const tab = await chrome.tabs.get(tabId); + if (!isDebuggableUrl$1(tab.url)) { + lastError = `Tab URL changed to ${tab.url} during retry`; + break; + } + } catch { + lastError = `Tab ${tabId} no longer exists`; + } + } + } + } + if (lastError) { + let finalUrl = "unknown"; + let finalWindowId = "unknown"; + try { + const tab = await chrome.tabs.get(tabId); + finalUrl = tab.url ?? "undefined"; + finalWindowId = String(tab.windowId); + } catch { + } + console.warn(`[opencli] attach failed for tab ${tabId}: url=${finalUrl}, windowId=${finalWindowId}, error=${lastError}`); + const hint = lastError.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : ""; + throw new Error(`attach failed: ${lastError}${hint}`); + } + attached.add(tabId); + try { + await chrome.debugger.sendCommand({ tabId }, "Runtime.enable"); + } catch { + } } async function evaluate(tabId, expression, aggressiveRetry = false) { - const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2; - for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) try { - await ensureAttached(tabId, aggressiveRetry); - const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { - expression, - returnByValue: true, - awaitPromise: true - }); - if (result.exceptionDetails) { - const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; - throw new Error(errMsg); - } - return result.result?.value; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - const isNavigateError = msg.includes("Inspected target navigated") || msg.includes("Target closed"); - if ((isNavigateError || msg.includes("attach failed") || msg.includes("Debugger is not attached") || msg.includes("chrome-extension://")) && attempt < MAX_EVAL_RETRIES) { - attached.delete(tabId); - const retryMs = isNavigateError ? 200 : 500; - await new Promise((resolve) => setTimeout(resolve, retryMs)); - continue; - } - throw e; - } - throw new Error("evaluate: max retries exhausted"); + const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2; + for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) { + try { + await ensureAttached(tabId, aggressiveRetry); + const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true + }); + if (result.exceptionDetails) { + const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; + throw new Error(errMsg); + } + return result.result?.value; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const isNavigateError = msg.includes("Inspected target navigated") || msg.includes("Target closed"); + const isAttachError = isNavigateError || msg.includes("attach failed") || msg.includes("Debugger is not attached") || msg.includes("chrome-extension://"); + if (isAttachError && attempt < MAX_EVAL_RETRIES) { + attached.delete(tabId); + const retryMs = isNavigateError ? 200 : 500; + await new Promise((resolve) => setTimeout(resolve, retryMs)); + continue; + } + throw e; + } + } + throw new Error("evaluate: max retries exhausted"); } -var evaluateAsync = evaluate; -/** -* Capture a screenshot via CDP Page.captureScreenshot. -* Returns base64-encoded image data. -*/ +const evaluateAsync = evaluate; async function screenshot(tabId, options = {}) { - await ensureAttached(tabId); - const format = options.format ?? "png"; - if (options.fullPage) { - const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics"); - const size = metrics.cssContentSize || metrics.contentSize; - if (size) await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { - mobile: false, - width: Math.ceil(size.width), - height: Math.ceil(size.height), - deviceScaleFactor: 1 - }); - } - try { - const params = { format }; - if (format === "jpeg" && options.quality !== void 0) params.quality = Math.max(0, Math.min(100, options.quality)); - return (await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params)).data; - } finally { - if (options.fullPage) await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => {}); - } + await ensureAttached(tabId); + const format = options.format ?? "png"; + if (options.fullPage) { + const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics"); + const size = metrics.cssContentSize || metrics.contentSize; + if (size) { + await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { + mobile: false, + width: Math.ceil(size.width), + height: Math.ceil(size.height), + deviceScaleFactor: 1 + }); + } + } + try { + const params = { format }; + if (format === "jpeg" && options.quality !== void 0) { + params.quality = Math.max(0, Math.min(100, options.quality)); + } + const result = await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params); + return result.data; + } finally { + if (options.fullPage) { + await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => { + }); + } + } } -/** -* Set local file paths on a file input element via CDP DOM.setFileInputFiles. -* This bypasses the need to send large base64 payloads through the message channel — -* Chrome reads the files directly from the local filesystem. -* -* @param tabId - Target tab ID -* @param files - Array of absolute local file paths -* @param selector - CSS selector to find the file input (optional, defaults to first file input) -*/ async function setFileInputFiles(tabId, files, selector) { - await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, "DOM.enable"); - const doc = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument"); - const query = selector || "input[type=\"file\"]"; - const result = await chrome.debugger.sendCommand({ tabId }, "DOM.querySelector", { - nodeId: doc.root.nodeId, - selector: query - }); - if (!result.nodeId) throw new Error(`No element found matching selector: ${query}`); - await chrome.debugger.sendCommand({ tabId }, "DOM.setFileInputFiles", { - files, - nodeId: result.nodeId - }); + await ensureAttached(tabId); + await chrome.debugger.sendCommand({ tabId }, "DOM.enable"); + const doc = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument"); + const query = selector || 'input[type="file"]'; + const result = await chrome.debugger.sendCommand({ tabId }, "DOM.querySelector", { + nodeId: doc.root.nodeId, + selector: query + }); + if (!result.nodeId) { + throw new Error(`No element found matching selector: ${query}`); + } + await chrome.debugger.sendCommand({ tabId }, "DOM.setFileInputFiles", { + files, + nodeId: result.nodeId + }); } async function insertText(tabId, text) { - await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, "Input.insertText", { text }); + await ensureAttached(tabId); + await chrome.debugger.sendCommand({ tabId }, "Input.insertText", { text }); +} +function registerFrameTracking() { + chrome.debugger.onEvent.addListener((source, method, params) => { + const tabId = source.tabId; + if (!tabId) return; + if (method === "Runtime.executionContextCreated") { + const context = params.context; + if (!context?.auxData?.frameId || context.auxData.isDefault !== true) return; + const frameId = context.auxData.frameId; + if (!tabFrameContexts.has(tabId)) { + tabFrameContexts.set(tabId, /* @__PURE__ */ new Map()); + } + tabFrameContexts.get(tabId).set(frameId, context.id); + } + if (method === "Runtime.executionContextDestroyed") { + const ctxId = params.executionContextId; + const contexts = tabFrameContexts.get(tabId); + if (contexts) { + for (const [fid, cid] of contexts) { + if (cid === ctxId) { + contexts.delete(fid); + break; + } + } + } + } + if (method === "Runtime.executionContextsCleared") { + tabFrameContexts.delete(tabId); + } + }); + chrome.tabs.onRemoved.addListener((tabId) => { + tabFrameContexts.delete(tabId); + }); +} +async function getFrameTree(tabId) { + await ensureAttached(tabId); + return chrome.debugger.sendCommand({ tabId }, "Page.getFrameTree"); +} +async function evaluateInFrame(tabId, expression, frameId, aggressiveRetry = false) { + await ensureAttached(tabId, aggressiveRetry); + await chrome.debugger.sendCommand({ tabId }, "Runtime.enable").catch(() => { + }); + const contexts = tabFrameContexts.get(tabId); + const contextId = contexts?.get(frameId); + if (contextId === void 0) { + throw new Error(`No execution context found for frame ${frameId}. The frame may not be loaded yet.`); + } + const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { + expression, + contextId, + returnByValue: true, + awaitPromise: true + }); + if (result.exceptionDetails) { + const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; + throw new Error(errMsg); + } + return result.result?.value; } function normalizeCapturePatterns(pattern) { - return String(pattern || "").split("|").map((part) => part.trim()).filter(Boolean); + return String(pattern || "").split("|").map((part) => part.trim()).filter(Boolean); } function shouldCaptureUrl(url, patterns) { - if (!url) return false; - if (!patterns.length) return true; - return patterns.some((pattern) => url.includes(pattern)); + if (!url) return false; + if (!patterns.length) return true; + return patterns.some((pattern) => url.includes(pattern)); } function normalizeHeaders(headers) { - if (!headers || typeof headers !== "object") return {}; - const out = {}; - for (const [key, value] of Object.entries(headers)) out[String(key)] = String(value); - return out; + if (!headers || typeof headers !== "object") return {}; + const out = {}; + for (const [key, value] of Object.entries(headers)) { + out[String(key)] = String(value); + } + return out; } function getOrCreateNetworkCaptureEntry(tabId, requestId, fallback) { - const state = networkCaptures.get(tabId); - if (!state) return null; - const existingIndex = state.requestToIndex.get(requestId); - if (existingIndex !== void 0) return state.entries[existingIndex] || null; - const url = fallback?.url || ""; - if (!shouldCaptureUrl(url, state.patterns)) return null; - const entry = { - kind: "cdp", - url, - method: fallback?.method || "GET", - requestHeaders: fallback?.requestHeaders || {}, - timestamp: Date.now() - }; - state.entries.push(entry); - state.requestToIndex.set(requestId, state.entries.length - 1); - return entry; + const state = networkCaptures.get(tabId); + if (!state) return null; + const existingIndex = state.requestToIndex.get(requestId); + if (existingIndex !== void 0) { + return state.entries[existingIndex] || null; + } + const url = fallback?.url || ""; + if (!shouldCaptureUrl(url, state.patterns)) return null; + const entry = { + kind: "cdp", + url, + method: fallback?.method || "GET", + requestHeaders: fallback?.requestHeaders || {}, + timestamp: Date.now() + }; + state.entries.push(entry); + state.requestToIndex.set(requestId, state.entries.length - 1); + return entry; } async function startNetworkCapture(tabId, pattern) { - await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, "Network.enable"); - networkCaptures.set(tabId, { - patterns: normalizeCapturePatterns(pattern), - entries: [], - requestToIndex: /* @__PURE__ */ new Map() - }); + await ensureAttached(tabId); + await chrome.debugger.sendCommand({ tabId }, "Network.enable"); + networkCaptures.set(tabId, { + patterns: normalizeCapturePatterns(pattern), + entries: [], + requestToIndex: /* @__PURE__ */ new Map() + }); } async function readNetworkCapture(tabId) { - const state = networkCaptures.get(tabId); - if (!state) return []; - const entries = state.entries.slice(); - state.entries = []; - state.requestToIndex.clear(); - return entries; + const state = networkCaptures.get(tabId); + if (!state) return []; + const entries = state.entries.slice(); + state.entries = []; + state.requestToIndex.clear(); + return entries; } function hasActiveNetworkCapture(tabId) { - return networkCaptures.has(tabId); + return networkCaptures.has(tabId); } async function detach(tabId) { - if (!attached.has(tabId)) return; - attached.delete(tabId); - networkCaptures.delete(tabId); - try { - await chrome.debugger.detach({ tabId }); - } catch {} + if (!attached.has(tabId)) return; + attached.delete(tabId); + networkCaptures.delete(tabId); + tabFrameContexts.delete(tabId); + try { + await chrome.debugger.detach({ tabId }); + } catch { + } } function registerListeners() { - chrome.tabs.onRemoved.addListener((tabId) => { - attached.delete(tabId); - networkCaptures.delete(tabId); - }); - chrome.debugger.onDetach.addListener((source) => { - if (source.tabId) { - attached.delete(source.tabId); - networkCaptures.delete(source.tabId); - } - }); - chrome.tabs.onUpdated.addListener(async (tabId, info) => { - if (info.url && !isDebuggableUrl$1(info.url)) await detach(tabId); - }); - chrome.debugger.onEvent.addListener(async (source, method, params) => { - const tabId = source.tabId; - if (!tabId) return; - const state = networkCaptures.get(tabId); - if (!state) return; - if (method === "Network.requestWillBeSent") { - const requestId = String(params?.requestId || ""); - const request = params?.request; - const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { - url: request?.url, - method: request?.method, - requestHeaders: normalizeHeaders(request?.headers) - }); - if (!entry) return; - entry.requestBodyKind = request?.hasPostData ? "string" : "empty"; - entry.requestBodyPreview = String(request?.postData || "").slice(0, 4e3); - try { - const postData = await chrome.debugger.sendCommand({ tabId }, "Network.getRequestPostData", { requestId }); - if (postData?.postData) { - entry.requestBodyKind = "string"; - entry.requestBodyPreview = postData.postData.slice(0, 4e3); - } - } catch {} - return; - } - if (method === "Network.responseReceived") { - const requestId = String(params?.requestId || ""); - const response = params?.response; - const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { url: response?.url }); - if (!entry) return; - entry.responseStatus = response?.status; - entry.responseContentType = response?.mimeType || ""; - entry.responseHeaders = normalizeHeaders(response?.headers); - return; - } - if (method === "Network.loadingFinished") { - const requestId = String(params?.requestId || ""); - const stateEntryIndex = state.requestToIndex.get(requestId); - if (stateEntryIndex === void 0) return; - const entry = state.entries[stateEntryIndex]; - if (!entry) return; - try { - const body = await chrome.debugger.sendCommand({ tabId }, "Network.getResponseBody", { requestId }); - if (typeof body?.body === "string") entry.responsePreview = body.base64Encoded ? `base64:${body.body.slice(0, 4e3)}` : body.body.slice(0, 4e3); - } catch {} - } - }); + chrome.tabs.onRemoved.addListener((tabId) => { + attached.delete(tabId); + networkCaptures.delete(tabId); + tabFrameContexts.delete(tabId); + }); + chrome.debugger.onDetach.addListener((source) => { + if (source.tabId) { + attached.delete(source.tabId); + networkCaptures.delete(source.tabId); + tabFrameContexts.delete(source.tabId); + } + }); + chrome.tabs.onUpdated.addListener(async (tabId, info) => { + if (info.url && !isDebuggableUrl$1(info.url)) { + await detach(tabId); + } + }); + chrome.debugger.onEvent.addListener(async (source, method, params) => { + const tabId = source.tabId; + if (!tabId) return; + const state = networkCaptures.get(tabId); + if (!state) return; + if (method === "Network.requestWillBeSent") { + const requestId = String(params?.requestId || ""); + const request = params?.request; + const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { + url: request?.url, + method: request?.method, + requestHeaders: normalizeHeaders(request?.headers) + }); + if (!entry) return; + entry.requestBodyKind = request?.hasPostData ? "string" : "empty"; + entry.requestBodyPreview = String(request?.postData || "").slice(0, 4e3); + try { + const postData = await chrome.debugger.sendCommand({ tabId }, "Network.getRequestPostData", { requestId }); + if (postData?.postData) { + entry.requestBodyKind = "string"; + entry.requestBodyPreview = postData.postData.slice(0, 4e3); + } + } catch { + } + return; + } + if (method === "Network.responseReceived") { + const requestId = String(params?.requestId || ""); + const response = params?.response; + const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { + url: response?.url + }); + if (!entry) return; + entry.responseStatus = response?.status; + entry.responseContentType = response?.mimeType || ""; + entry.responseHeaders = normalizeHeaders(response?.headers); + return; + } + if (method === "Network.loadingFinished") { + const requestId = String(params?.requestId || ""); + const stateEntryIndex = state.requestToIndex.get(requestId); + if (stateEntryIndex === void 0) return; + const entry = state.entries[stateEntryIndex]; + if (!entry) return; + try { + const body = await chrome.debugger.sendCommand({ tabId }, "Network.getResponseBody", { requestId }); + if (typeof body?.body === "string") { + entry.responsePreview = body.base64Encoded ? `base64:${body.body.slice(0, 4e3)}` : body.body.slice(0, 4e3); + } + } catch { + } + } + }); } -//#endregion -//#region src/identity.ts -/** -* Page identity mapping — targetId ↔ tabId. -* -* targetId is the cross-layer page identity (CDP target UUID). -* tabId is an internal Chrome Tabs API routing detail — never exposed outside the extension. -* -* Lifecycle: -* - Cache populated lazily via chrome.debugger.getTargets() -* - Evicted on tab close (chrome.tabs.onRemoved) -* - Miss triggers full refresh; refresh miss → hard error (no guessing) -*/ -var targetToTab = /* @__PURE__ */ new Map(); -var tabToTarget = /* @__PURE__ */ new Map(); -/** -* Resolve targetId for a given tabId. -* Returns cached value if available; on miss, refreshes from chrome.debugger.getTargets(). -* Throws if no targetId can be found (page may have been destroyed). -*/ + +const targetToTab = /* @__PURE__ */ new Map(); +const tabToTarget = /* @__PURE__ */ new Map(); async function resolveTargetId(tabId) { - const cached = tabToTarget.get(tabId); - if (cached) return cached; - await refreshMappings(); - const result = tabToTarget.get(tabId); - if (!result) throw new Error(`No targetId for tab ${tabId} — page may have been closed`); - return result; + const cached = tabToTarget.get(tabId); + if (cached) return cached; + await refreshMappings(); + const result = tabToTarget.get(tabId); + if (!result) throw new Error(`No targetId for tab ${tabId} — page may have been closed`); + return result; } -/** -* Resolve tabId for a given targetId. -* Returns cached value if available; on miss, refreshes from chrome.debugger.getTargets(). -* Throws if no tabId can be found — never falls back to guessing. -*/ async function resolveTabId$1(targetId) { - const cached = targetToTab.get(targetId); - if (cached !== void 0) return cached; - await refreshMappings(); - const result = targetToTab.get(targetId); - if (result === void 0) throw new Error(`Page not found: ${targetId} — stale page identity`); - return result; + const cached = targetToTab.get(targetId); + if (cached !== void 0) return cached; + await refreshMappings(); + const result = targetToTab.get(targetId); + if (result === void 0) throw new Error(`Page not found: ${targetId} — stale page identity`); + return result; } -/** -* Remove mappings for a closed tab. -* Called from chrome.tabs.onRemoved listener. -*/ function evictTab(tabId) { - const targetId = tabToTarget.get(tabId); - if (targetId) targetToTab.delete(targetId); - tabToTarget.delete(tabId); + const targetId = tabToTarget.get(tabId); + if (targetId) targetToTab.delete(targetId); + tabToTarget.delete(tabId); } -/** -* Full refresh of targetId ↔ tabId mappings from chrome.debugger.getTargets(). -*/ async function refreshMappings() { - const targets = await chrome.debugger.getTargets(); - targetToTab.clear(); - tabToTarget.clear(); - for (const t of targets) if (t.type === "page" && t.tabId !== void 0) { - targetToTab.set(t.id, t.tabId); - tabToTarget.set(t.tabId, t.id); - } + const targets = await chrome.debugger.getTargets(); + targetToTab.clear(); + tabToTarget.clear(); + for (const t of targets) { + if (t.type === "page" && t.tabId !== void 0) { + targetToTab.set(t.id, t.tabId); + tabToTarget.set(t.tabId, t.id); + } + } +} + +let daemonPort = DAEMON_PORT; +function getDaemonWsUrl() { + return `ws://${DAEMON_HOST}:${daemonPort}/ext`; +} +function getDaemonPingUrl() { + return `http://${DAEMON_HOST}:${daemonPort}/ping`; } -//#endregion -//#region src/background.ts -var ws = null; -var reconnectTimer = null; -var reconnectAttempts = 0; -var _origLog = console.log.bind(console); -var _origWarn = console.warn.bind(console); -var _origError = console.error.bind(console); +function loadDaemonPort() { + return new Promise((resolve) => { + chrome.storage.local.get("daemonPort", (result) => { + if (result.daemonPort && Number.isInteger(result.daemonPort) && result.daemonPort >= 1024 && result.daemonPort <= 65535) { + daemonPort = result.daemonPort; + } + resolve(); + }); + }); +} +chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== "local" || !changes.daemonPort) return; + const newPort = changes.daemonPort.newValue; + if (newPort && Number.isInteger(newPort) && newPort >= 1024 && newPort <= 65535) { + daemonPort = newPort; + } else { + daemonPort = DAEMON_PORT; + } + if (ws) { + ws.close(); + ws = null; + } + reconnectAttempts = 0; + void connect(); +}); +let ws = null; +let reconnectTimer = null; +let reconnectAttempts = 0; +const _origLog = console.log.bind(console); +const _origWarn = console.warn.bind(console); +const _origError = console.error.bind(console); function forwardLog(level, args) { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - try { - const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); - ws.send(JSON.stringify({ - type: "log", - level, - msg, - ts: Date.now() - })); - } catch {} + if (!ws || ws.readyState !== WebSocket.OPEN) return; + try { + const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); + ws.send(JSON.stringify({ type: "log", level, msg, ts: Date.now() })); + } catch { + } } console.log = (...args) => { - _origLog(...args); - forwardLog("info", args); + _origLog(...args); + forwardLog("info", args); }; console.warn = (...args) => { - _origWarn(...args); - forwardLog("warn", args); + _origWarn(...args); + forwardLog("warn", args); }; console.error = (...args) => { - _origError(...args); - forwardLog("error", args); + _origError(...args); + forwardLog("error", args); }; -/** -* Probe the daemon via its /ping HTTP endpoint before attempting a WebSocket -* connection. fetch() failures are silently catchable; new WebSocket() is not -* — Chrome logs ERR_CONNECTION_REFUSED to the extension error page before any -* JS handler can intercept it. By keeping the probe inside connect() every -* call site remains unchanged and the guard can never be accidentally skipped. -*/ async function connect() { - if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; - try { - if (!(await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) })).ok) return; - } catch { - return; - } - try { - ws = new WebSocket(DAEMON_WS_URL); - } catch { - scheduleReconnect(); - return; - } - ws.onopen = () => { - console.log("[opencli] Connected to daemon"); - reconnectAttempts = 0; - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - ws?.send(JSON.stringify({ - type: "hello", - version: chrome.runtime.getManifest().version, - compatRange: ">=1.7.0" - })); - }; - ws.onmessage = async (event) => { - try { - const result = await handleCommand(JSON.parse(event.data)); - ws?.send(JSON.stringify(result)); - } catch (err) { - console.error("[opencli] Message handling error:", err); - } - }; - ws.onclose = () => { - console.log("[opencli] Disconnected from daemon"); - ws = null; - scheduleReconnect(); - }; - ws.onerror = () => { - ws?.close(); - }; + if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + try { + const res = await fetch(getDaemonPingUrl(), { signal: AbortSignal.timeout(1e3) }); + if (!res.ok) return; + } catch { + return; + } + try { + ws = new WebSocket(getDaemonWsUrl()); + } catch { + scheduleReconnect(); + return; + } + ws.onopen = () => { + console.log("[opencli] Connected to daemon"); + reconnectAttempts = 0; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + ws?.send(JSON.stringify({ + type: "hello", + version: chrome.runtime.getManifest().version, + compatRange: ">=1.7.0" + })); + }; + ws.onmessage = async (event) => { + try { + const command = JSON.parse(event.data); + const result = await handleCommand(command); + ws?.send(JSON.stringify(result)); + } catch (err) { + console.error("[opencli] Message handling error:", err); + } + }; + ws.onclose = () => { + console.log("[opencli] Disconnected from daemon"); + ws = null; + scheduleReconnect(); + }; + ws.onerror = () => { + ws?.close(); + }; } -/** -* After MAX_EAGER_ATTEMPTS (reaching 60s backoff), stop scheduling reconnects. -* The keepalive alarm (~24s) will still call connect() periodically, but at a -* much lower frequency — reducing console noise when the daemon is not running. -*/ -var MAX_EAGER_ATTEMPTS = 6; +const MAX_EAGER_ATTEMPTS = 6; function scheduleReconnect() { - if (reconnectTimer) return; - reconnectAttempts++; - if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; - const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - connect(); - }, delay); + if (reconnectTimer) return; + reconnectAttempts++; + if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; + const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + void connect(); + }, delay); } -var automationSessions = /* @__PURE__ */ new Map(); -var IDLE_TIMEOUT_DEFAULT = 3e4; -var IDLE_TIMEOUT_INTERACTIVE = 6e5; -/** Per-workspace custom timeout overrides set via command.idleTimeout */ -var workspaceTimeoutOverrides = /* @__PURE__ */ new Map(); +const automationSessions = /* @__PURE__ */ new Map(); +const IDLE_TIMEOUT_DEFAULT = 3e4; +const IDLE_TIMEOUT_INTERACTIVE = 6e5; +const workspaceTimeoutOverrides = /* @__PURE__ */ new Map(); function getIdleTimeout(workspace) { - const override = workspaceTimeoutOverrides.get(workspace); - if (override !== void 0) return override; - if (workspace.startsWith("browser:") || workspace.startsWith("operate:")) return IDLE_TIMEOUT_INTERACTIVE; - return IDLE_TIMEOUT_DEFAULT; + const override = workspaceTimeoutOverrides.get(workspace); + if (override !== void 0) return override; + if (workspace.startsWith("browser:") || workspace.startsWith("operate:")) { + return IDLE_TIMEOUT_INTERACTIVE; + } + return IDLE_TIMEOUT_DEFAULT; } -var windowFocused = false; +let windowFocused = false; function getWorkspaceKey(workspace) { - return workspace?.trim() || "default"; + return workspace?.trim() || "default"; } function resetWindowIdleTimer(workspace) { - const session = automationSessions.get(workspace); - if (!session) return; - if (session.idleTimer) clearTimeout(session.idleTimer); - const timeout = getIdleTimeout(workspace); - session.idleDeadlineAt = Date.now() + timeout; - session.idleTimer = setTimeout(async () => { - const current = automationSessions.get(workspace); - if (!current) return; - if (!current.owned) { - console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`); - workspaceTimeoutOverrides.delete(workspace); - automationSessions.delete(workspace); - return; - } - try { - await chrome.windows.remove(current.windowId); - console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout, ${timeout / 1e3}s)`); - } catch {} - workspaceTimeoutOverrides.delete(workspace); - automationSessions.delete(workspace); - }, timeout); + const session = automationSessions.get(workspace); + if (!session) return; + if (session.idleTimer) clearTimeout(session.idleTimer); + const timeout = getIdleTimeout(workspace); + session.idleDeadlineAt = Date.now() + timeout; + session.idleTimer = setTimeout(async () => { + const current = automationSessions.get(workspace); + if (!current) return; + if (!current.owned) { + console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`); + workspaceTimeoutOverrides.delete(workspace); + automationSessions.delete(workspace); + return; + } + try { + await chrome.windows.remove(current.windowId); + console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout, ${timeout / 1e3}s)`); + } catch { + } + workspaceTimeoutOverrides.delete(workspace); + automationSessions.delete(workspace); + }, timeout); } -/** Get or create the dedicated automation window. -* @param initialUrl — if provided (http/https), used as the initial page instead of about:blank. -* This avoids an extra blank-page→target-domain navigation on first command. -*/ async function getAutomationWindow(workspace, initialUrl) { - const existing = automationSessions.get(workspace); - if (existing) try { - await chrome.windows.get(existing.windowId); - return existing.windowId; - } catch { - automationSessions.delete(workspace); - } - const startUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; - const win = await chrome.windows.create({ - url: startUrl, - focused: windowFocused, - width: 1280, - height: 900, - type: "normal" - }); - const session = { - windowId: win.id, - idleTimer: null, - idleDeadlineAt: Date.now() + getIdleTimeout(workspace), - owned: true, - preferredTabId: null - }; - automationSessions.set(workspace, session); - console.log(`[opencli] Created automation window ${session.windowId} (${workspace}, start=${startUrl})`); - resetWindowIdleTimer(workspace); - const tabs = await chrome.tabs.query({ windowId: win.id }); - if (tabs[0]?.id) await new Promise((resolve) => { - const timeout = setTimeout(resolve, 500); - const listener = (tabId, info) => { - if (tabId === tabs[0].id && info.status === "complete") { - chrome.tabs.onUpdated.removeListener(listener); - clearTimeout(timeout); - resolve(); - } - }; - if (tabs[0].status === "complete") { - clearTimeout(timeout); - resolve(); - } else chrome.tabs.onUpdated.addListener(listener); - }); - return session.windowId; + const existing = automationSessions.get(workspace); + if (existing) { + try { + await chrome.windows.get(existing.windowId); + return existing.windowId; + } catch { + automationSessions.delete(workspace); + } + } + const startUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; + const win = await chrome.windows.create({ + url: startUrl, + focused: windowFocused, + width: 1280, + height: 900, + type: "normal" + }); + const session = { + windowId: win.id, + idleTimer: null, + idleDeadlineAt: Date.now() + getIdleTimeout(workspace), + owned: true, + preferredTabId: null + }; + automationSessions.set(workspace, session); + console.log(`[opencli] Created automation window ${session.windowId} (${workspace}, start=${startUrl})`); + resetWindowIdleTimer(workspace); + const tabs = await chrome.tabs.query({ windowId: win.id }); + if (tabs[0]?.id) { + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 500); + const listener = (tabId, info) => { + if (tabId === tabs[0].id && info.status === "complete") { + chrome.tabs.onUpdated.removeListener(listener); + clearTimeout(timeout); + resolve(); + } + }; + if (tabs[0].status === "complete") { + clearTimeout(timeout); + resolve(); + } else { + chrome.tabs.onUpdated.addListener(listener); + } + }); + } + return session.windowId; } chrome.windows.onRemoved.addListener(async (windowId) => { - for (const [workspace, session] of automationSessions.entries()) if (session.windowId === windowId) { - console.log(`[opencli] Automation window closed (${workspace})`); - if (session.idleTimer) clearTimeout(session.idleTimer); - automationSessions.delete(workspace); - workspaceTimeoutOverrides.delete(workspace); - } + for (const [workspace, session] of automationSessions.entries()) { + if (session.windowId === windowId) { + console.log(`[opencli] Automation window closed (${workspace})`); + if (session.idleTimer) clearTimeout(session.idleTimer); + automationSessions.delete(workspace); + workspaceTimeoutOverrides.delete(workspace); + } + } }); chrome.tabs.onRemoved.addListener((tabId) => { - evictTab(tabId); + evictTab(tabId); }); -var initialized = false; -function initialize() { - if (initialized) return; - initialized = true; - chrome.alarms.create("keepalive", { periodInMinutes: .4 }); - registerListeners(); - connect(); - console.log("[opencli] OpenCLI extension initialized"); +let initialized = false; +async function initialize() { + if (initialized) return; + initialized = true; + chrome.alarms.create("keepalive", { periodInMinutes: 0.4 }); + registerListeners(); + registerFrameTracking(); + await loadDaemonPort(); + void connect(); + console.log("[opencli] OpenCLI extension initialized"); } chrome.runtime.onInstalled.addListener(() => { - initialize(); + initialize(); }); chrome.runtime.onStartup.addListener(() => { - initialize(); + initialize(); }); chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === "keepalive") connect(); + if (alarm.name === "keepalive") void connect(); }); chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { - if (msg?.type === "getStatus") sendResponse({ - connected: ws?.readyState === WebSocket.OPEN, - reconnecting: reconnectTimer !== null - }); - return false; + if (msg?.type === "getStatus") { + sendResponse({ + connected: ws?.readyState === WebSocket.OPEN, + reconnecting: reconnectTimer !== null, + port: daemonPort + }); + } + return false; }); async function handleCommand(cmd) { - const workspace = getWorkspaceKey(cmd.workspace); - windowFocused = cmd.windowFocused === true; - if (cmd.idleTimeout != null && cmd.idleTimeout > 0) workspaceTimeoutOverrides.set(workspace, cmd.idleTimeout * 1e3); - resetWindowIdleTimer(workspace); - try { - switch (cmd.action) { - case "exec": return await handleExec(cmd, workspace); - case "navigate": return await handleNavigate(cmd, workspace); - case "tabs": return await handleTabs(cmd, workspace); - case "cookies": return await handleCookies(cmd); - case "screenshot": return await handleScreenshot(cmd, workspace); - case "close-window": return await handleCloseWindow(cmd, workspace); - case "cdp": return await handleCdp(cmd, workspace); - case "sessions": return await handleSessions(cmd); - case "set-file-input": return await handleSetFileInput(cmd, workspace); - case "insert-text": return await handleInsertText(cmd, workspace); - case "bind-current": return await handleBindCurrent(cmd, workspace); - case "network-capture-start": return await handleNetworkCaptureStart(cmd, workspace); - case "network-capture-read": return await handleNetworkCaptureRead(cmd, workspace); - default: return { - id: cmd.id, - ok: false, - error: `Unknown action: ${cmd.action}` - }; - } - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } + const workspace = getWorkspaceKey(cmd.workspace); + windowFocused = cmd.windowFocused === true; + if (cmd.idleTimeout != null && cmd.idleTimeout > 0) { + workspaceTimeoutOverrides.set(workspace, cmd.idleTimeout * 1e3); + } + resetWindowIdleTimer(workspace); + try { + switch (cmd.action) { + case "exec": + return await handleExec(cmd, workspace); + case "navigate": + return await handleNavigate(cmd, workspace); + case "tabs": + return await handleTabs(cmd, workspace); + case "cookies": + return await handleCookies(cmd); + case "screenshot": + return await handleScreenshot(cmd, workspace); + case "close-window": + return await handleCloseWindow(cmd, workspace); + case "cdp": + return await handleCdp(cmd, workspace); + case "sessions": + return await handleSessions(cmd); + case "set-file-input": + return await handleSetFileInput(cmd, workspace); + case "insert-text": + return await handleInsertText(cmd, workspace); + case "bind-current": + return await handleBindCurrent(cmd, workspace); + case "network-capture-start": + return await handleNetworkCaptureStart(cmd, workspace); + case "network-capture-read": + return await handleNetworkCaptureRead(cmd, workspace); + case "frames": + return await handleFrames(cmd, workspace); + default: + return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` }; + } + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } } -/** Internal blank page used when no user URL is provided. */ -var BLANK_PAGE = "about:blank"; -/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */ +const BLANK_PAGE = "about:blank"; function isDebuggableUrl(url) { - if (!url) return true; - return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); + if (!url) return true; + return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); } -/** Check if a URL is safe for user-facing navigation (http/https only). */ function isSafeNavigationUrl(url) { - return url.startsWith("http://") || url.startsWith("https://"); + return url.startsWith("http://") || url.startsWith("https://"); } -/** Minimal URL normalization for same-page comparison: root slash + default port only. */ function normalizeUrlForComparison(url) { - if (!url) return ""; - try { - const parsed = new URL(url); - if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") parsed.port = ""; - const pathname = parsed.pathname === "/" ? "" : parsed.pathname; - return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`; - } catch { - return url; - } + if (!url) return ""; + try { + const parsed = new URL(url); + if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") { + parsed.port = ""; + } + const pathname = parsed.pathname === "/" ? "" : parsed.pathname; + return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`; + } catch { + return url; + } } function isTargetUrl(currentUrl, targetUrl) { - return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); + return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); } function matchesDomain(url, domain) { - if (!url) return false; - try { - const parsed = new URL(url); - return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`); - } catch { - return false; - } + if (!url) return false; + try { + const parsed = new URL(url); + return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`); + } catch { + return false; + } } function matchesBindCriteria(tab, cmd) { - if (!tab.id || !isDebuggableUrl(tab.url)) return false; - if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false; - if (cmd.matchPathPrefix) try { - if (!new URL(tab.url).pathname.startsWith(cmd.matchPathPrefix)) return false; - } catch { - return false; - } - return true; + if (!tab.id || !isDebuggableUrl(tab.url)) return false; + if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false; + if (cmd.matchPathPrefix) { + try { + const parsed = new URL(tab.url); + if (!parsed.pathname.startsWith(cmd.matchPathPrefix)) return false; + } catch { + return false; + } + } + return true; +} +function getUrlOrigin(url) { + if (!url) return null; + try { + return new URL(url).origin; + } catch { + return null; + } +} +function enumerateCrossOriginFrames(tree) { + const frames = []; + function collect(node, accessibleOrigin) { + for (const child of node.childFrames || []) { + const frame = child.frame; + const frameUrl = frame.url || frame.unreachableUrl || ""; + const frameOrigin = getUrlOrigin(frameUrl); + if (accessibleOrigin && frameOrigin && frameOrigin === accessibleOrigin) { + collect(child, frameOrigin); + continue; + } + frames.push({ + index: frames.length, + frameId: frame.id, + url: frameUrl, + name: frame.name || "" + }); + } + } + const rootFrame = tree?.frameTree?.frame; + const rootUrl = rootFrame?.url || rootFrame?.unreachableUrl || ""; + collect(tree.frameTree, getUrlOrigin(rootUrl)); + return frames; } function setWorkspaceSession(workspace, session) { - const existing = automationSessions.get(workspace); - if (existing?.idleTimer) clearTimeout(existing.idleTimer); - automationSessions.set(workspace, { - ...session, - idleTimer: null, - idleDeadlineAt: Date.now() + getIdleTimeout(workspace) - }); + const existing = automationSessions.get(workspace); + if (existing?.idleTimer) clearTimeout(existing.idleTimer); + automationSessions.set(workspace, { + ...session, + idleTimer: null, + idleDeadlineAt: Date.now() + getIdleTimeout(workspace) + }); } -/** -* Resolve tabId from command's page (targetId). -* Returns undefined if no page identity is provided. -*/ async function resolveCommandTabId(cmd) { - if (cmd.page) return resolveTabId$1(cmd.page); + if (cmd.page) return resolveTabId$1(cmd.page); + return void 0; } -/** -* Resolve target tab in the automation window, returning both the tabId and -* the Tab object (when available) so callers can skip a redundant chrome.tabs.get(). -*/ async function resolveTab(tabId, workspace, initialUrl) { - if (tabId !== void 0) try { - const tab = await chrome.tabs.get(tabId); - const session = automationSessions.get(workspace); - const matchesSession = session ? session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId : false; - if (isDebuggableUrl(tab.url) && matchesSession) return { - tabId, - tab - }; - if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) { - console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`); - try { - await chrome.tabs.move(tabId, { - windowId: session.windowId, - index: -1 - }); - const moved = await chrome.tabs.get(tabId); - if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) return { - tabId, - tab: moved - }; - } catch (moveErr) { - console.warn(`[opencli] Failed to move tab back: ${moveErr}`); - } - } else if (!isDebuggableUrl(tab.url)) console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); - } catch { - console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); - } - const existingSession = automationSessions.get(workspace); - if (existingSession?.preferredTabId !== null) try { - const preferredTab = await chrome.tabs.get(existingSession.preferredTabId); - if (isDebuggableUrl(preferredTab.url)) return { - tabId: preferredTab.id, - tab: preferredTab - }; - } catch { - automationSessions.delete(workspace); - } - const windowId = await getAutomationWindow(workspace, initialUrl); - const tabs = await chrome.tabs.query({ windowId }); - const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url)); - if (debuggableTab?.id) return { - tabId: debuggableTab.id, - tab: debuggableTab - }; - const reuseTab = tabs.find((t) => t.id); - if (reuseTab?.id) { - await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE }); - await new Promise((resolve) => setTimeout(resolve, 300)); - try { - const updated = await chrome.tabs.get(reuseTab.id); - if (isDebuggableUrl(updated.url)) return { - tabId: reuseTab.id, - tab: updated - }; - console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`); - } catch {} - } - const newTab = await chrome.tabs.create({ - windowId, - url: BLANK_PAGE, - active: true - }); - if (!newTab.id) throw new Error("Failed to create tab in automation window"); - return { - tabId: newTab.id, - tab: newTab - }; + if (tabId !== void 0) { + try { + const tab = await chrome.tabs.get(tabId); + const session = automationSessions.get(workspace); + const matchesSession = session ? session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId : false; + if (isDebuggableUrl(tab.url) && matchesSession) return { tabId, tab }; + if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) { + console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`); + try { + await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 }); + const moved = await chrome.tabs.get(tabId); + if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) { + return { tabId, tab: moved }; + } + } catch (moveErr) { + console.warn(`[opencli] Failed to move tab back: ${moveErr}`); + } + } else if (!isDebuggableUrl(tab.url)) { + console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); + } + } catch { + console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); + } + } + const existingSession = automationSessions.get(workspace); + if (existingSession?.preferredTabId !== null) { + try { + const preferredTab = await chrome.tabs.get(existingSession.preferredTabId); + if (isDebuggableUrl(preferredTab.url)) return { tabId: preferredTab.id, tab: preferredTab }; + } catch { + automationSessions.delete(workspace); + } + } + const windowId = await getAutomationWindow(workspace, initialUrl); + const tabs = await chrome.tabs.query({ windowId }); + const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url)); + if (debuggableTab?.id) return { tabId: debuggableTab.id, tab: debuggableTab }; + const reuseTab = tabs.find((t) => t.id); + if (reuseTab?.id) { + await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE }); + await new Promise((resolve) => setTimeout(resolve, 300)); + try { + const updated = await chrome.tabs.get(reuseTab.id); + if (isDebuggableUrl(updated.url)) return { tabId: reuseTab.id, tab: updated }; + console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`); + } catch { + } + } + const newTab = await chrome.tabs.create({ windowId, url: BLANK_PAGE, active: true }); + if (!newTab.id) throw new Error("Failed to create tab in automation window"); + return { tabId: newTab.id, tab: newTab }; } -/** Build a page-scoped success result with targetId resolved from tabId */ async function pageScopedResult(id, tabId, data) { - return { - id, - ok: true, - data, - page: await resolveTargetId(tabId) - }; + const page = await resolveTargetId(tabId); + return { id, ok: true, data, page }; } -/** Convenience wrapper returning just the tabId (used by most handlers) */ async function resolveTabId(tabId, workspace, initialUrl) { - return (await resolveTab(tabId, workspace, initialUrl)).tabId; + const resolved = await resolveTab(tabId, workspace, initialUrl); + return resolved.tabId; } async function listAutomationTabs(workspace) { - const session = automationSessions.get(workspace); - if (!session) return []; - if (session.preferredTabId !== null) try { - return [await chrome.tabs.get(session.preferredTabId)]; - } catch { - automationSessions.delete(workspace); - return []; - } - try { - return await chrome.tabs.query({ windowId: session.windowId }); - } catch { - automationSessions.delete(workspace); - return []; - } + const session = automationSessions.get(workspace); + if (!session) return []; + if (session.preferredTabId !== null) { + try { + return [await chrome.tabs.get(session.preferredTabId)]; + } catch { + automationSessions.delete(workspace); + return []; + } + } + try { + return await chrome.tabs.query({ windowId: session.windowId }); + } catch { + automationSessions.delete(workspace); + return []; + } } async function listAutomationWebTabs(workspace) { - return (await listAutomationTabs(workspace)).filter((tab) => isDebuggableUrl(tab.url)); + const tabs = await listAutomationTabs(workspace); + return tabs.filter((tab) => isDebuggableUrl(tab.url)); } async function handleExec(cmd, workspace) { - if (!cmd.code) return { - id: cmd.id, - ok: false, - error: "Missing code" - }; - const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); - try { - const aggressive = workspace.startsWith("browser:") || workspace.startsWith("operate:"); - const data = await evaluateAsync(tabId, cmd.code, aggressive); - return pageScopedResult(cmd.id, tabId, data); - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } + if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" }; + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, workspace); + try { + const aggressive = workspace.startsWith("browser:") || workspace.startsWith("operate:"); + if (cmd.frameIndex != null) { + const tree = await getFrameTree(tabId); + const frames = enumerateCrossOriginFrames(tree); + if (cmd.frameIndex < 0 || cmd.frameIndex >= frames.length) { + return { id: cmd.id, ok: false, error: `Frame index ${cmd.frameIndex} out of range (${frames.length} cross-origin frames available)` }; + } + const data2 = await evaluateInFrame(tabId, cmd.code, frames[cmd.frameIndex].frameId, aggressive); + return pageScopedResult(cmd.id, tabId, data2); + } + const data = await evaluateAsync(tabId, cmd.code, aggressive); + return pageScopedResult(cmd.id, tabId, data); + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} +async function handleFrames(cmd, workspace) { + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, workspace); + try { + const tree = await getFrameTree(tabId); + return { id: cmd.id, ok: true, data: enumerateCrossOriginFrames(tree) }; + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } } async function handleNavigate(cmd, workspace) { - if (!cmd.url) return { - id: cmd.id, - ok: false, - error: "Missing url" - }; - if (!isSafeNavigationUrl(cmd.url)) return { - id: cmd.id, - ok: false, - error: "Blocked URL scheme -- only http:// and https:// are allowed" - }; - const resolved = await resolveTab(await resolveCommandTabId(cmd), workspace, cmd.url); - const tabId = resolved.tabId; - const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId); - const beforeNormalized = normalizeUrlForComparison(beforeTab.url); - const targetUrl = cmd.url; - if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) return pageScopedResult(cmd.id, tabId, { - title: beforeTab.title, - url: beforeTab.url, - timedOut: false - }); - if (!hasActiveNetworkCapture(tabId)) await detach(tabId); - await chrome.tabs.update(tabId, { url: targetUrl }); - let timedOut = false; - await new Promise((resolve) => { - let settled = false; - let checkTimer = null; - let timeoutTimer = null; - const finish = () => { - if (settled) return; - settled = true; - chrome.tabs.onUpdated.removeListener(listener); - if (checkTimer) clearTimeout(checkTimer); - if (timeoutTimer) clearTimeout(timeoutTimer); - resolve(); - }; - const isNavigationDone = (url) => { - return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized; - }; - const listener = (id, info, tab) => { - if (id !== tabId) return; - if (info.status === "complete" && isNavigationDone(tab.url ?? info.url)) finish(); - }; - chrome.tabs.onUpdated.addListener(listener); - checkTimer = setTimeout(async () => { - try { - const currentTab = await chrome.tabs.get(tabId); - if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) finish(); - } catch {} - }, 100); - timeoutTimer = setTimeout(() => { - timedOut = true; - console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); - finish(); - }, 15e3); - }); - let tab = await chrome.tabs.get(tabId); - const session = automationSessions.get(workspace); - if (session && tab.windowId !== session.windowId) { - console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${session.windowId}`); - try { - await chrome.tabs.move(tabId, { - windowId: session.windowId, - index: -1 - }); - tab = await chrome.tabs.get(tabId); - } catch (moveErr) { - console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`); - } - } - return pageScopedResult(cmd.id, tabId, { - title: tab.title, - url: tab.url, - timedOut - }); + if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" }; + if (!isSafeNavigationUrl(cmd.url)) { + return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" }; + } + const cmdTabId = await resolveCommandTabId(cmd); + const resolved = await resolveTab(cmdTabId, workspace, cmd.url); + const tabId = resolved.tabId; + const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId); + const beforeNormalized = normalizeUrlForComparison(beforeTab.url); + const targetUrl = cmd.url; + if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) { + return pageScopedResult(cmd.id, tabId, { title: beforeTab.title, url: beforeTab.url, timedOut: false }); + } + if (!hasActiveNetworkCapture(tabId)) { + await detach(tabId); + } + await chrome.tabs.update(tabId, { url: targetUrl }); + let timedOut = false; + await new Promise((resolve) => { + let settled = false; + let checkTimer = null; + let timeoutTimer = null; + const finish = () => { + if (settled) return; + settled = true; + chrome.tabs.onUpdated.removeListener(listener); + if (checkTimer) clearTimeout(checkTimer); + if (timeoutTimer) clearTimeout(timeoutTimer); + resolve(); + }; + const isNavigationDone = (url) => { + return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized; + }; + const listener = (id, info, tab2) => { + if (id !== tabId) return; + if (info.status === "complete" && isNavigationDone(tab2.url ?? info.url)) { + finish(); + } + }; + chrome.tabs.onUpdated.addListener(listener); + checkTimer = setTimeout(async () => { + try { + const currentTab = await chrome.tabs.get(tabId); + if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) { + finish(); + } + } catch { + } + }, 100); + timeoutTimer = setTimeout(() => { + timedOut = true; + console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); + finish(); + }, 15e3); + }); + let tab = await chrome.tabs.get(tabId); + const session = automationSessions.get(workspace); + if (session && tab.windowId !== session.windowId) { + console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${session.windowId}`); + try { + await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 }); + tab = await chrome.tabs.get(tabId); + } catch (moveErr) { + console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`); + } + } + return pageScopedResult(cmd.id, tabId, { title: tab.title, url: tab.url, timedOut }); } async function handleTabs(cmd, workspace) { - switch (cmd.op) { - case "list": { - const tabs = await listAutomationWebTabs(workspace); - const data = await Promise.all(tabs.map(async (t, i) => { - let page; - try { - page = t.id ? await resolveTargetId(t.id) : void 0; - } catch {} - return { - index: i, - page, - url: t.url, - title: t.title, - active: t.active - }; - })); - return { - id: cmd.id, - ok: true, - data - }; - } - case "new": { - if (cmd.url && !isSafeNavigationUrl(cmd.url)) return { - id: cmd.id, - ok: false, - error: "Blocked URL scheme -- only http:// and https:// are allowed" - }; - const windowId = await getAutomationWindow(workspace); - const tab = await chrome.tabs.create({ - windowId, - url: cmd.url ?? BLANK_PAGE, - active: true - }); - if (!tab.id) return { - id: cmd.id, - ok: false, - error: "Failed to create tab" - }; - return pageScopedResult(cmd.id, tab.id, { url: tab.url }); - } - case "close": { - if (cmd.index !== void 0) { - const target = (await listAutomationWebTabs(workspace))[cmd.index]; - if (!target?.id) return { - id: cmd.id, - ok: false, - error: `Tab index ${cmd.index} not found` - }; - const closedPage = await resolveTargetId(target.id).catch(() => void 0); - await chrome.tabs.remove(target.id); - await detach(target.id); - return { - id: cmd.id, - ok: true, - data: { closed: closedPage } - }; - } - const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); - const closedPage = await resolveTargetId(tabId).catch(() => void 0); - await chrome.tabs.remove(tabId); - await detach(tabId); - return { - id: cmd.id, - ok: true, - data: { closed: closedPage } - }; - } - case "select": { - if (cmd.index === void 0 && cmd.page === void 0) return { - id: cmd.id, - ok: false, - error: "Missing index or page" - }; - const cmdTabId = await resolveCommandTabId(cmd); - if (cmdTabId !== void 0) { - const session = automationSessions.get(workspace); - let tab; - try { - tab = await chrome.tabs.get(cmdTabId); - } catch { - return { - id: cmd.id, - ok: false, - error: `Page no longer exists` - }; - } - if (!session || tab.windowId !== session.windowId) return { - id: cmd.id, - ok: false, - error: `Page is not in the automation window` - }; - await chrome.tabs.update(cmdTabId, { active: true }); - return pageScopedResult(cmd.id, cmdTabId, { selected: true }); - } - const target = (await listAutomationWebTabs(workspace))[cmd.index]; - if (!target?.id) return { - id: cmd.id, - ok: false, - error: `Tab index ${cmd.index} not found` - }; - await chrome.tabs.update(target.id, { active: true }); - return pageScopedResult(cmd.id, target.id, { selected: true }); - } - default: return { - id: cmd.id, - ok: false, - error: `Unknown tabs op: ${cmd.op}` - }; - } + switch (cmd.op) { + case "list": { + const tabs = await listAutomationWebTabs(workspace); + const data = await Promise.all(tabs.map(async (t, i) => { + let page; + try { + page = t.id ? await resolveTargetId(t.id) : void 0; + } catch { + } + return { index: i, page, url: t.url, title: t.title, active: t.active }; + })); + return { id: cmd.id, ok: true, data }; + } + case "new": { + if (cmd.url && !isSafeNavigationUrl(cmd.url)) { + return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" }; + } + const windowId = await getAutomationWindow(workspace); + const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true }); + if (!tab.id) return { id: cmd.id, ok: false, error: "Failed to create tab" }; + return pageScopedResult(cmd.id, tab.id, { url: tab.url }); + } + case "close": { + if (cmd.index !== void 0) { + const tabs = await listAutomationWebTabs(workspace); + const target = tabs[cmd.index]; + if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; + const closedPage2 = await resolveTargetId(target.id).catch(() => void 0); + await chrome.tabs.remove(target.id); + await detach(target.id); + return { id: cmd.id, ok: true, data: { closed: closedPage2 } }; + } + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, workspace); + const closedPage = await resolveTargetId(tabId).catch(() => void 0); + await chrome.tabs.remove(tabId); + await detach(tabId); + return { id: cmd.id, ok: true, data: { closed: closedPage } }; + } + case "select": { + if (cmd.index === void 0 && cmd.page === void 0) + return { id: cmd.id, ok: false, error: "Missing index or page" }; + const cmdTabId = await resolveCommandTabId(cmd); + if (cmdTabId !== void 0) { + const session = automationSessions.get(workspace); + let tab; + try { + tab = await chrome.tabs.get(cmdTabId); + } catch { + return { id: cmd.id, ok: false, error: `Page no longer exists` }; + } + if (!session || tab.windowId !== session.windowId) { + return { id: cmd.id, ok: false, error: `Page is not in the automation window` }; + } + await chrome.tabs.update(cmdTabId, { active: true }); + return pageScopedResult(cmd.id, cmdTabId, { selected: true }); + } + const tabs = await listAutomationWebTabs(workspace); + const target = tabs[cmd.index]; + if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; + await chrome.tabs.update(target.id, { active: true }); + return pageScopedResult(cmd.id, target.id, { selected: true }); + } + default: + return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` }; + } } async function handleCookies(cmd) { - if (!cmd.domain && !cmd.url) return { - id: cmd.id, - ok: false, - error: "Cookie scope required: provide domain or url to avoid dumping all cookies" - }; - const details = {}; - if (cmd.domain) details.domain = cmd.domain; - if (cmd.url) details.url = cmd.url; - const data = (await chrome.cookies.getAll(details)).map((c) => ({ - name: c.name, - value: c.value, - domain: c.domain, - path: c.path, - secure: c.secure, - httpOnly: c.httpOnly, - expirationDate: c.expirationDate - })); - return { - id: cmd.id, - ok: true, - data - }; + if (!cmd.domain && !cmd.url) { + return { id: cmd.id, ok: false, error: "Cookie scope required: provide domain or url to avoid dumping all cookies" }; + } + const details = {}; + if (cmd.domain) details.domain = cmd.domain; + if (cmd.url) details.url = cmd.url; + const cookies = await chrome.cookies.getAll(details); + const data = cookies.map((c) => ({ + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + secure: c.secure, + httpOnly: c.httpOnly, + expirationDate: c.expirationDate + })); + return { id: cmd.id, ok: true, data }; } async function handleScreenshot(cmd, workspace) { - const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); - try { - const data = await screenshot(tabId, { - format: cmd.format, - quality: cmd.quality, - fullPage: cmd.fullPage - }); - return pageScopedResult(cmd.id, tabId, data); - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, workspace); + try { + const data = await screenshot(tabId, { + format: cmd.format, + quality: cmd.quality, + fullPage: cmd.fullPage + }); + return pageScopedResult(cmd.id, tabId, data); + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } } -/** CDP methods permitted via the 'cdp' passthrough action. */ -var CDP_ALLOWLIST = new Set([ - "Accessibility.getFullAXTree", - "DOM.getDocument", - "DOM.getBoxModel", - "DOM.getContentQuads", - "DOM.querySelectorAll", - "DOM.scrollIntoViewIfNeeded", - "DOMSnapshot.captureSnapshot", - "Input.dispatchMouseEvent", - "Input.dispatchKeyEvent", - "Input.insertText", - "Page.getLayoutMetrics", - "Page.captureScreenshot", - "Runtime.enable", - "Emulation.setDeviceMetricsOverride", - "Emulation.clearDeviceMetricsOverride" +const CDP_ALLOWLIST = /* @__PURE__ */ new Set([ + // Agent DOM context + "Accessibility.getFullAXTree", + "DOM.getDocument", + "DOM.getBoxModel", + "DOM.getContentQuads", + "DOM.querySelectorAll", + "DOM.scrollIntoViewIfNeeded", + "DOMSnapshot.captureSnapshot", + // Native input events + "Input.dispatchMouseEvent", + "Input.dispatchKeyEvent", + "Input.insertText", + // Page metrics & screenshots + "Page.getLayoutMetrics", + "Page.captureScreenshot", + "Page.getFrameTree", + // Runtime.enable needed for CDP attach setup (Runtime.evaluate goes through 'exec' action) + "Runtime.enable", + // Emulation (used by screenshot full-page) + "Emulation.setDeviceMetricsOverride", + "Emulation.clearDeviceMetricsOverride" ]); async function handleCdp(cmd, workspace) { - if (!cmd.cdpMethod) return { - id: cmd.id, - ok: false, - error: "Missing cdpMethod" - }; - if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) return { - id: cmd.id, - ok: false, - error: `CDP method not permitted: ${cmd.cdpMethod}` - }; - const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); - try { - await ensureAttached(tabId, workspace.startsWith("browser:") || workspace.startsWith("operate:")); - const data = await chrome.debugger.sendCommand({ tabId }, cmd.cdpMethod, cmd.cdpParams ?? {}); - return pageScopedResult(cmd.id, tabId, data); - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } + if (!cmd.cdpMethod) return { id: cmd.id, ok: false, error: "Missing cdpMethod" }; + if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) { + return { id: cmd.id, ok: false, error: `CDP method not permitted: ${cmd.cdpMethod}` }; + } + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, workspace); + try { + const aggressive = workspace.startsWith("browser:") || workspace.startsWith("operate:"); + await ensureAttached(tabId, aggressive); + const data = await chrome.debugger.sendCommand( + { tabId }, + cmd.cdpMethod, + cmd.cdpParams ?? {} + ); + return pageScopedResult(cmd.id, tabId, data); + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } } async function handleCloseWindow(cmd, workspace) { - const session = automationSessions.get(workspace); - if (session) { - if (session.owned) try { - await chrome.windows.remove(session.windowId); - } catch {} - if (session.idleTimer) clearTimeout(session.idleTimer); - workspaceTimeoutOverrides.delete(workspace); - automationSessions.delete(workspace); - } - return { - id: cmd.id, - ok: true, - data: { closed: true } - }; + const session = automationSessions.get(workspace); + if (session) { + if (session.owned) { + try { + await chrome.windows.remove(session.windowId); + } catch { + } + } + if (session.idleTimer) clearTimeout(session.idleTimer); + workspaceTimeoutOverrides.delete(workspace); + automationSessions.delete(workspace); + } + return { id: cmd.id, ok: true, data: { closed: true } }; } async function handleSetFileInput(cmd, workspace) { - if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) return { - id: cmd.id, - ok: false, - error: "Missing or empty files array" - }; - const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); - try { - await setFileInputFiles(tabId, cmd.files, cmd.selector); - return pageScopedResult(cmd.id, tabId, { count: cmd.files.length }); - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } + if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) { + return { id: cmd.id, ok: false, error: "Missing or empty files array" }; + } + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, workspace); + try { + await setFileInputFiles(tabId, cmd.files, cmd.selector); + return pageScopedResult(cmd.id, tabId, { count: cmd.files.length }); + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } } async function handleInsertText(cmd, workspace) { - if (typeof cmd.text !== "string") return { - id: cmd.id, - ok: false, - error: "Missing text payload" - }; - const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); - try { - await insertText(tabId, cmd.text); - return pageScopedResult(cmd.id, tabId, { inserted: true }); - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } + if (typeof cmd.text !== "string") { + return { id: cmd.id, ok: false, error: "Missing text payload" }; + } + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, workspace); + try { + await insertText(tabId, cmd.text); + return pageScopedResult(cmd.id, tabId, { inserted: true }); + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } } async function handleNetworkCaptureStart(cmd, workspace) { - const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); - try { - await startNetworkCapture(tabId, cmd.pattern); - return pageScopedResult(cmd.id, tabId, { started: true }); - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, workspace); + try { + await startNetworkCapture(tabId, cmd.pattern); + return pageScopedResult(cmd.id, tabId, { started: true }); + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } } async function handleNetworkCaptureRead(cmd, workspace) { - const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace); - try { - const data = await readNetworkCapture(tabId); - return pageScopedResult(cmd.id, tabId, data); - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, workspace); + try { + const data = await readNetworkCapture(tabId); + return pageScopedResult(cmd.id, tabId, data); + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } } async function handleSessions(cmd) { - const now = Date.now(); - const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({ - workspace, - windowId: session.windowId, - tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length, - idleMsRemaining: Math.max(0, session.idleDeadlineAt - now) - }))); - return { - id: cmd.id, - ok: true, - data - }; + const now = Date.now(); + const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({ + workspace, + windowId: session.windowId, + tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length, + idleMsRemaining: Math.max(0, session.idleDeadlineAt - now) + }))); + return { id: cmd.id, ok: true, data }; } async function handleBindCurrent(cmd, workspace) { - const activeTabs = await chrome.tabs.query({ - active: true, - lastFocusedWindow: true - }); - const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true }); - const allTabs = await chrome.tabs.query({}); - const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? allTabs.find((tab) => matchesBindCriteria(tab, cmd)); - if (!boundTab?.id) return { - id: cmd.id, - ok: false, - error: cmd.matchDomain || cmd.matchPathPrefix ? `No visible tab matching ${cmd.matchDomain ?? "domain"}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ""}` : "No active debuggable tab found" - }; - setWorkspaceSession(workspace, { - windowId: boundTab.windowId, - owned: false, - preferredTabId: boundTab.id - }); - resetWindowIdleTimer(workspace); - console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`); - return pageScopedResult(cmd.id, boundTab.id, { - url: boundTab.url, - title: boundTab.title, - workspace - }); + const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); + const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true }); + const allTabs = await chrome.tabs.query({}); + const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? allTabs.find((tab) => matchesBindCriteria(tab, cmd)); + if (!boundTab?.id) { + return { + id: cmd.id, + ok: false, + error: cmd.matchDomain || cmd.matchPathPrefix ? `No visible tab matching ${cmd.matchDomain ?? "domain"}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ""}` : "No active debuggable tab found" + }; + } + setWorkspaceSession(workspace, { + windowId: boundTab.windowId, + owned: false, + preferredTabId: boundTab.id + }); + resetWindowIdleTimer(workspace); + console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`); + return pageScopedResult(cmd.id, boundTab.id, { + url: boundTab.url, + title: boundTab.title, + workspace + }); } -//#endregion diff --git a/extension/manifest.json b/extension/manifest.json index d8190dfe9..8d3c22f66 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -8,7 +8,8 @@ "tabs", "cookies", "activeTab", - "alarms" + "alarms", + "storage" ], "host_permissions": [ "" diff --git a/extension/popup.html b/extension/popup.html index 02ca1b972..ff77e8e94 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -54,6 +54,50 @@ border-radius: 3px; font-size: 11px; } + .port-section { + margin-top: 12px; + padding: 10px 12px; + border-radius: 8px; + background: #f5f5f5; + } + .port-section label { + font-size: 11px; + color: #666; + display: block; + margin-bottom: 6px; + } + .port-row { + display: flex; + gap: 6px; + align-items: center; + } + .port-row input { + flex: 1; + padding: 5px 8px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 13px; + font-family: inherit; + outline: none; + } + .port-row input:focus { border-color: #007aff; } + .port-row button { + padding: 5px 12px; + border: none; + border-radius: 4px; + background: #007aff; + color: #fff; + font-size: 12px; + cursor: pointer; + } + .port-row button:hover { background: #006ae0; } + .port-msg { + font-size: 11px; + margin-top: 4px; + height: 16px; + } + .port-msg.ok { color: #34c759; } + .port-msg.err { color: #ff3b30; } .footer { margin-top: 14px; text-align: center; @@ -76,6 +120,14 @@

OpenCLI

This is normal. The extension connects automatically when you run any opencli command.
+
+ +
+ + +
+
+
diff --git a/extension/popup.js b/extension/popup.js index 4bd3a7d48..1bfb5879f 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -1,3 +1,27 @@ +const DEFAULT_PORT = 19825; + +// Load saved port into input +chrome.storage.local.get('daemonPort', (result) => { + document.getElementById('portInput').value = result.daemonPort || DEFAULT_PORT; +}); + +// Save port +document.getElementById('portSave').addEventListener('click', () => { + const input = document.getElementById('portInput'); + const msg = document.getElementById('portMsg'); + const val = parseInt(input.value, 10); + if (!Number.isInteger(val) || val < 1024 || val > 65535) { + msg.textContent = 'Port must be 1024-65535'; + msg.className = 'port-msg err'; + return; + } + chrome.storage.local.set({ daemonPort: val }, () => { + msg.textContent = 'Saved'; + msg.className = 'port-msg ok'; + setTimeout(() => { msg.textContent = ''; }, 2000); + }); +}); + // Query connection status from background service worker chrome.runtime.sendMessage({ type: 'getStatus' }, (resp) => { const dot = document.getElementById('dot'); @@ -9,17 +33,18 @@ chrome.runtime.sendMessage({ type: 'getStatus' }, (resp) => { hint.style.display = 'block'; return; } + const portLabel = resp.port ? ` (port ${resp.port})` : ''; if (resp.connected) { dot.className = 'dot connected'; - status.innerHTML = 'Connected to daemon'; + status.innerHTML = `Connected to daemon${portLabel}`; hint.style.display = 'none'; } else if (resp.reconnecting) { dot.className = 'dot connecting'; - status.innerHTML = 'Reconnecting...'; + status.innerHTML = `Reconnecting...${portLabel}`; hint.style.display = 'none'; } else { dot.className = 'dot disconnected'; - status.innerHTML = 'No daemon connected'; + status.innerHTML = `No daemon connected${portLabel}`; hint.style.display = 'block'; } }); diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index 42394389b..82df9f22a 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -116,6 +116,13 @@ function createChromeMock() { cookies: { getAll: vi.fn(async () => []), }, + storage: { + local: { + get: vi.fn((_key: string, cb: (result: Record) => void) => cb({})), + set: vi.fn((_items: Record, cb?: () => void) => cb?.()), + }, + onChanged: { addListener: vi.fn() } as Listener<(changes: Record, areaName: string) => void>, + }, }; return { chrome, tabs, query, create, update }; diff --git a/extension/src/background.ts b/extension/src/background.ts index d7e2e0719..bcb8efc74 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -8,10 +8,51 @@ declare const __OPENCLI_COMPAT_RANGE__: string; import type { Command, Result } from './protocol'; -import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; +import { DAEMON_PORT, DAEMON_HOST, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; import * as executor from './cdp'; import * as identity from './identity'; +// ─── Dynamic daemon port ──────────────────────────────────────────────── +// Each Chrome profile has independent chrome.storage.local, so different +// profiles can target different daemon ports for multi-profile support. + +let daemonPort: number = DAEMON_PORT; + +function getDaemonWsUrl(): string { + return `ws://${DAEMON_HOST}:${daemonPort}/ext`; +} + +function getDaemonPingUrl(): string { + return `http://${DAEMON_HOST}:${daemonPort}/ping`; +} + +function loadDaemonPort(): Promise { + return new Promise((resolve) => { + chrome.storage.local.get('daemonPort', (result) => { + if (result.daemonPort && Number.isInteger(result.daemonPort) && result.daemonPort >= 1024 && result.daemonPort <= 65535) { + daemonPort = result.daemonPort; + } + resolve(); + }); + }); +} + +chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== 'local' || !changes.daemonPort) return; + const newPort = changes.daemonPort.newValue; + if (newPort && Number.isInteger(newPort) && newPort >= 1024 && newPort <= 65535) { + daemonPort = newPort; + } else { + daemonPort = DAEMON_PORT; + } + if (ws) { + ws.close(); + ws = null; + } + reconnectAttempts = 0; + void connect(); +}); + let ws: WebSocket | null = null; let reconnectTimer: ReturnType | null = null; let reconnectAttempts = 0; @@ -48,14 +89,14 @@ async function connect(): Promise { if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; try { - const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1000) }); + const res = await fetch(getDaemonPingUrl(), { signal: AbortSignal.timeout(1000) }); if (!res.ok) return; // unexpected response — not our daemon } catch { return; // daemon not running — skip WebSocket to avoid console noise } try { - ws = new WebSocket(DAEMON_WS_URL); + ws = new WebSocket(getDaemonWsUrl()); } catch { scheduleReconnect(); return; @@ -262,12 +303,13 @@ chrome.tabs.onRemoved.addListener((tabId) => { let initialized = false; -function initialize(): void { +async function initialize(): Promise { if (initialized) return; initialized = true; chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds executor.registerListeners(); executor.registerFrameTracking(); + await loadDaemonPort(); void connect(); console.log('[opencli] OpenCLI extension initialized'); } @@ -291,6 +333,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { sendResponse({ connected: ws?.readyState === WebSocket.OPEN, reconnecting: reconnectTimer !== null, + port: daemonPort, }); } return false; diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index aad6532f7..1fed335cf 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -9,8 +9,7 @@ import * as fs from 'node:fs'; import type { IPage } from '../types.js'; import type { IBrowserFactory } from '../runtime.js'; import { Page } from './page.js'; -import { getDaemonHealth, requestDaemonShutdown } from './daemon-client.js'; -import { DEFAULT_DAEMON_PORT } from '../constants.js'; +import { getDaemonHealth, requestDaemonShutdown, getEffectiveDaemonPort } from './daemon-client.js'; import { BrowserConnectError } from '../errors.js'; import { PKG_VERSION } from '../version.js'; @@ -154,7 +153,7 @@ export class BrowserBridge implements IBrowserFactory { throw new BrowserConnectError( 'Failed to start opencli daemon', - `Try running manually:\n node ${daemonPath}\nMake sure port ${DEFAULT_DAEMON_PORT} is available.`, + `Try running manually:\n node ${daemonPath}\nMake sure port ${getEffectiveDaemonPort()} is available.`, 'daemon-not-running', ); } diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index 4f956f176..f7f62a5f4 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -9,7 +9,11 @@ import type { BrowserSessionInfo } from '../types.js'; import { sleep } from '../utils.js'; import { classifyBrowserError } from './errors.js'; -const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10); +export function getEffectiveDaemonPort(): number { + return parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10); +} + +const DAEMON_PORT = getEffectiveDaemonPort(); const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`; const OPENCLI_HEADERS = { 'X-OpenCLI': '1' }; diff --git a/src/browser/errors.ts b/src/browser/errors.ts index 1889b2f2e..751c509da 100644 --- a/src/browser/errors.ts +++ b/src/browser/errors.ts @@ -8,6 +8,10 @@ import { BrowserConnectError, type BrowserConnectKind } from '../errors.js'; import { DEFAULT_DAEMON_PORT } from '../constants.js'; +function getEffectiveDaemonPort(): number { + return parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10); +} + /** * Unified browser error classification. * @@ -102,7 +106,7 @@ export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: str case 'daemon-not-running': return new BrowserConnectError( 'Cannot connect to opencli daemon.' + (detail ? `\n\n${detail}` : ''), - `The daemon should auto-start. If it keeps failing, make sure port ${DEFAULT_DAEMON_PORT} is available.`, + `The daemon should auto-start. If it keeps failing, make sure port ${getEffectiveDaemonPort()} is available.`, kind, ); case 'extension-not-connected': diff --git a/src/doctor.ts b/src/doctor.ts index 494ed51df..fbc567f2f 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -66,6 +66,7 @@ export type DoctorReport = { extensionVersion?: string; latestExtensionVersion?: string; connectivity?: ConnectivityResult; + port?: number; sessions?: Array<{ workspace: string; windowId: number; tabCount: number; idleMsRemaining: number }>; issues: string[]; }; @@ -204,6 +205,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise