diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 00000000..ff775429 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,57 @@ +# Privacy Policy — OpenCLI Browser Extension + +**Last updated**: 2026-03-25 + +## What the extension does + +The OpenCLI Browser Extension is a bridge between the [OpenCLI](https://github.com/jackwener/opencli) command-line tool and your Chrome browser. It receives commands from a **locally running daemon** process via WebSocket (`localhost` only) and executes them in **isolated Chrome windows** that are separate from your normal browsing session. + +## Data collection + +The extension does **NOT** collect, store, transmit, or sell any personal data. Specifically: + +- **No analytics or telemetry** — no data is sent to any remote server. +- **No user tracking** — no cookies, identifiers, or fingerprints are created. +- **No external network requests** — all communication is strictly `localhost` (WebSocket to `ws://localhost:19825`). + +## Permissions explained + +| Permission | Why it's needed | +|------------|----------------| +| `debugger` | Required to use Chrome DevTools Protocol (CDP) for browser automation — executing JavaScript, capturing page content, and taking screenshots in isolated windows. | +| `tabs` | Required to create and manage isolated automation windows and tabs, separate from the user's browsing session. | +| `cookies` | Required to read site-specific cookies (scoped by domain) so CLI commands can authenticate with websites the user is already logged into. Cookies are **never written, modified, or transmitted externally**. | +| `activeTab` | Required to identify the currently active tab for context-aware commands. | +| `alarms` | Required to maintain the WebSocket connection to the local daemon via periodic keepalive checks. | + +## Data flow + +``` +User's terminal (opencli CLI) + ↓ (spawns) +Local daemon process (localhost:19825) + ↓ (WebSocket, localhost only) +Chrome Extension (this extension) + ↓ (Chrome APIs) +Isolated Chrome automation window +``` + +All data stays on the user's machine. No data leaves `localhost`. + +## Cookie access + +The extension reads cookies **only** when explicitly requested by a CLI command, and **only** for the specific domain the command targets. It cannot and does not dump all cookies. Cookie data is returned to the local daemon process and is never sent to any external server. + +## Third-party services + +This extension does not integrate with, send data to, or receive data from any third-party service. + +## Open source + +This extension is fully open source. You can audit the complete source code at: +https://github.com/jackwener/opencli/tree/main/extension + +## Contact + +For privacy questions or concerns, please open an issue at: +https://github.com/jackwener/opencli/issues diff --git a/extension/dist/background.js b/extension/dist/background.js index fe310f3d..463cf1aa 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -1,582 +1,570 @@ -//#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`; -`${DAEMON_HOST}${DAEMON_PORT}`; -/** Base reconnect delay for extension WebSocket (ms) */ -var WS_RECONNECT_BASE_DELAY = 2e3; -/** Max reconnect delay (ms) */ -var WS_RECONNECT_MAX_DELAY = 6e4; -//#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(); -/** Check if a URL can be attached via CDP */ +const DAEMON_PORT = 19825; +const DAEMON_HOST = "localhost"; +const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; +const WS_RECONNECT_BASE_DELAY = 2e3; +const WS_RECONNECT_MAX_DELAY = 6e4; + +const attached = /* @__PURE__ */ new Set(); +const BLANK_PAGE$1 = "data:text/html,"; function isDebuggableUrl$1(url) { - if (!url) return true; - return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://"); + if (!url) return true; + return url.startsWith("http://") || url.startsWith("https://") || url === BLANK_PAGE$1; } async function ensureAttached(tabId) { - 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); - } - try { - await chrome.debugger.attach({ tabId }, "1.3"); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - const hint = msg.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : ""; - if (msg.includes("Another debugger is already attached")) { - try { - await chrome.debugger.detach({ tabId }); - } catch {} - try { - await chrome.debugger.attach({ tabId }, "1.3"); - } catch { - throw new Error(`attach failed: ${msg}${hint}`); - } - } else throw new Error(`attach failed: ${msg}${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); + } + } + try { + await chrome.debugger.attach({ tabId }, "1.3"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const hint = msg.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : ""; + if (msg.includes("Another debugger is already attached")) { + try { + await chrome.debugger.detach({ tabId }); + } catch { + } + try { + await chrome.debugger.attach({ tabId }, "1.3"); + } catch { + throw new Error(`attach failed: ${msg}${hint}`); + } + } else { + throw new Error(`attach failed: ${msg}${hint}`); + } + } + attached.add(tabId); + try { + await chrome.debugger.sendCommand({ tabId }, "Runtime.enable"); + } catch { + } } async function evaluate(tabId, expression) { - await ensureAttached(tabId); - 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; + await ensureAttached(tabId); + 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; } -var evaluateAsync = evaluate; -/** -* Capture a screenshot via CDP Page.captureScreenshot. -* Returns base64-encoded image data. -*/ +const evaluateAsync = evaluate; async function screenshot(tabId, options = {}) { - await ensureAttached(tabId); - const format = options.format ?? "png"; - if (options.fullPage) { - const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics"); - const size = metrics.cssContentSize || metrics.contentSize; - if (size) await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { - mobile: false, - width: Math.ceil(size.width), - height: Math.ceil(size.height), - deviceScaleFactor: 1 - }); - } - try { - const params = { format }; - if (format === "jpeg" && options.quality !== void 0) params.quality = Math.max(0, Math.min(100, options.quality)); - return (await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params)).data; - } finally { - if (options.fullPage) await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => {}); - } + await ensureAttached(tabId); + const format = options.format ?? "png"; + if (options.fullPage) { + const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics"); + const size = metrics.cssContentSize || metrics.contentSize; + if (size) { + await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { + mobile: false, + width: Math.ceil(size.width), + height: Math.ceil(size.height), + deviceScaleFactor: 1 + }); + } + } + try { + const params = { format }; + if (format === "jpeg" && options.quality !== void 0) { + params.quality = Math.max(0, Math.min(100, options.quality)); + } + const result = await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params); + return result.data; + } finally { + if (options.fullPage) { + await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => { + }); + } + } } async function detach(tabId) { - if (!attached.has(tabId)) return; - attached.delete(tabId); - try { - await chrome.debugger.detach({ tabId }); - } catch {} + if (!attached.has(tabId)) return; + attached.delete(tabId); + try { + await chrome.debugger.detach({ tabId }); + } catch { + } } function registerListeners() { - chrome.tabs.onRemoved.addListener((tabId) => { - attached.delete(tabId); - }); - chrome.debugger.onDetach.addListener((source) => { - if (source.tabId) attached.delete(source.tabId); - }); - chrome.tabs.onUpdated.addListener(async (tabId, info) => { - if (info.url && !isDebuggableUrl$1(info.url)) await detach(tabId); - }); + chrome.tabs.onRemoved.addListener((tabId) => { + attached.delete(tabId); + }); + chrome.debugger.onDetach.addListener((source) => { + if (source.tabId) attached.delete(source.tabId); + }); + chrome.tabs.onUpdated.addListener(async (tabId, info) => { + if (info.url && !isDebuggableUrl$1(info.url)) { + await detach(tabId); + } + }); } -//#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); + +let ws = null; +let reconnectTimer = null; +let reconnectAttempts = 0; +const _origLog = console.log.bind(console); +const _origWarn = console.warn.bind(console); +const _origError = console.error.bind(console); function forwardLog(level, args) { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - try { - const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); - ws.send(JSON.stringify({ - type: "log", - level, - msg, - ts: Date.now() - })); - } catch {} + if (!ws || ws.readyState !== WebSocket.OPEN) return; + try { + const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); + ws.send(JSON.stringify({ type: "log", level, msg, ts: Date.now() })); + } catch { + } } console.log = (...args) => { - _origLog(...args); - forwardLog("info", args); + _origLog(...args); + forwardLog("info", args); }; console.warn = (...args) => { - _origWarn(...args); - forwardLog("warn", args); + _origWarn(...args); + forwardLog("warn", args); }; console.error = (...args) => { - _origError(...args); - forwardLog("error", args); + _origError(...args); + forwardLog("error", args); }; function connect() { - if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) 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.onmessage = async (event) => { - try { - const result = await handleCommand(JSON.parse(event.data)); - ws?.send(JSON.stringify(result)); - } catch (err) { - console.error("[opencli] Message handling error:", err); - } - }; - ws.onclose = () => { - console.log("[opencli] Disconnected from daemon"); - ws = null; - scheduleReconnect(); - }; - ws.onerror = () => { - ws?.close(); - }; + if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + try { + 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.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(); + }; } function scheduleReconnect() { - if (reconnectTimer) return; - reconnectAttempts++; - const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - connect(); - }, delay); + if (reconnectTimer) return; + reconnectAttempts++; + const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, delay); } -var automationSessions = /* @__PURE__ */ new Map(); -var WINDOW_IDLE_TIMEOUT = 12e4; +const automationSessions = /* @__PURE__ */ new Map(); +const WINDOW_IDLE_TIMEOUT = 12e4; 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); - session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT; - session.idleTimer = setTimeout(async () => { - const current = automationSessions.get(workspace); - if (!current) return; - try { - await chrome.windows.remove(current.windowId); - console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`); - } catch {} - automationSessions.delete(workspace); - }, WINDOW_IDLE_TIMEOUT); + const session = automationSessions.get(workspace); + if (!session) return; + if (session.idleTimer) clearTimeout(session.idleTimer); + session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT; + session.idleTimer = setTimeout(async () => { + const current = automationSessions.get(workspace); + if (!current) return; + try { + await chrome.windows.remove(current.windowId); + console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`); + } catch { + } + automationSessions.delete(workspace); + }, WINDOW_IDLE_TIMEOUT); } -/** Get or create the dedicated automation window. */ async function getAutomationWindow(workspace) { - const existing = automationSessions.get(workspace); - if (existing) try { - await chrome.windows.get(existing.windowId); - return existing.windowId; - } catch { - automationSessions.delete(workspace); - } - const session = { - windowId: (await chrome.windows.create({ - url: "data:text/html,", - focused: false, - width: 1280, - height: 900, - type: "normal" - })).id, - idleTimer: null, - idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT - }; - automationSessions.set(workspace, session); - console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`); - resetWindowIdleTimer(workspace); - await new Promise((resolve) => setTimeout(resolve, 200)); - 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 win = await chrome.windows.create({ + url: BLANK_PAGE, + focused: false, + width: 1280, + height: 900, + type: "normal" + }); + const session = { + windowId: win.id, + idleTimer: null, + idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT + }; + automationSessions.set(workspace, session); + console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`); + resetWindowIdleTimer(workspace); + await new Promise((resolve) => setTimeout(resolve, 200)); + return session.windowId; } chrome.windows.onRemoved.addListener((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); - } + 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); + } + } }); -var initialized = false; +let initialized = false; function initialize() { - if (initialized) return; - initialized = true; - chrome.alarms.create("keepalive", { periodInMinutes: .4 }); - registerListeners(); - connect(); - console.log("[opencli] OpenCLI extension initialized"); + if (initialized) return; + initialized = true; + chrome.alarms.create("keepalive", { periodInMinutes: 0.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") 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; }); async function handleCommand(cmd) { - const workspace = getWorkspaceKey(cmd.workspace); - 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 "sessions": return await handleSessions(cmd); - 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); + 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 "sessions": + return await handleSessions(cmd); + 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) + }; + } } -/** Check if a URL can be attached via CDP (not chrome:// or chrome-extension://) */ +const BLANK_PAGE = "data:text/html,"; function isDebuggableUrl(url) { - if (!url) return true; - return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://"); + if (!url) return true; + return url.startsWith("http://") || url.startsWith("https://") || url === BLANK_PAGE; +} +function isSafeNavigationUrl(url) { + return url.startsWith("http://") || url.startsWith("https://"); +} +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; + } +} +function isTargetUrl(currentUrl, targetUrl) { + return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); } -/** -* Resolve target tab in the automation window. -* If explicit tabId is given, use that directly. -* Otherwise, find or create a tab in the dedicated automation window. -*/ async function resolveTabId(tabId, workspace) { - if (tabId !== void 0) try { - const tab = await chrome.tabs.get(tabId); - if (isDebuggableUrl(tab.url)) return tabId; - 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 windowId = await getAutomationWindow(workspace); - const tabs = await chrome.tabs.query({ windowId }); - const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url)); - if (debuggableTab?.id) return debuggableTab.id; - const reuseTab = tabs.find((t) => t.id); - if (reuseTab?.id) { - await chrome.tabs.update(reuseTab.id, { url: "data:text/html," }); - await new Promise((resolve) => setTimeout(resolve, 300)); - try { - const updated = await chrome.tabs.get(reuseTab.id); - if (isDebuggableUrl(updated.url)) return reuseTab.id; - console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`); - } catch {} - } - const newTab = await chrome.tabs.create({ - windowId, - url: "data:text/html,", - active: true - }); - if (!newTab.id) throw new Error("Failed to create tab in automation window"); - return newTab.id; + if (tabId !== void 0) { + try { + const tab = await chrome.tabs.get(tabId); + const session = automationSessions.get(workspace); + if (isDebuggableUrl(tab.url) && session && tab.windowId === session.windowId) return tabId; + if (session && tab.windowId !== session.windowId) { + console.warn(`[opencli] Tab ${tabId} belongs to window ${tab.windowId}, not automation window ${session.windowId}, re-resolving`); + } 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 windowId = await getAutomationWindow(workspace); + const tabs = await chrome.tabs.query({ windowId }); + const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url)); + if (debuggableTab?.id) return debuggableTab.id; + 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 reuseTab.id; + 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 newTab.id; } async function listAutomationTabs(workspace) { - const session = automationSessions.get(workspace); - if (!session) return []; - try { - return await chrome.tabs.query({ windowId: session.windowId }); - } catch { - automationSessions.delete(workspace); - return []; - } + const session = automationSessions.get(workspace); + if (!session) return []; + try { + return await chrome.tabs.query({ windowId: session.windowId }); + } catch { + automationSessions.delete(workspace); + return []; + } } async function listAutomationWebTabs(workspace) { - return (await listAutomationTabs(workspace)).filter((tab) => isDebuggableUrl(tab.url)); + const tabs = await listAutomationTabs(workspace); + return tabs.filter((tab) => isDebuggableUrl(tab.url)); } async function handleExec(cmd, workspace) { - if (!cmd.code) return { - id: cmd.id, - ok: false, - error: "Missing code" - }; - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - const data = await evaluateAsync(tabId, cmd.code); - return { - id: cmd.id, - ok: true, - 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(cmd.tabId, workspace); + try { + const data = await evaluateAsync(tabId, cmd.code); + return { id: cmd.id, ok: true, 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" - }; - const tabId = await resolveTabId(cmd.tabId, workspace); - const beforeUrl = (await chrome.tabs.get(tabId)).url ?? ""; - const targetUrl = cmd.url; - await detach(tabId); - await chrome.tabs.update(tabId, { url: targetUrl }); - let timedOut = false; - await new Promise((resolve) => { - let urlChanged = false; - const listener = (id, info, tab) => { - if (id !== tabId) return; - if (info.url && info.url !== beforeUrl) urlChanged = true; - if (urlChanged && info.status === "complete") { - chrome.tabs.onUpdated.removeListener(listener); - resolve(); - } - }; - chrome.tabs.onUpdated.addListener(listener); - setTimeout(async () => { - try { - const currentTab = await chrome.tabs.get(tabId); - if (currentTab.url !== beforeUrl && currentTab.status === "complete") { - chrome.tabs.onUpdated.removeListener(listener); - resolve(); - } - } catch {} - }, 100); - setTimeout(() => { - chrome.tabs.onUpdated.removeListener(listener); - timedOut = true; - console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); - resolve(); - }, 15e3); - }); - const tab = await chrome.tabs.get(tabId); - return { - id: cmd.id, - ok: true, - data: { - title: tab.title, - url: tab.url, - tabId, - 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 tabId = await resolveTabId(cmd.tabId, workspace); + const beforeTab = await chrome.tabs.get(tabId); + const beforeNormalized = normalizeUrlForComparison(beforeTab.url); + const targetUrl = cmd.url; + if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) { + return { + id: cmd.id, + ok: true, + data: { title: beforeTab.title, url: beforeTab.url, tabId, timedOut: false } + }; + } + 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); + }); + const tab = await chrome.tabs.get(tabId); + return { + id: cmd.id, + ok: true, + data: { title: tab.title, url: tab.url, tabId, timedOut } + }; } async function handleTabs(cmd, workspace) { - switch (cmd.op) { - case "list": { - const data = (await listAutomationWebTabs(workspace)).map((t, i) => ({ - index: i, - tabId: t.id, - url: t.url, - title: t.title, - active: t.active - })); - return { - id: cmd.id, - ok: true, - data - }; - } - case "new": { - const windowId = await getAutomationWindow(workspace); - const tab = await chrome.tabs.create({ - windowId, - url: cmd.url ?? "data:text/html,", - active: true - }); - return { - id: cmd.id, - ok: true, - data: { - tabId: 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` - }; - await chrome.tabs.remove(target.id); - await detach(target.id); - return { - id: cmd.id, - ok: true, - data: { closed: target.id } - }; - } - const tabId = await resolveTabId(cmd.tabId, workspace); - await chrome.tabs.remove(tabId); - await detach(tabId); - return { - id: cmd.id, - ok: true, - data: { closed: tabId } - }; - } - case "select": { - if (cmd.index === void 0 && cmd.tabId === void 0) return { - id: cmd.id, - ok: false, - error: "Missing index or tabId" - }; - if (cmd.tabId !== void 0) { - await chrome.tabs.update(cmd.tabId, { active: true }); - return { - id: cmd.id, - ok: true, - data: { selected: cmd.tabId } - }; - } - 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 { - id: cmd.id, - ok: true, - data: { selected: target.id } - }; - } - 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 = tabs.map((t, i) => ({ + index: i, + tabId: t.id, + 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 }); + return { id: cmd.id, ok: true, data: { tabId: 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` }; + await chrome.tabs.remove(target.id); + await detach(target.id); + return { id: cmd.id, ok: true, data: { closed: target.id } }; + } + const tabId = await resolveTabId(cmd.tabId, workspace); + await chrome.tabs.remove(tabId); + await detach(tabId); + return { id: cmd.id, ok: true, data: { closed: tabId } }; + } + case "select": { + if (cmd.index === void 0 && cmd.tabId === void 0) + return { id: cmd.id, ok: false, error: "Missing index or tabId" }; + if (cmd.tabId !== void 0) { + const session = automationSessions.get(workspace); + let tab; + try { + tab = await chrome.tabs.get(cmd.tabId); + } catch { + return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} no longer exists` }; + } + if (!session || tab.windowId !== session.windowId) { + return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} is not in the automation window` }; + } + await chrome.tabs.update(cmd.tabId, { active: true }); + return { id: cmd.id, ok: true, data: { selected: cmd.tabId } }; + } + 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 { id: cmd.id, ok: true, data: { selected: target.id } }; + } + default: + return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` }; + } } async function handleCookies(cmd) { - const details = {}; - if (cmd.domain) details.domain = cmd.domain; - if (cmd.url) details.url = cmd.url; - const data = (await chrome.cookies.getAll(details)).map((c) => ({ - name: c.name, - value: c.value, - domain: c.domain, - path: c.path, - secure: c.secure, - httpOnly: c.httpOnly, - expirationDate: c.expirationDate - })); - return { - id: cmd.id, - ok: true, - data - }; + if (!cmd.domain && !cmd.url) { + return { id: cmd.id, ok: false, error: "Cookie scope required: provide domain or url to avoid dumping all cookies" }; + } + const details = {}; + if (cmd.domain) details.domain = cmd.domain; + if (cmd.url) details.url = cmd.url; + const cookies = await chrome.cookies.getAll(details); + const data = cookies.map((c) => ({ + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + secure: c.secure, + httpOnly: c.httpOnly, + expirationDate: c.expirationDate + })); + return { id: cmd.id, ok: true, data }; } async function handleScreenshot(cmd, workspace) { - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - const data = await screenshot(tabId, { - format: cmd.format, - quality: cmd.quality, - fullPage: cmd.fullPage - }); - return { - id: cmd.id, - ok: true, - data - }; - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + const data = await screenshot(tabId, { + format: cmd.format, + quality: cmd.quality, + fullPage: cmd.fullPage + }); + return { id: cmd.id, ok: true, 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) { - try { - await chrome.windows.remove(session.windowId); - } catch {} - if (session.idleTimer) clearTimeout(session.idleTimer); - automationSessions.delete(workspace); - } - return { - id: cmd.id, - ok: true, - data: { closed: true } - }; + const session = automationSessions.get(workspace); + if (session) { + try { + await chrome.windows.remove(session.windowId); + } catch { + } + if (session.idleTimer) clearTimeout(session.idleTimer); + automationSessions.delete(workspace); + } + return { id: cmd.id, ok: true, data: { closed: true } }; } 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 }; } -//#endregion diff --git a/extension/manifest.json b/extension/manifest.json index 57f8607b..e7e3ae05 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "OpenCLI", "version": "1.4.0", - "description": "Bridge between opencli CLI and your browser — execute commands, read cookies, manage tabs.", + "description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in isolated Chrome windows via a local daemon.", "permissions": [ "debugger", "tabs", @@ -22,10 +22,14 @@ }, "action": { "default_title": "OpenCLI", + "default_popup": "popup.html", "default_icon": { "16": "icons/icon-16.png", "32": "icons/icon-32.png" } }, + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'" + }, "homepage_url": "https://github.com/jackwener/opencli" } diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 00000000..abb2e9d5 --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,65 @@ + + + + + + + +
+ OpenCLI +

