From d954ec1e0b3162ddfaa18615027cf4a2ccef9e5f Mon Sep 17 00:00:00 2001 From: freemandealer Date: Fri, 17 Apr 2026 19:13:50 +0800 Subject: [PATCH 1/5] fix(browser): harden multi-tab routing and target isolation - make daemon command ids collision-resistant and retry duplicate pending ids\n- add validated tab list/new/select/close flows with persisted default targets\n- keep untargeted browser commands on the default tab unless tab select changes it\n- document tab targeting and add unit, extension, and e2e coverage for concurrent multi-tab execution --- README.md | 4 + README.zh-CN.md | 4 + docs/guide/browser-bridge.md | 21 + docs/zh/guide/browser-bridge.md | 21 + extension/dist/background.js | 1985 +++++++++++++++-------------- extension/src/background.test.ts | 105 ++ extension/src/background.ts | 1 + src/browser/base-page.ts | 2 +- src/browser/cdp.ts | 2 +- src/browser/daemon-client.test.ts | 47 + src/browser/daemon-client.ts | 7 +- src/browser/page.test.ts | 100 ++ src/browser/page.ts | 45 +- src/cli.test.ts | 183 +++ src/cli.ts | 241 +++- src/daemon.ts | 8 + src/types.ts | 10 +- tests/e2e/browser-tabs.test.ts | 303 +++++ vitest.config.ts | 1 + 19 files changed, 2131 insertions(+), 959 deletions(-) create mode 100644 tests/e2e/browser-tabs.test.ts diff --git a/README.md b/README.md index d59fe84fe..848d57aec 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,10 @@ The agent handles all the `opencli browser` commands internally — you just des - [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md) — real-time browser operation - [`skills/opencli-explorer/SKILL.md`](./skills/opencli-explorer/SKILL.md) — adapter creation workflow +Available browser commands include `open`, `state`, `click`, `type`, `select`, `keys`, `wait`, `get`, `screenshot`, `scroll`, `back`, `eval`, `network`, `tab list`, `tab new`, `tab select`, `tab close`, `init`, `verify`, and `close`. + +Use `opencli browser tab list` to inspect tab target IDs, then pass `--tab ` to route a command to a specific tab. `tab new` creates a new tab without changing the default browser target; only `tab select ` promotes that tab to the default target for later untargeted `opencli browser ...` commands. + ## Core Concepts ### `browser`: AI Agent browser control diff --git a/README.zh-CN.md b/README.zh-CN.md index fb1a44cae..2396f089b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -110,6 +110,10 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自 - [`skills/opencli-browser/SKILL.md`](./skills/opencli-browser/SKILL.md) — 实时浏览器操作 - [`skills/opencli-explorer/SKILL.md`](./skills/opencli-explorer/SKILL.md) — 适配器创建工作流 +`browser` 可用命令包括:`open`、`state`、`click`、`type`、`select`、`keys`、`wait`、`get`、`screenshot`、`scroll`、`back`、`eval`、`network`、`tab list`、`tab new`、`tab select`、`tab close`、`init`、`verify`、`close`。 + +可以先用 `opencli browser tab list` 查看 tab 的 target ID,再通过 `--tab ` 把命令明确路由到某个 tab。`tab new` 只会新建 tab,不会改变默认浏览器目标;只有显式执行 `tab select `,才会把该 tab 设为后续未指定 target 的 `opencli browser ...` 命令的默认目标。 + ## 核心概念 ### `browser`:AI Agent 的浏览器控制层 diff --git a/docs/guide/browser-bridge.md b/docs/guide/browser-bridge.md index c13374fab..7ba0224a5 100644 --- a/docs/guide/browser-bridge.md +++ b/docs/guide/browser-bridge.md @@ -25,6 +25,27 @@ That's it! The daemon auto-starts when you run any browser command. No tokens, n opencli doctor # Check extension + daemon connectivity ``` +## Tab Targeting + +Browser commands run inside the shared `browser:default` workspace unless you explicitly choose another tab target. + +```bash +opencli browser tab list +opencli browser tab new https://www.baidu.com/ +opencli browser eval --tab 'document.title' +opencli browser tab select +opencli browser get title +opencli browser tab close +``` + +Key rules: + +- `opencli browser tab list` prints the current tab `targetId` values. +- `--tab ` routes a single browser command to that specific tab. +- `tab new` creates a new tab but does not change the default browser target. +- `tab select ` makes that tab the default target for later untargeted `opencli browser ...` commands. +- `tab close ` removes the tab; if it was the current default target, the stored default is cleared. + ## How It Works ``` diff --git a/docs/zh/guide/browser-bridge.md b/docs/zh/guide/browser-bridge.md index 299705b7e..728d10e29 100644 --- a/docs/zh/guide/browser-bridge.md +++ b/docs/zh/guide/browser-bridge.md @@ -23,6 +23,27 @@ OpenCLI 通过轻量级 **Browser Bridge** Chrome 扩展 + 微守护进程连接 opencli doctor # 检查扩展 + 守护进程连接 ``` +## 多 Tab 定位 + +浏览器命令默认运行在共享的 `browser:default` workspace 中;如果需要操作指定 tab,可以显式传目标 target。 + +```bash +opencli browser tab list +opencli browser tab new https://www.baidu.com/ +opencli browser eval --tab 'document.title' +opencli browser tab select +opencli browser get title +opencli browser tab close +``` + +规则如下: + +- `opencli browser tab list` 会打印当前 tab 的 `targetId`。 +- `--tab ` 会把单条 browser 命令路由到对应 tab。 +- `tab new` 只会新建 tab,不会改变默认浏览器目标。 +- `tab select ` 会把该 tab 设为后续未显式指定 target 的 `opencli browser ...` 命令默认目标。 +- `tab close ` 会关闭该 tab;如果它正好是当前默认目标,会一并清掉这条默认绑定。 + ## Daemon 生命周期 Daemon 在首次运行浏览器命令时自动启动,之后保持常驻运行。 diff --git a/extension/dist/background.js b/extension/dist/background.js index 7b30a6d53..0cb0a2762 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -1,1055 +1,1184 @@ -const DAEMON_PORT = 19825; -const DAEMON_HOST = "localhost"; -const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; -const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; -const WS_RECONNECT_BASE_DELAY = 2e3; -const WS_RECONNECT_MAX_DELAY = 5e3; - -const attached = /* @__PURE__ */ new Set(); -const networkCaptures = /* @__PURE__ */ new Map(); +//#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. */ 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"); - 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"); + 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 evaluateAsync = evaluate; +var evaluateAsync = evaluate; +/** +* Capture a screenshot via CDP Page.captureScreenshot. +* Returns base64-encoded image data. +*/ 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)); - } - 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(() => { - }); - } - } + 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(() => {}); + } } +/** +* 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 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); + 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); + }); + 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 {} + } + }); } - -const targetToTab = /* @__PURE__ */ new Map(); -const tabToTarget = /* @__PURE__ */ new Map(); +//#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). +*/ 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 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); +//#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 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 { - const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) }); - if (!res.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 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(); - }; + 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(); + }; } -const MAX_EAGER_ATTEMPTS = 6; +/** +* 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; 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; - void 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; + connect(); + }, delay); } -const automationSessions = /* @__PURE__ */ new Map(); -const IDLE_TIMEOUT_DEFAULT = 3e4; -const IDLE_TIMEOUT_INTERACTIVE = 6e5; -const workspaceTimeoutOverrides = /* @__PURE__ */ new Map(); +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(); 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; } -let windowFocused = false; +var 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); }); -let initialized = false; +var initialized = false; function initialize() { - if (initialized) return; - initialized = true; - chrome.alarms.create("keepalive", { periodInMinutes: 0.4 }); - registerListeners(); - void connect(); - console.log("[opencli] OpenCLI extension initialized"); + if (initialized) return; + initialized = true; + chrome.alarms.create("keepalive", { periodInMinutes: .4 }); + registerListeners(); + 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") void connect(); + if (alarm.name === "keepalive") 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 + }); + 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); + 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 BLANK_PAGE = "about:blank"; +/** 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. */ 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 { - const parsed = new URL(tab.url); - if (!parsed.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 { + if (!new URL(tab.url).pathname.startsWith(cmd.matchPathPrefix)) return false; + } catch { + return false; + } + return true; } 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); - return cmd.tabId; + if (cmd.page) return resolveTabId$1(cmd.page); } +/** +* 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) { - const page = await resolveTargetId(tabId); - return { id, ok: true, data, page }; + return { + id, + ok: true, + data, + page: await resolveTargetId(tabId) + }; } +/** Convenience wrapper returning just the tabId (used by most handlers) */ async function resolveTabId(tabId, workspace, initialUrl) { - const resolved = await resolveTab(tabId, workspace, initialUrl); - return resolved.tabId; + return (await resolveTab(tabId, workspace, initialUrl)).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) { - const tabs = await listAutomationTabs(workspace); - return tabs.filter((tab) => isDebuggableUrl(tab.url)); + return (await listAutomationTabs(workspace)).filter((tab) => isDebuggableUrl(tab.url)); } async function handleExec(cmd, workspace) { - 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:"); - 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 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) + }; + } } 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 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 }); + 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 + }); } 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 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 && cmd.tabId === 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}` }; - } + 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}` + }; + } } 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 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 }; + 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 + }; } async function handleScreenshot(cmd, workspace) { - 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) }; - } + 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 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", - // 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" +/** 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" ]); 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 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) }; - } + 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) + }; + } } 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 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) }; - } + 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) + }; + } } async function handleInsertText(cmd, workspace) { - 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) }; - } + 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) + }; + } } async function handleNetworkCaptureStart(cmd, workspace) { - 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) }; - } + 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) + }; + } } async function handleNetworkCaptureRead(cmd, workspace) { - 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) }; - } + 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) + }; + } } 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/src/background.test.ts b/extension/src/background.test.ts index 54543d7c5..b77b35924 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -167,6 +167,26 @@ describe('background tab isolation', () => { expect(create).toHaveBeenCalledWith({ windowId: 1, url: 'https://new.example', active: true }); }); + it('closes a tab by page identity', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId('site:twitter', 1); + + const result = await mod.__test__.handleTabs( + { id: 'close-by-page', action: 'tabs', op: 'close', workspace: 'site:twitter', page: 'target-1' }, + 'site:twitter', + ); + + expect(result).toEqual({ + id: 'close-by-page', + ok: true, + data: { closed: 'target-1' }, + }); + expect(chrome.tabs.remove).toHaveBeenCalledWith(1); + }); + it('treats normalized same-url navigate as already complete', async () => { const { chrome, tabs, update } = createChromeMock(); tabs[0].url = 'https://www.bilibili.com/'; @@ -262,6 +282,91 @@ describe('background tab isolation', () => { ])); }); + it('can execute concurrently on two pages in the same workspace', async () => { + const { chrome, tabs } = createChromeMock(); + tabs.push({ + id: 4, + windowId: 1, + url: 'https://automation-2.example', + title: 'automation-2', + active: false, + status: 'complete', + }); + vi.stubGlobal('chrome', chrome); + + let inFlight = 0; + let maxInFlight = 0; + vi.doMock('./cdp', () => ({ + registerListeners: vi.fn(), + evaluateAsync: vi.fn(async (tabId: number, code: string) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise(resolve => setTimeout(resolve, 30)); + inFlight--; + return { tabId, code }; + }), + })); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId('site:parallel', 1); + + const [first, second] = await Promise.all([ + mod.__test__.handleExec({ id: 'p1', action: 'exec', workspace: 'site:parallel', page: 'target-1', code: 'window.__task = 1' }, 'site:parallel'), + mod.__test__.handleExec({ id: 'p2', action: 'exec', workspace: 'site:parallel', page: 'target-4', code: 'window.__task = 2' }, 'site:parallel'), + ]); + + expect(first).toEqual(expect.objectContaining({ + ok: true, + page: 'target-1', + data: { tabId: 1, code: 'window.__task = 1' }, + })); + expect(second).toEqual(expect.objectContaining({ + ok: true, + page: 'target-4', + data: { tabId: 4, code: 'window.__task = 2' }, + })); + expect(maxInFlight).toBe(2); + }); + + it('can execute concurrently across two workspaces/windows', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + let inFlight = 0; + let maxInFlight = 0; + vi.doMock('./cdp', () => ({ + registerListeners: vi.fn(), + evaluateAsync: vi.fn(async (tabId: number, code: string) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise(resolve => setTimeout(resolve, 30)); + inFlight--; + return { tabId, code }; + }), + })); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId('site:twitter', 1); + mod.__test__.setAutomationWindowId('site:zhihu', 2); + + const [first, second] = await Promise.all([ + mod.__test__.handleExec({ id: 'w1', action: 'exec', workspace: 'site:twitter', code: 'window.__window = 1' }, 'site:twitter'), + mod.__test__.handleExec({ id: 'w2', action: 'exec', workspace: 'site:zhihu', code: 'window.__window = 2' }, 'site:zhihu'), + ]); + + expect(first).toEqual(expect.objectContaining({ + ok: true, + page: 'target-1', + data: { tabId: 1, code: 'window.__window = 1' }, + })); + expect(second).toEqual(expect.objectContaining({ + ok: true, + page: 'target-2', + data: { tabId: 2, code: 'window.__window = 2' }, + })); + expect(maxInFlight).toBe(2); + }); + it('keeps site:notebooklm inside its owned automation window instead of rebinding to a user tab', async () => { const { chrome, tabs } = createChromeMock(); tabs[0].url = 'https://notebooklm.google.com/'; diff --git a/extension/src/background.ts b/extension/src/background.ts index d4fe12d77..a20c161ea 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -904,6 +904,7 @@ async function handleBindCurrent(cmd: Command, workspace: string): Promise; abstract screenshot(options?: ScreenshotOptions): Promise; abstract tabs(): Promise; - abstract selectTab(index: number): Promise; + abstract selectTab(target: number | string): Promise; // ── Shared DOM helper implementations ── diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index 6d1319bf9..1624ea485 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -339,7 +339,7 @@ class CDPPage extends BasePage { return []; } - async selectTab(_index: number): Promise { + async selectTab(_target: number | string): Promise { // Not supported in direct CDP mode } } diff --git a/src/browser/daemon-client.test.ts b/src/browser/daemon-client.test.ts index 6361233a0..415d6fe83 100644 --- a/src/browser/daemon-client.test.ts +++ b/src/browser/daemon-client.test.ts @@ -4,6 +4,7 @@ import { fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown, + sendCommand, } from './daemon-client.js'; describe('daemon-client', () => { @@ -103,4 +104,50 @@ describe('daemon-client', () => { await expect(getDaemonHealth()).resolves.toEqual({ state: 'ready', status }); }); + + it('sendCommand includes the current pid in generated command ids', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000); + vi.mocked(fetch).mockResolvedValue({ + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }), + } as Response); + + await expect(sendCommand('exec', { code: '1 + 1' })).resolves.toBe('ok'); + await expect(sendCommand('exec', { code: '2 + 2' })).resolves.toBe('ok'); + + const ids = vi.mocked(fetch).mock.calls.map(([, init]) => { + const body = JSON.parse(String(init?.body)) as { id: string }; + return body.id; + }); + + expect(ids).toHaveLength(2); + expect(ids[0]).toMatch(new RegExp(`^cmd_${process.pid}_1763000000000_\\d+$`)); + expect(ids[1]).toMatch(new RegExp(`^cmd_${process.pid}_1763000000000_\\d+$`)); + expect(ids[0]).not.toBe(ids[1]); + }); + + it('sendCommand retries with a new id when daemon reports a duplicate pending id', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_123); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce({ + ok: false, + status: 409, + json: () => Promise.resolve({ ok: false, error: 'Duplicate command id already pending; retry' }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 42 }), + } as Response); + + await expect(sendCommand('exec', { code: '6 * 7' })).resolves.toBe(42); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const ids = fetchMock.mock.calls.map(([, init]) => { + const body = JSON.parse(String(init?.body)) as { id: string }; + return body.id; + }); + expect(ids[0]).not.toBe(ids[1]); + }); }); diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index beb62c78a..3b0590b33 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -16,7 +16,7 @@ const OPENCLI_HEADERS = { 'X-OpenCLI': '1' }; let _idCounter = 0; function generateId(): string { - return `cmd_${Date.now()}_${++_idCounter}`; + return `cmd_${process.pid}_${Date.now()}_${++_idCounter}`; } export interface DaemonCommand { @@ -155,6 +155,11 @@ async function sendCommandRaw( const result = (await res.json()) as DaemonResult; if (!result.ok) { + const isDuplicateCommandId = res.status === 409 + || (result.error ?? '').includes('Duplicate command id'); + if (isDuplicateCommandId && attempt < maxRetries) { + continue; + } const advice = classifyBrowserError(new Error(result.error ?? '')); if (advice.retryable && attempt < maxRetries) { await sleep(advice.delayMs); diff --git a/src/browser/page.test.ts b/src/browser/page.test.ts index 533d4b331..8718b912f 100644 --- a/src/browser/page.test.ts +++ b/src/browser/page.test.ts @@ -132,3 +132,103 @@ describe('Page network capture compatibility', () => { expect(warnMock).toHaveBeenCalledTimes(1); }); }); + +describe('Page active target tracking', () => { + beforeEach(() => { + sendCommandMock.mockReset(); + sendCommandFullMock.mockReset(); + warnMock.mockReset(); + }); + + it('tracks only one active page identity at a time', async () => { + sendCommandFullMock + .mockResolvedValueOnce({ data: { url: 'https://first.example' }, page: 'page-1' }) + .mockResolvedValueOnce({ data: { selected: true }, page: 'page-2' }); + sendCommandMock.mockResolvedValue('ok'); + + const page = new Page('browser:default'); + + await page.goto('https://first.example', { waitUntil: 'none' }); + expect(page.getActivePage()).toBe('page-1'); + + await page.selectTab(1); + expect(page.getActivePage()).toBe('page-2'); + + await page.evaluate('1 + 1'); + + expect(sendCommandMock).toHaveBeenLastCalledWith('exec', expect.objectContaining({ + workspace: 'browser:default', + page: 'page-2', + })); + }); + + it('allows the caller to bind a specific active page identity explicitly', async () => { + sendCommandMock.mockResolvedValue('bound'); + + const page = new Page('browser:default'); + page.setActivePage?.('page-explicit'); + + await page.evaluate('1 + 1'); + + expect(sendCommandMock).toHaveBeenCalledWith('exec', expect.objectContaining({ + workspace: 'browser:default', + page: 'page-explicit', + })); + }); + + it('creates a new tab and adopts its returned page identity', async () => { + sendCommandFullMock.mockResolvedValueOnce({ + data: { url: 'https://second.example' }, + page: 'page-2', + }); + sendCommandMock.mockResolvedValue('ok'); + + const page = new Page('browser:default'); + const created = await page.newTab?.('https://second.example'); + + expect(created).toBe('page-2'); + expect(page.getActivePage()).toBe('page-2'); + expect(sendCommandFullMock).toHaveBeenCalledWith('tabs', expect.objectContaining({ + op: 'new', + url: 'https://second.example', + workspace: 'browser:default', + })); + }); + + it('closes a tab by explicit page identity', async () => { + sendCommandMock.mockResolvedValueOnce({ closed: 'page-2' }); + + const page = new Page('browser:default'); + await page.closeTab?.('page-2'); + + expect(sendCommandMock).toHaveBeenCalledWith('tabs', expect.objectContaining({ + op: 'close', + workspace: 'browser:default', + page: 'page-2', + })); + }); + + it('clears the active page binding when closing the selected tab by numeric index', async () => { + sendCommandFullMock.mockResolvedValueOnce({ data: { selected: true }, page: 'page-2' }); + sendCommandMock + .mockResolvedValueOnce({ closed: 'page-2' }) + .mockResolvedValueOnce('ok'); + + const page = new Page('browser:default'); + + await page.selectTab(1); + expect(page.getActivePage()).toBe('page-2'); + + await page.closeTab?.(1); + expect(page.getActivePage()).toBeUndefined(); + + await page.evaluate('1 + 1'); + + const evalCall = sendCommandMock.mock.calls.at(-1); + expect(evalCall?.[0]).toBe('exec'); + expect(evalCall?.[1]).toEqual(expect.objectContaining({ + workspace: 'browser:default', + })); + expect(evalCall?.[1]).not.toHaveProperty('page'); + }); +}); diff --git a/src/browser/page.ts b/src/browser/page.ts index c81f373d0..3c5d44e6d 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -108,6 +108,16 @@ export class Page extends BasePage { return this._page; } + /** Bind this Page instance to a specific page identity (targetId). */ + setActivePage(page?: string): void { + this._page = page; + this._lastUrl = null; + } + + /** @deprecated Use getActivePage() instead */ + getActiveTabId(): number | undefined { + return undefined; + } private _markUnsupportedNetworkCapture(): void { this._networkCaptureUnsupported = true; if (this._networkCaptureWarned) return; @@ -154,9 +164,40 @@ export class Page extends BasePage { return Array.isArray(result) ? result : []; } - async selectTab(index: number): Promise { - const result = await sendCommandFull('tabs', { op: 'select', index, ...this._wsOpt() }); + async newTab(url?: string): Promise { + const result = await sendCommandFull('tabs', { + op: 'new', + ...(url !== undefined && { url }), + ...this._wsOpt(), + }); + if (result.page) this._page = result.page; + this._lastUrl = null; + return result.page; + } + + async closeTab(target?: number | string): Promise { + const params: Record = { op: 'close', ...this._wsOpt() }; + if (typeof target === 'number') params.index = target; + else if (typeof target === 'string') params.page = target; + else if (this._page !== undefined) params.page = this._page; + + const result = await sendCommand('tabs', params) as { closed?: string } | null; + const closedPage = typeof result?.closed === 'string' ? result.closed : undefined; + + if ((closedPage && closedPage === this._page) || (!closedPage && (target === undefined || target === this._page))) { + this._page = undefined; + this._lastUrl = null; + } + } + + async selectTab(target: number | string): Promise { + const result = await sendCommandFull('tabs', { + op: 'select', + ...(typeof target === 'number' ? { index: target } : { page: target }), + ...this._wsOpt(), + }); if (result.page) this._page = result.page; + this._lastUrl = null; } /** diff --git a/src/cli.test.ts b/src/cli.test.ts index e1393985c..7be3885c3 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import type { IPage } from './types.js'; @@ -13,6 +15,9 @@ const { mockRenderCascadeResult, mockGetBrowserFactory, mockBrowserSession, + mockBrowserConnect, + mockBrowserClose, + browserState, } = vi.hoisted(() => ({ mockExploreUrl: vi.fn(), mockRenderExploreSummary: vi.fn(), @@ -24,6 +29,9 @@ const { mockRenderCascadeResult: vi.fn(), mockGetBrowserFactory: vi.fn(() => ({ name: 'BrowserFactory' })), mockBrowserSession: vi.fn(), + mockBrowserConnect: vi.fn(), + mockBrowserClose: vi.fn(), + browserState: { page: null as IPage | null }, })); vi.mock('./explore.js', () => ({ @@ -51,6 +59,16 @@ vi.mock('./runtime.js', () => ({ browserSession: mockBrowserSession, })); +vi.mock('./browser/index.js', () => { + mockBrowserConnect.mockImplementation(async () => browserState.page as IPage); + return { + BrowserBridge: class { + connect = mockBrowserConnect; + close = mockBrowserClose; + }, + }; +}); + import { createProgram, findPackageRoot, resolveBrowserVerifyInvocation } from './cli.js'; describe('built-in browser commands verbose wiring', () => { @@ -58,7 +76,9 @@ describe('built-in browser commands verbose wiring', () => { beforeEach(() => { delete process.env.OPENCLI_VERBOSE; + delete process.env.OPENCLI_CACHE_DIR; process.exitCode = undefined; + vi.clearAllMocks(); mockExploreUrl.mockReset().mockResolvedValue({ ok: true }); mockRenderExploreSummary.mockReset().mockReturnValue('explore-summary'); @@ -69,6 +89,7 @@ describe('built-in browser commands verbose wiring', () => { mockCascadeProbe.mockReset().mockResolvedValue({ ok: true }); mockRenderCascadeResult.mockReset().mockReturnValue('cascade-summary'); mockGetBrowserFactory.mockClear(); + mockBrowserClose.mockReset().mockResolvedValue(undefined); mockBrowserSession.mockReset().mockImplementation(async (_factory, fn) => { const page = { goto: vi.fn(), @@ -76,6 +97,10 @@ describe('built-in browser commands verbose wiring', () => { } as unknown as IPage; return fn(page); }); + browserState.page = { + evaluate: vi.fn(), + readNetworkCapture: vi.fn().mockResolvedValue([]), + } as unknown as IPage; }); it('enables OPENCLI_VERBOSE for explore via the real CLI command', async () => { @@ -215,6 +240,164 @@ describe('resolveBrowserVerifyInvocation', () => { }); }); +describe('browser tab targeting commands', () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + function getBrowserStateFile(cacheDir: string): string { + return path.join(cacheDir, 'browser-state', 'browser_default.json'); + } + + beforeEach(() => { + process.exitCode = undefined; + process.env.OPENCLI_CACHE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-tab-state-')); + consoleLogSpy.mockClear(); + stderrSpy.mockClear(); + mockBrowserConnect.mockClear(); + mockBrowserClose.mockReset().mockResolvedValue(undefined); + + browserState.page = { + setActivePage: vi.fn(), + evaluate: vi.fn().mockResolvedValue({ ok: true }), + tabs: vi.fn().mockResolvedValue([ + { index: 0, page: 'tab-1', url: 'https://one.example', title: 'one', active: true }, + { index: 1, page: 'tab-2', url: 'https://two.example', title: 'two', active: false }, + ]), + selectTab: vi.fn().mockResolvedValue(undefined), + newTab: vi.fn().mockResolvedValue('tab-3'), + closeTab: vi.fn().mockResolvedValue(undefined), + readNetworkCapture: vi.fn().mockResolvedValue([]), + } as unknown as IPage; + }); + + it('binds browser commands to an explicit target tab via --tab', async () => { + const program = createProgram('', ''); + + await program.parseAsync(['node', 'opencli', 'browser', 'eval', '--tab', 'tab-2', 'document.title']); + + expect(browserState.page?.setActivePage).toHaveBeenCalledWith('tab-2'); + expect(browserState.page?.evaluate).toHaveBeenCalledWith('document.title'); + }); + + it('rejects an explicit --tab target that is no longer in the current session', async () => { + browserState.page = { + setActivePage: vi.fn(), + getActivePage: vi.fn(), + tabs: vi.fn().mockResolvedValue([]), + evaluate: vi.fn(), + } as unknown as IPage; + + const program = createProgram('', ''); + await program.parseAsync(['node', 'opencli', 'browser', 'eval', '--tab', 'tab-stale', 'document.title']); + + expect(process.exitCode).toBeDefined(); + expect(browserState.page?.setActivePage).not.toHaveBeenCalled(); + expect(browserState.page?.evaluate).not.toHaveBeenCalled(); + expect(stderrSpy.mock.calls.flat().join('\n')).toContain('Target tab tab-stale is not part of the current browser session'); + }); + + it('lists tabs with target IDs via browser tab list', async () => { + const program = createProgram('', ''); + + await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'list']); + + expect(browserState.page?.tabs).toHaveBeenCalledTimes(1); + expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"page": "tab-1"'); + expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"page": "tab-2"'); + }); + + it('creates a new tab and prints its target ID', async () => { + const program = createProgram('', ''); + + await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'new', 'https://three.example']); + + expect(browserState.page?.newTab).toHaveBeenCalledWith('https://three.example'); + expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"page": "tab-3"'); + }); + + it('does not promote a newly created tab to the persisted default target', async () => { + const program = createProgram('', ''); + + await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'new', 'https://three.example']); + await program.parseAsync(['node', 'opencli', 'browser', 'eval', 'document.title']); + + expect(browserState.page?.newTab).toHaveBeenCalledWith('https://three.example'); + expect(browserState.page?.setActivePage).not.toHaveBeenCalled(); + expect(browserState.page?.evaluate).toHaveBeenCalledWith('document.title'); + }); + + it('persists an explicitly selected tab as the default target for later untargeted commands', async () => { + const program = createProgram('', ''); + + await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'select', 'tab-2']); + await program.parseAsync(['node', 'opencli', 'browser', 'eval', 'document.title']); + + expect(browserState.page?.selectTab).toHaveBeenCalledWith('tab-2'); + expect(browserState.page?.setActivePage).toHaveBeenCalledWith('tab-2'); + expect(browserState.page?.evaluate).toHaveBeenCalledWith('document.title'); + expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"selected": "tab-2"'); + }); + + it('clears a saved default target when it is no longer present in the current session', async () => { + const cacheDir = String(process.env.OPENCLI_CACHE_DIR); + const program = createProgram('', ''); + + await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'select', 'tab-2']); + expect(fs.existsSync(getBrowserStateFile(cacheDir))).toBe(true); + + browserState.page = { + setActivePage: vi.fn(), + getActivePage: vi.fn(), + tabs: vi.fn().mockResolvedValue([]), + evaluate: vi.fn().mockResolvedValue({ ok: true }), + readNetworkCapture: vi.fn().mockResolvedValue([]), + } as unknown as IPage; + + await program.parseAsync(['node', 'opencli', 'browser', 'eval', 'document.title']); + + expect(browserState.page?.setActivePage).not.toHaveBeenCalled(); + expect(browserState.page?.evaluate).toHaveBeenCalledWith('document.title'); + expect(fs.existsSync(getBrowserStateFile(cacheDir))).toBe(false); + }); + + it('clears the persisted default target when that tab is closed', async () => { + const program = createProgram('', ''); + + await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'select', 'tab-2']); + await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'close', 'tab-2']); + vi.mocked(browserState.page?.setActivePage as any).mockClear(); + vi.mocked(browserState.page?.evaluate as any).mockClear(); + + await program.parseAsync(['node', 'opencli', 'browser', 'eval', 'document.title']); + + expect(browserState.page?.closeTab).toHaveBeenCalledWith('tab-2'); + expect(browserState.page?.setActivePage).not.toHaveBeenCalled(); + expect(browserState.page?.evaluate).toHaveBeenCalledWith('document.title'); + }); + + it('closes a tab by target ID', async () => { + const program = createProgram('', ''); + + await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'close', 'tab-2']); + + expect(browserState.page?.closeTab).toHaveBeenCalledWith('tab-2'); + expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"closed": "tab-2"'); + }); + + it('rejects closing a stale tab target ID that is no longer in the current session', async () => { + browserState.page = { + tabs: vi.fn().mockResolvedValue([]), + closeTab: vi.fn(), + } as unknown as IPage; + + const program = createProgram('', ''); + await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'close', 'tab-stale']); + + expect(process.exitCode).toBeDefined(); + expect(browserState.page?.closeTab).not.toHaveBeenCalled(); + expect(stderrSpy.mock.calls.flat().join('\n')).toContain('Target tab tab-stale is not part of the current browser session'); + }); +}); describe('findPackageRoot', () => { it('walks up from dist/src to the package root', () => { const packageRoot = path.join('repo-root'); diff --git a/src/cli.ts b/src/cli.ts index 4e8c5043b..7c8396227 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,6 +6,7 @@ */ import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { Command } from 'commander'; @@ -26,18 +27,147 @@ import { daemonStatus, daemonStop } from './commands/daemon.js'; import { log } from './logger.js'; const CLI_FILE = fileURLToPath(import.meta.url); +const DEFAULT_BROWSER_WORKSPACE = 'browser:default'; +const BROWSER_TAB_OPTION_DESCRIPTION = 'Target tab/page identity from "browser tab list"'; + +type BrowserNetworkItem = { + url: string; + method: string; + status: number; + size: number; + ct: string; + body: unknown; +}; + +type BrowserTargetState = { + defaultPage?: string; + updatedAt: string; +}; + +type BrowserTabSummary = { + page?: string; +}; + +function getBrowserCacheDir(): string { + return process.env.OPENCLI_CACHE_DIR || path.join(os.homedir(), '.opencli', 'cache'); +} + +function getBrowserTargetStatePath(scope: string = DEFAULT_BROWSER_WORKSPACE): string { + const safeWorkspace = scope.replace(/[^a-zA-Z0-9_-]+/g, '_'); + return path.join(getBrowserCacheDir(), 'browser-state', `${safeWorkspace}.json`); +} + +function loadBrowserTargetState(scope: string = DEFAULT_BROWSER_WORKSPACE): BrowserTargetState | null { + try { + const raw = fs.readFileSync(getBrowserTargetStatePath(scope), 'utf-8'); + const parsed = JSON.parse(raw) as BrowserTargetState | null; + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } +} + +function saveBrowserTargetState(defaultPage?: string, scope: string = DEFAULT_BROWSER_WORKSPACE): void { + const target = getBrowserTargetStatePath(scope); + if (!defaultPage) { + fs.rmSync(target, { force: true }); + return; + } + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, JSON.stringify({ defaultPage, updatedAt: new Date().toISOString() }), 'utf-8'); +} + +function hasBrowserTabTarget(tabs: unknown[], targetPage: string): boolean { + return tabs.some((tab) => { + return typeof tab === 'object' + && tab !== null + && 'page' in tab + && typeof (tab as BrowserTabSummary).page === 'string' + && (tab as BrowserTabSummary).page === targetPage; + }); +} + +async function resolveBrowserTargetInSession( + page: import('./types.js').IPage, + targetPage: string, + opts: { scope?: string; source: 'explicit' | 'saved' }, +): Promise { + const candidate = targetPage.trim(); + if (!candidate) return undefined; + + let tabs: unknown[]; + try { + tabs = await page.tabs(); + } catch (err) { + if (opts.source === 'saved') { + saveBrowserTargetState(undefined, opts.scope); + return undefined; + } + throw new Error( + `Target tab ${candidate} could not be validated in the current browser session. ` + + 'The Browser Bridge workspace may have restarted; re-run "opencli browser tab list" and choose a current target.', + { cause: err }, + ); + } + + if (Array.isArray(tabs) && hasBrowserTabTarget(tabs, candidate)) { + return candidate; + } + + if (opts.source === 'saved') { + saveBrowserTargetState(undefined, opts.scope); + return undefined; + } + + throw new Error( + `Target tab ${candidate} is not part of the current browser session. ` + + 'The Browser Bridge workspace may have restarted; re-run "opencli browser tab list" and choose a current target.', + ); +} + +async function resolveStoredBrowserTarget(page: import('./types.js').IPage, scope: string = DEFAULT_BROWSER_WORKSPACE): Promise { + const defaultPage = loadBrowserTargetState(scope)?.defaultPage?.trim(); + if (!defaultPage) return undefined; + return resolveBrowserTargetInSession(page, defaultPage, { scope, source: 'saved' }); +} /** Create a browser page for browser commands. Uses a dedicated browser workspace for session persistence. */ -async function getBrowserPage(): Promise { +async function getBrowserPage(targetPage?: string): Promise { const { BrowserBridge } = await import('./browser/index.js'); const bridge = new BrowserBridge(); const envTimeout = process.env.OPENCLI_BROWSER_TIMEOUT; const idleTimeout = envTimeout ? parseInt(envTimeout, 10) : undefined; - return bridge.connect({ + const page = await bridge.connect({ timeout: 30, - workspace: 'browser:default', + workspace: DEFAULT_BROWSER_WORKSPACE, ...(idleTimeout && idleTimeout > 0 && { idleTimeout }), }); + const resolvedTargetPage = targetPage + ? await resolveBrowserTargetInSession(page, targetPage, { scope: DEFAULT_BROWSER_WORKSPACE, source: 'explicit' }) + : await resolveStoredBrowserTarget(page, DEFAULT_BROWSER_WORKSPACE); + if (resolvedTargetPage) { + if (!page.setActivePage) { + throw new Error('This browser session does not support explicit tab targeting'); + } + page.setActivePage(resolvedTargetPage); + } + return page; +} + +function addBrowserTabOption(command: Command): Command { + return command.option('--tab ', BROWSER_TAB_OPTION_DESCRIPTION); +} + +function getBrowserTargetId(command?: Command): string | undefined { + if (!command) return undefined; + const opts = command.optsWithGlobals ? command.optsWithGlobals() : command.opts(); + return typeof opts.tab === 'string' && opts.tab.trim() ? opts.tab.trim() : undefined; +} + +function resolveBrowserTabTarget(targetId?: string, opts?: { tab?: string }): string | undefined { + if (typeof targetId === 'string' && targetId.trim()) return targetId.trim(); + if (typeof opts?.tab === 'string' && opts.tab.trim()) return opts.tab.trim(); + return undefined; } function parsePositiveIntOption(val: string | undefined, label: string, fallback: number): number { @@ -324,7 +454,9 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command function browserAction(fn: (page: Awaited>, ...args: any[]) => Promise) { return async (...args: any[]) => { try { - const page = await getBrowserPage(); + const command = args.at(-1) instanceof Command ? args.at(-1) as Command : undefined; + const targetPage = getBrowserTargetId(command); + const page = await getBrowserPage(targetPage); await fn(page, ...args); } catch (err) { if (err instanceof BrowserConnectError) { @@ -350,12 +482,75 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command }; } + const browserTab = browser + .command('tab') + .description('Tab management — list, create, and close tabs in the automation window'); + + browserTab.command('list') + .description('List tabs in the automation window with target IDs') + .action(browserAction(async (page) => { + const tabs = await page.tabs(); + console.log(JSON.stringify(tabs, null, 2)); + })); + + browserTab.command('new') + .argument('[url]', 'Optional URL to open in the new tab') + .description('Create a new tab and print its target ID') + .action(browserAction(async (page, url?: string) => { + if (!page.newTab) { + throw new Error('This browser session does not support creating tabs'); + } + const createdPage = await page.newTab(url); + console.log(JSON.stringify({ + page: createdPage, + url: url ?? null, + }, null, 2)); + })); + + addBrowserTabOption(browserTab.command('select') + .argument('[targetId]', 'Target tab/page identity from "browser tab list"') + .description('Select a tab by target ID and make it the default browser tab')) + .action(browserAction(async (page, targetId?: string, opts?: { tab?: string }) => { + const resolvedTarget = resolveBrowserTabTarget(targetId, opts); + if (!resolvedTarget) { + throw new Error('Target tab required. Pass it as an argument or --tab .'); + } + await page.selectTab(resolvedTarget); + saveBrowserTargetState(resolvedTarget, DEFAULT_BROWSER_WORKSPACE); + console.log(JSON.stringify({ selected: resolvedTarget }, null, 2)); + })); + + addBrowserTabOption(browserTab.command('close') + .argument('[targetId]', 'Target tab/page identity from "browser tab list"') + .description('Close a tab by target ID')) + .action(browserAction(async (page, targetId?: string, opts?: { tab?: string }) => { + const resolvedTarget = resolveBrowserTabTarget(targetId, opts); + if (!page.closeTab) { + throw new Error('This browser session does not support closing tabs'); + } + if (!resolvedTarget) { + throw new Error('Target tab required. Pass it as an argument or --tab .'); + } + const validatedTarget = await resolveBrowserTargetInSession(page, resolvedTarget, { + scope: DEFAULT_BROWSER_WORKSPACE, + source: 'explicit', + }); + if (!validatedTarget) { + throw new Error(`Target tab ${resolvedTarget} is not part of the current browser session.`); + } + await page.closeTab(validatedTarget); + if (loadBrowserTargetState(DEFAULT_BROWSER_WORKSPACE)?.defaultPage === validatedTarget) { + saveBrowserTargetState(undefined, DEFAULT_BROWSER_WORKSPACE); + } + console.log(JSON.stringify({ closed: validatedTarget }, null, 2)); + })); + // ── Navigation ── /** Network interceptor JS — injected on every open/navigate to capture fetch/XHR */ const NETWORK_INTERCEPTOR_JS = `(function(){if(window.__opencli_net)return;window.__opencli_net=[];var M=200,B=50000,F=window.fetch;window.fetch=async function(){var r=await F.apply(this,arguments);try{var ct=r.headers.get('content-type')||'';if(ct.includes('json')||ct.includes('text')){var c=r.clone(),t=await c.text();if(window.__opencli_net.length').description('Open URL in automation window') + addBrowserTabOption(browser.command('open').argument('').description('Open URL in automation window')) .action(browserAction(async (page, url) => { // Start session-level capture before navigation (catches initial requests) const hasSessionCapture = await page.startNetworkCapture?.() ?? false; @@ -368,14 +563,14 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command console.log(`Navigated to: ${await page.getCurrentUrl?.() ?? url}`); })); - browser.command('back').description('Go back in browser history') + addBrowserTabOption(browser.command('back').description('Go back in browser history')) .action(browserAction(async (page) => { await page.evaluate('history.back()'); await page.wait(2); console.log('Navigated back'); })); - browser.command('scroll').argument('', 'up or down').option('--amount ', 'Pixels to scroll', '500') + addBrowserTabOption(browser.command('scroll').argument('', 'up or down').option('--amount ', 'Pixels to scroll', '500')) .description('Scroll page') .action(browserAction(async (page, direction, opts) => { if (direction !== 'up' && direction !== 'down') { @@ -389,7 +584,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command // ── Inspect ── - browser.command('state').description('Page state: URL, title, interactive elements with [N] indices') + addBrowserTabOption(browser.command('state').description('Page state: URL, title, interactive elements with [N] indices')) .action(browserAction(async (page) => { const snapshot = await page.snapshot({ viewportExpand: 2000 }); const url = await page.getCurrentUrl?.() ?? ''; @@ -397,7 +592,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command console.log(typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2)); })); - browser.command('screenshot').argument('[path]', 'Save to file (base64 if omitted)') + addBrowserTabOption(browser.command('screenshot').argument('[path]', 'Save to file (base64 if omitted)')) .description('Take screenshot') .action(browserAction(async (page, path) => { if (path) { @@ -412,38 +607,38 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command const get = browser.command('get').description('Get page properties'); - get.command('title').description('Page title') + addBrowserTabOption(get.command('title').description('Page title')) .action(browserAction(async (page) => { console.log(await page.evaluate('document.title')); })); - get.command('url').description('Current page URL') + addBrowserTabOption(get.command('url').description('Current page URL')) .action(browserAction(async (page) => { console.log(await page.getCurrentUrl?.() ?? await page.evaluate('location.href')); })); - get.command('text').argument('', 'Element index').description('Element text content') + addBrowserTabOption(get.command('text').argument('', 'Element index').description('Element text content')) .action(browserAction(async (page, index) => { await resolveRef(page, String(index)); const text = await page.evaluate(getTextResolvedJs()); console.log(text ?? '(empty)'); })); - get.command('value').argument('', 'Element index').description('Input/textarea value') + addBrowserTabOption(get.command('value').argument('', 'Element index').description('Input/textarea value')) .action(browserAction(async (page, index) => { await resolveRef(page, String(index)); const val = await page.evaluate(getValueResolvedJs()); console.log(val ?? '(empty)'); })); - get.command('html').option('--selector ', 'CSS selector scope').description('Page HTML (or scoped)') + addBrowserTabOption(get.command('html').option('--selector ', 'CSS selector scope').description('Page HTML (or scoped)')) .action(browserAction(async (page, opts) => { const sel = opts.selector ? JSON.stringify(opts.selector) : 'null'; const html = await page.evaluate(`(${sel} ? document.querySelector(${sel})?.outerHTML : document.documentElement.outerHTML)?.slice(0, 50000)`); console.log(html ?? '(empty)'); })); - get.command('attributes').argument('', 'Element index').description('Element attributes') + addBrowserTabOption(get.command('attributes').argument('', 'Element index').description('Element attributes')) .action(browserAction(async (page, index) => { await resolveRef(page, String(index)); const attrs = await page.evaluate(getAttributesResolvedJs()); @@ -452,13 +647,13 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command // ── Interact ── - browser.command('click').argument('', 'Element index from state').description('Click element by index') + addBrowserTabOption(browser.command('click').argument('', 'Element index from state').description('Click element by index')) .action(browserAction(async (page, index) => { await page.click(index); console.log(`Clicked element [${index}]`); })); - browser.command('type').argument('', 'Element index').argument('', 'Text to type') + addBrowserTabOption(browser.command('type').argument('', 'Element index').argument('', 'Text to type')) .description('Click element, then type text') .action(browserAction(async (page, index, text) => { await page.click(index); @@ -475,7 +670,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command } })); - browser.command('select').argument('', 'Element index of ').argument('