OpenCLI

+
+
+ + Checking... +
+ + + + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 00000000..21c952cd --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,20 @@ +// Query connection status from background service worker +chrome.runtime.sendMessage({ type: 'getStatus' }, (resp) => { + const dot = document.getElementById('dot'); + const status = document.getElementById('status'); + if (chrome.runtime.lastError || !resp) { + dot.className = 'dot disconnected'; + status.innerHTML = 'No daemon connected'; + return; + } + if (resp.connected) { + dot.className = 'dot connected'; + status.innerHTML = 'Connected to daemon'; + } else if (resp.reconnecting) { + dot.className = 'dot connecting'; + status.innerHTML = 'Reconnecting...'; + } else { + dot.className = 'dot disconnected'; + status.innerHTML = 'No daemon connected'; + } +}); diff --git a/extension/src/background.ts b/extension/src/background.ts index 1ace5666..7e184be2 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -193,6 +193,18 @@ chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === 'keepalive') connect(); }); +// ─── Popup status API ─────────────────────────────────────────────── + +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.type === 'getStatus') { + sendResponse({ + connected: ws?.readyState === WebSocket.OPEN, + reconnecting: reconnectTimer !== null, + }); + } + return false; +}); + // ─── Command dispatcher ───────────────────────────────────────────── async function handleCommand(cmd: Command): Promise {