From 32248c0a5c265cf2d8f95c85de191e215d25fa54 Mon Sep 17 00:00:00 2001 From: zarkin404 Date: Thu, 2 Apr 2026 17:04:00 +0800 Subject: [PATCH 1/3] feat: add settings for daemon host and port in popup - Introduced a settings section in the popup to allow users to configure the daemon host and port. - Implemented storage functionality to save and retrieve these settings. - Updated connection logic to use the configured host and port for WebSocket connections. - Enhanced the background script to handle changes in the stored settings and reconnect accordingly. - Bumped version to 1.5.5 to reflect these changes. Made-with: Cursor --- extension/dist/background.js | 76 +++++++++++++++++++++++++++++++----- extension/manifest.json | 3 +- extension/popup.html | 57 +++++++++++++++++++++++++++ extension/popup.js | 45 ++++++++++++++++++++- extension/src/background.ts | 72 ++++++++++++++++++++++++++++++---- extension/src/protocol.ts | 20 +++++++--- 6 files changed, 248 insertions(+), 25 deletions(-) diff --git a/extension/dist/background.js b/extension/dist/background.js index 686e5d17..f331c56f 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -1,7 +1,14 @@ -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 DEFAULT_DAEMON_HOST = "localhost"; +const DEFAULT_DAEMON_PORT = 19825; +function buildDaemonEndpoints(host, port) { + const h = (host || DEFAULT_DAEMON_HOST).trim() || DEFAULT_DAEMON_HOST; + const p = Number.isFinite(port) && port >= 1 && port <= 65535 ? port : DEFAULT_DAEMON_PORT; + const hostPart = h.includes(":") && !h.startsWith("[") ? `[${h}]` : h; + return { + ping: `http://${hostPart}:${p}/ping`, + ws: `ws://${hostPart}:${p}/ext` + }; +} const WS_RECONNECT_BASE_DELAY = 2e3; const WS_RECONNECT_MAX_DELAY = 5e3; @@ -210,9 +217,22 @@ function registerListeners() { }); } +const STORAGE_KEYS = { host: "daemonHost", port: "daemonPort" }; let ws = null; +let activeWsUrl = null; +let pendingWsUrl = null; let reconnectTimer = null; let reconnectAttempts = 0; +async function getDaemonSettings() { + const result = await chrome.storage.local.get({ + [STORAGE_KEYS.host]: DEFAULT_DAEMON_HOST, + [STORAGE_KEYS.port]: DEFAULT_DAEMON_PORT + }); + let host = result[STORAGE_KEYS.host]?.trim() || DEFAULT_DAEMON_HOST; + let port = typeof result[STORAGE_KEYS.port] === "number" ? result[STORAGE_KEYS.port] : DEFAULT_DAEMON_PORT; + if (!Number.isFinite(port) || port < 1 || port > 65535) port = DEFAULT_DAEMON_PORT; + return { host, port }; +} const _origLog = console.log.bind(console); const _origWarn = console.warn.bind(console); const _origError = console.error.bind(console); @@ -237,21 +257,37 @@ console.error = (...args) => { forwardLog("error", args); }; async function connect() { - if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + const { host, port } = await getDaemonSettings(); + const { ping: pingUrl, ws: wsUrl } = buildDaemonEndpoints(host, port); + if (ws) { + if (ws.readyState === WebSocket.OPEN && activeWsUrl === wsUrl) return; + if (ws.readyState === WebSocket.CONNECTING && pendingWsUrl === wsUrl) return; + try { + ws.close(); + } catch { + } + ws = null; + activeWsUrl = null; + pendingWsUrl = null; + } try { - const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) }); + const res = await fetch(pingUrl, { signal: AbortSignal.timeout(1e3) }); if (!res.ok) return; } catch { return; } try { - ws = new WebSocket(DAEMON_WS_URL); + pendingWsUrl = wsUrl; + ws = new WebSocket(wsUrl); } catch { + pendingWsUrl = null; scheduleReconnect(); return; } ws.onopen = () => { console.log("[opencli] Connected to daemon"); + pendingWsUrl = null; + activeWsUrl = wsUrl; reconnectAttempts = 0; if (reconnectTimer) { clearTimeout(reconnectTimer); @@ -271,6 +307,8 @@ async function connect() { ws.onclose = () => { console.log("[opencli] Disconnected from daemon"); ws = null; + activeWsUrl = null; + pendingWsUrl = null; scheduleReconnect(); }; ws.onerror = () => { @@ -364,12 +402,30 @@ chrome.runtime.onStartup.addListener(() => { chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === "keepalive") void connect(); }); +chrome.storage.onChanged.addListener((changes, area) => { + if (area !== "local") return; + if (!changes[STORAGE_KEYS.host] && !changes[STORAGE_KEYS.port]) return; + try { + ws?.close(); + } catch { + } + ws = null; + activeWsUrl = null; + pendingWsUrl = null; + reconnectAttempts = 0; + void connect(); +}); chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg?.type === "getStatus") { - sendResponse({ - connected: ws?.readyState === WebSocket.OPEN, - reconnecting: reconnectTimer !== null + void getDaemonSettings().then(({ host, port }) => { + sendResponse({ + connected: ws?.readyState === WebSocket.OPEN, + reconnecting: reconnectTimer !== null, + host, + port + }); }); + return true; } return false; }); diff --git a/extension/manifest.json b/extension/manifest.json index f4feaa49..268ef229 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -9,7 +9,8 @@ "tabs", "cookies", "activeTab", - "alarms" + "alarms", + "storage" ], "host_permissions": [ "" diff --git a/extension/popup.html b/extension/popup.html index 02ca1b97..72a86a60 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -38,6 +38,55 @@ .dot.connecting { background: #ff9500; } .status-text { font-size: 13px; color: #555; } .status-text strong { color: #333; } + .settings { + margin-top: 12px; + padding: 10px 12px; + border-radius: 8px; + background: #f5f5f5; + } + .settings label { + display: block; + font-size: 11px; + color: #666; + margin-bottom: 4px; + margin-top: 8px; + } + .settings label:first-of-type { margin-top: 0; } + .settings input { + width: 100%; + padding: 6px 8px; + border: 1px solid #e0e0e0; + border-radius: 6px; + font-size: 13px; + font-family: inherit; + background: #fff; + color: #333; + } + .settings input:focus { + outline: none; + border-color: #007aff; + } + .settings button { + margin-top: 10px; + width: 100%; + padding: 8px 12px; + border: none; + border-radius: 8px; + background: #007aff; + color: #fff; + font-size: 13px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + } + .settings button:hover { background: #0066d6; } + .settings button:active { opacity: 0.9; } + .settings .save-hint { + margin-top: 6px; + font-size: 11px; + color: #34c759; + min-height: 16px; + } .hint { margin-top: 10px; padding: 8px 10px; @@ -73,6 +122,14 @@

OpenCLI

Checking... +
+ + + + + +
+
This is normal. The extension connects automatically when you run any opencli command.
diff --git a/extension/popup.js b/extension/popup.js index 4bd3a7d4..a062d35a 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -1,5 +1,7 @@ -// Query connection status from background service worker -chrome.runtime.sendMessage({ type: 'getStatus' }, (resp) => { +const DEFAULT_HOST = 'localhost'; +const DEFAULT_PORT = 19825; + +function renderStatus(resp) { const dot = document.getElementById('dot'); const status = document.getElementById('status'); const hint = document.getElementById('hint'); @@ -22,4 +24,43 @@ chrome.runtime.sendMessage({ type: 'getStatus' }, (resp) => { status.innerHTML = 'No daemon connected'; hint.style.display = 'block'; } +} + +function loadFields() { + chrome.storage.local.get( + { daemonHost: DEFAULT_HOST, daemonPort: DEFAULT_PORT }, + (stored) => { + document.getElementById('host').value = stored.daemonHost || DEFAULT_HOST; + document.getElementById('port').value = String(stored.daemonPort ?? DEFAULT_PORT); + }, + ); +} + +function refreshStatus() { + chrome.runtime.sendMessage({ type: 'getStatus' }, (resp) => { + renderStatus(resp); + }); +} + +document.getElementById('save').addEventListener('click', () => { + const hostRaw = document.getElementById('host').value; + const host = (hostRaw && hostRaw.trim()) ? hostRaw.trim() : DEFAULT_HOST; + const portNum = parseInt(document.getElementById('port').value, 10); + const hintEl = document.getElementById('saveHint'); + if (!Number.isFinite(portNum) || portNum < 1 || portNum > 65535) { + hintEl.textContent = 'Enter a valid port (1–65535).'; + hintEl.style.color = '#ff3b30'; + return; + } + chrome.storage.local.set({ daemonHost: host, daemonPort: portNum }, () => { + hintEl.textContent = 'Saved. Reconnecting…'; + hintEl.style.color = '#34c759'; + setTimeout(() => { + hintEl.textContent = ''; + refreshStatus(); + }, 800); + }); }); + +loadFields(); +refreshStatus(); diff --git a/extension/src/background.ts b/extension/src/background.ts index 5a8cabb2..e1d38191 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -6,13 +6,35 @@ */ import type { Command, Result } from './protocol'; -import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; +import { + DEFAULT_DAEMON_HOST, + DEFAULT_DAEMON_PORT, + WS_RECONNECT_BASE_DELAY, + WS_RECONNECT_MAX_DELAY, + buildDaemonEndpoints, +} from './protocol'; import * as executor from './cdp'; +const STORAGE_KEYS = { host: 'daemonHost', port: 'daemonPort' } as const; + let ws: WebSocket | null = null; +let activeWsUrl: string | null = null; +/** URL we are currently connecting to (before onopen). */ +let pendingWsUrl: string | null = null; let reconnectTimer: ReturnType | null = null; let reconnectAttempts = 0; +async function getDaemonSettings(): Promise<{ host: string; port: number }> { + const result = await chrome.storage.local.get({ + [STORAGE_KEYS.host]: DEFAULT_DAEMON_HOST, + [STORAGE_KEYS.port]: DEFAULT_DAEMON_PORT, + }); + let host = result[STORAGE_KEYS.host]?.trim() || DEFAULT_DAEMON_HOST; + let port = typeof result[STORAGE_KEYS.port] === 'number' ? result[STORAGE_KEYS.port] : DEFAULT_DAEMON_PORT; + if (!Number.isFinite(port) || port < 1 || port > 65535) port = DEFAULT_DAEMON_PORT; + return { host, port }; +} + // ─── Console log forwarding ────────────────────────────────────────── // Hook console.log/warn/error to forward logs to daemon via WebSocket. @@ -42,24 +64,40 @@ console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error * call site remains unchanged and the guard can never be accidentally skipped. */ async function connect(): Promise { - if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + const { host, port } = await getDaemonSettings(); + const { ping: pingUrl, ws: wsUrl } = buildDaemonEndpoints(host, port); + + if (ws) { + if (ws.readyState === WebSocket.OPEN && activeWsUrl === wsUrl) return; + if (ws.readyState === WebSocket.CONNECTING && pendingWsUrl === wsUrl) return; + try { + ws.close(); + } catch { /* ignore */ } + ws = null; + activeWsUrl = null; + pendingWsUrl = null; + } try { - const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1000) }); + const res = await fetch(pingUrl, { signal: AbortSignal.timeout(1000) }); if (!res.ok) return; // unexpected response — not our daemon } catch { return; // daemon not running — skip WebSocket to avoid console noise } try { - ws = new WebSocket(DAEMON_WS_URL); + pendingWsUrl = wsUrl; + ws = new WebSocket(wsUrl); } catch { + pendingWsUrl = null; scheduleReconnect(); return; } ws.onopen = () => { console.log('[opencli] Connected to daemon'); + pendingWsUrl = null; + activeWsUrl = wsUrl; reconnectAttempts = 0; // Reset on successful connection if (reconnectTimer) { clearTimeout(reconnectTimer); @@ -82,6 +120,8 @@ async function connect(): Promise { ws.onclose = () => { console.log('[opencli] Disconnected from daemon'); ws = null; + activeWsUrl = null; + pendingWsUrl = null; scheduleReconnect(); }; @@ -218,14 +258,32 @@ chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === 'keepalive') void connect(); }); +chrome.storage.onChanged.addListener((changes, area) => { + if (area !== 'local') return; + if (!changes[STORAGE_KEYS.host] && !changes[STORAGE_KEYS.port]) return; + try { + ws?.close(); + } catch { /* ignore */ } + ws = null; + activeWsUrl = null; + pendingWsUrl = null; + reconnectAttempts = 0; + void connect(); +}); + // ─── Popup status API ─────────────────────────────────────────────── chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg?.type === 'getStatus') { - sendResponse({ - connected: ws?.readyState === WebSocket.OPEN, - reconnecting: reconnectTimer !== null, + void getDaemonSettings().then(({ host, port }) => { + sendResponse({ + connected: ws?.readyState === WebSocket.OPEN, + reconnecting: reconnectTimer !== null, + host, + port, + }); }); + return true; } return false; }); diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index 4652dab7..8da04849 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -49,12 +49,22 @@ export interface Result { error?: string; } +/** Default daemon host (overridable in extension popup) */ +export const DEFAULT_DAEMON_HOST = 'localhost'; + /** Default daemon port */ -export const DAEMON_PORT = 19825; -export const DAEMON_HOST = 'localhost'; -export const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; -/** Lightweight health-check endpoint — probed before each WebSocket attempt. */ -export const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; +export const DEFAULT_DAEMON_PORT = 19825; + +/** Build ping / WebSocket URLs for a daemon host and port. */ +export function buildDaemonEndpoints(host: string, port: number): { ping: string; ws: string } { + const h = (host || DEFAULT_DAEMON_HOST).trim() || DEFAULT_DAEMON_HOST; + const p = Number.isFinite(port) && port >= 1 && port <= 65535 ? port : DEFAULT_DAEMON_PORT; + const hostPart = h.includes(':') && !h.startsWith('[') ? `[${h}]` : h; + return { + ping: `http://${hostPart}:${p}/ping`, + ws: `ws://${hostPart}:${p}/ext`, + }; +} /** Base reconnect delay for extension WebSocket (ms) */ export const WS_RECONNECT_BASE_DELAY = 2000; From b201f4ea64ead882c90173c9474746ac9b024845 Mon Sep 17 00:00:00 2001 From: zarkin404 Date: Thu, 2 Apr 2026 17:45:03 +0800 Subject: [PATCH 2/3] fix(extension): harden daemon reconnect and host settings Guard websocket callbacks against stale sockets, validate and normalize daemon host input, and document the risks of exposing the daemon remotely. Made-with: Cursor --- docs/guide/browser-bridge.md | 4 ++ extension/dist/background.js | 110 +++++++++++++++++++------------ extension/popup.html | 12 ++++ extension/popup.js | 50 +++++++++++++- extension/src/background.test.ts | 53 ++++++++++++++- extension/src/background.ts | 103 +++++++++++++++++------------ extension/src/protocol.test.ts | 22 +++++++ extension/src/protocol.ts | 29 +++++++- 8 files changed, 294 insertions(+), 89 deletions(-) create mode 100644 extension/src/protocol.test.ts diff --git a/docs/guide/browser-bridge.md b/docs/guide/browser-bridge.md index 2fac087f..fc8e95e9 100644 --- a/docs/guide/browser-bridge.md +++ b/docs/guide/browser-bridge.md @@ -47,3 +47,7 @@ opencli daemon restart # Stop + restart ``` Override the timeout via the `OPENCLI_DAEMON_TIMEOUT` environment variable (milliseconds). Set to `0` to keep the daemon alive indefinitely. + +## Remote Exposure Warning + +If you point the extension at a daemon that is reachable over a public tunnel, or any non-local network path, add your own protection layer first. The daemon has minimal built-in auth, so prefer a VPN, SSH tunnel, or another authenticated/private tunnel instead of exposing the port directly to the public internet. diff --git a/extension/dist/background.js b/extension/dist/background.js index f331c56f..3883417e 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -1,7 +1,27 @@ const DEFAULT_DAEMON_HOST = "localhost"; const DEFAULT_DAEMON_PORT = 19825; +function normalizeDaemonHost(host) { + let value = (host || "").trim(); + if (!value) return DEFAULT_DAEMON_HOST; + if (value.includes("://")) { + try { + value = new URL(value).hostname || value; + } catch { + value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ""); + } + } + value = value.replace(/[/?#].*$/, ""); + const bracketedIpv6Match = value.match(/^\[([^\]]+)\](?::\d+)?$/); + if (bracketedIpv6Match?.[1]) return bracketedIpv6Match[1]; + const colonCount = (value.match(/:/g) || []).length; + if (colonCount === 1) { + const [hostname] = value.split(":"); + value = hostname || value; + } + return value.trim() || DEFAULT_DAEMON_HOST; +} function buildDaemonEndpoints(host, port) { - const h = (host || DEFAULT_DAEMON_HOST).trim() || DEFAULT_DAEMON_HOST; + const h = normalizeDaemonHost(host); const p = Number.isFinite(port) && port >= 1 && port <= 65535 ? port : DEFAULT_DAEMON_PORT; const hostPart = h.includes(":") && !h.startsWith("[") ? `[${h}]` : h; return { @@ -228,7 +248,7 @@ async function getDaemonSettings() { [STORAGE_KEYS.host]: DEFAULT_DAEMON_HOST, [STORAGE_KEYS.port]: DEFAULT_DAEMON_PORT }); - let host = result[STORAGE_KEYS.host]?.trim() || DEFAULT_DAEMON_HOST; + const host = normalizeDaemonHost(result[STORAGE_KEYS.host]); let port = typeof result[STORAGE_KEYS.port] === "number" ? result[STORAGE_KEYS.port] : DEFAULT_DAEMON_PORT; if (!Number.isFinite(port) || port < 1 || port > 65535) port = DEFAULT_DAEMON_PORT; return { host, port }; @@ -262,13 +282,14 @@ async function connect() { if (ws) { if (ws.readyState === WebSocket.OPEN && activeWsUrl === wsUrl) return; if (ws.readyState === WebSocket.CONNECTING && pendingWsUrl === wsUrl) return; - try { - ws.close(); - } catch { - } + const previousSocket = ws; ws = null; activeWsUrl = null; pendingWsUrl = null; + try { + previousSocket.close(); + } catch { + } } try { const res = await fetch(pingUrl, { signal: AbortSignal.timeout(1e3) }); @@ -278,42 +299,48 @@ async function connect() { } try { pendingWsUrl = wsUrl; - ws = new WebSocket(wsUrl); + const socket = new WebSocket(wsUrl); + ws = socket; + socket.onopen = () => { + if (ws !== socket) return; + console.log("[opencli] Connected to daemon"); + pendingWsUrl = null; + activeWsUrl = wsUrl; + reconnectAttempts = 0; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + socket.send(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version })); + }; + socket.onmessage = async (event) => { + if (ws !== socket) return; + try { + const command = JSON.parse(event.data); + const result = await handleCommand(command); + if (ws !== socket || socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify(result)); + } catch (err) { + console.error("[opencli] Message handling error:", err); + } + }; + socket.onclose = () => { + if (ws !== socket) return; + console.log("[opencli] Disconnected from daemon"); + ws = null; + activeWsUrl = null; + pendingWsUrl = null; + scheduleReconnect(); + }; + socket.onerror = () => { + if (ws !== socket) return; + socket.close(); + }; } catch { pendingWsUrl = null; scheduleReconnect(); return; } - ws.onopen = () => { - console.log("[opencli] Connected to daemon"); - pendingWsUrl = null; - activeWsUrl = wsUrl; - reconnectAttempts = 0; - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - ws?.send(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version })); - }; - 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; - activeWsUrl = null; - pendingWsUrl = null; - scheduleReconnect(); - }; - ws.onerror = () => { - ws?.close(); - }; } const MAX_EAGER_ATTEMPTS = 6; function scheduleReconnect() { @@ -405,13 +432,14 @@ chrome.alarms.onAlarm.addListener((alarm) => { chrome.storage.onChanged.addListener((changes, area) => { if (area !== "local") return; if (!changes[STORAGE_KEYS.host] && !changes[STORAGE_KEYS.port]) return; - try { - ws?.close(); - } catch { - } + const previousSocket = ws; ws = null; activeWsUrl = null; pendingWsUrl = null; + try { + previousSocket?.close(); + } catch { + } reconnectAttempts = 0; void connect(); }); diff --git a/extension/popup.html b/extension/popup.html index 72a86a60..5f194674 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -87,6 +87,15 @@ color: #34c759; min-height: 16px; } + .settings .warning { + margin-top: 10px; + padding: 8px 10px; + border-radius: 6px; + background: #fff4e5; + font-size: 11px; + color: #7a4b00; + line-height: 1.5; + } .hint { margin-top: 10px; padding: 8px 10px; @@ -129,6 +138,9 @@

OpenCLI

+
+ If you expose the daemon remotely, protect it with a VPN, SSH tunnel, or another authenticated tunnel. The daemon has minimal built-in auth. +
This is normal. The extension connects automatically when you run any opencli command. diff --git a/extension/popup.js b/extension/popup.js index a062d35a..909d5792 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -1,5 +1,45 @@ const DEFAULT_HOST = 'localhost'; const DEFAULT_PORT = 19825; +const HOST_VALIDATION_ERROR = 'Enter hostname or IP only, no scheme or port.'; + +function normalizeHost(host) { + let value = (host || '').trim(); + if (!value) return DEFAULT_HOST; + + if (value.includes('://')) { + try { + value = new URL(value).hostname || value; + } catch { + value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''); + } + } + + value = value.replace(/[/?#].*$/, ''); + + const bracketedIpv6Match = value.match(/^\[([^\]]+)\](?::\d+)?$/); + if (bracketedIpv6Match && bracketedIpv6Match[1]) return bracketedIpv6Match[1]; + + const colonCount = (value.match(/:/g) || []).length; + if (colonCount === 1) { + const [hostname] = value.split(':'); + value = hostname || value; + } + + return value.trim() || DEFAULT_HOST; +} + +function validateHost(host) { + const value = (host || '').trim(); + if (!value) return null; + if (value.includes('://')) return HOST_VALIDATION_ERROR; + if (/[/?#]/.test(value)) return HOST_VALIDATION_ERROR; + if (/^\[[^\]]+\]:\d+$/.test(value)) return HOST_VALIDATION_ERROR; + + const colonCount = (value.match(/:/g) || []).length; + if (colonCount === 1 && !value.startsWith('[')) return HOST_VALIDATION_ERROR; + + return null; +} function renderStatus(resp) { const dot = document.getElementById('dot'); @@ -30,7 +70,7 @@ function loadFields() { chrome.storage.local.get( { daemonHost: DEFAULT_HOST, daemonPort: DEFAULT_PORT }, (stored) => { - document.getElementById('host').value = stored.daemonHost || DEFAULT_HOST; + document.getElementById('host').value = normalizeHost(stored.daemonHost); document.getElementById('port').value = String(stored.daemonPort ?? DEFAULT_PORT); }, ); @@ -44,9 +84,15 @@ function refreshStatus() { document.getElementById('save').addEventListener('click', () => { const hostRaw = document.getElementById('host').value; - const host = (hostRaw && hostRaw.trim()) ? hostRaw.trim() : DEFAULT_HOST; + const hostError = validateHost(hostRaw); + const host = normalizeHost(hostRaw); const portNum = parseInt(document.getElementById('port').value, 10); const hintEl = document.getElementById('saveHint'); + if (hostError) { + hintEl.textContent = hostError; + hintEl.style.color = '#ff3b30'; + return; + } if (!Number.isFinite(portNum) || portNum < 1 || portNum > 65535) { hintEl.textContent = 'Enter a valid port (1–65535).'; hintEl.style.color = '#ff3b30'; diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index f9368251..dec79311 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -14,13 +14,19 @@ type MockTab = { class MockWebSocket { static OPEN = 1; static CONNECTING = 0; + static instances: MockWebSocket[] = []; + + url: string; readyState = MockWebSocket.CONNECTING; onopen: (() => void) | null = null; onmessage: ((event: { data: string }) => void) | null = null; onclose: (() => void) | null = null; onerror: (() => void) | null = null; - constructor(_url: string) {} + constructor(url: string) { + this.url = url; + MockWebSocket.instances.push(this); + } send(_data: string): void {} close(): void { this.onclose?.(); @@ -29,6 +35,10 @@ class MockWebSocket { function createChromeMock() { let nextTabId = 10; + const storageState = { + daemonHost: 'localhost', + daemonPort: 19825, + }; const tabs: MockTab[] = [ { id: 1, windowId: 1, url: 'https://automation.example', title: 'automation', active: true, status: 'complete' }, { id: 2, windowId: 2, url: 'https://user.example', title: 'user', active: true, status: 'complete' }, @@ -91,12 +101,21 @@ function createChromeMock() { onMessage: { addListener: vi.fn() } as Listener<(msg: unknown, sender: unknown, sendResponse: (value: unknown) => void) => void>, getManifest: vi.fn(() => ({ version: 'test-version' })), }, + storage: { + local: { + get: vi.fn(async (defaults: Record) => ({ + ...defaults, + ...storageState, + })), + }, + onChanged: { addListener: vi.fn() } as Listener<(changes: unknown, area: string) => void>, + }, cookies: { getAll: vi.fn(async () => []), }, }; - return { chrome, tabs, query, create, update }; + return { chrome, tabs, query, create, update, storageState }; } describe('background tab isolation', () => { @@ -104,6 +123,7 @@ describe('background tab isolation', () => { vi.resetModules(); vi.useRealTimers(); vi.stubGlobal('WebSocket', MockWebSocket); + MockWebSocket.instances = []; }); afterEach(() => { @@ -237,4 +257,33 @@ describe('background tab isolation', () => { expect(chrome.windows.remove).toHaveBeenCalledWith(1); expect(mod.__test__.getSession('site:notebooklm')).toBeNull(); }); + + it('ignores stale websocket close events after reconnecting with new settings', async () => { + const { chrome, storageState } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true }))); + + const mod = await import('./background'); + + await mod.__test__.connect(); + const firstSocket = MockWebSocket.instances.at(-1)!; + firstSocket.readyState = MockWebSocket.OPEN; + firstSocket.onopen?.(); + + storageState.daemonHost = '127.0.0.1'; + + await mod.__test__.connect(); + const secondSocket = MockWebSocket.instances.at(-1)!; + secondSocket.readyState = MockWebSocket.OPEN; + secondSocket.onopen?.(); + + firstSocket.onclose?.(); + + expect(mod.__test__.getConnectionState()).toEqual(expect.objectContaining({ + ws: secondSocket, + activeWsUrl: 'ws://127.0.0.1:19825/ext', + pendingWsUrl: null, + reconnecting: false, + })); + }); }); diff --git a/extension/src/background.ts b/extension/src/background.ts index e1d38191..3a7788cd 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -12,6 +12,7 @@ import { WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY, buildDaemonEndpoints, + normalizeDaemonHost, } from './protocol'; import * as executor from './cdp'; @@ -29,7 +30,7 @@ async function getDaemonSettings(): Promise<{ host: string; port: number }> { [STORAGE_KEYS.host]: DEFAULT_DAEMON_HOST, [STORAGE_KEYS.port]: DEFAULT_DAEMON_PORT, }); - let host = result[STORAGE_KEYS.host]?.trim() || DEFAULT_DAEMON_HOST; + const host = normalizeDaemonHost(result[STORAGE_KEYS.host]); let port = typeof result[STORAGE_KEYS.port] === 'number' ? result[STORAGE_KEYS.port] : DEFAULT_DAEMON_PORT; if (!Number.isFinite(port) || port < 1 || port > 65535) port = DEFAULT_DAEMON_PORT; return { host, port }; @@ -70,12 +71,13 @@ async function connect(): Promise { if (ws) { if (ws.readyState === WebSocket.OPEN && activeWsUrl === wsUrl) return; if (ws.readyState === WebSocket.CONNECTING && pendingWsUrl === wsUrl) return; - try { - ws.close(); - } catch { /* ignore */ } + const previousSocket = ws; ws = null; activeWsUrl = null; pendingWsUrl = null; + try { + previousSocket.close(); + } catch { /* ignore */ } } try { @@ -87,47 +89,53 @@ async function connect(): Promise { try { pendingWsUrl = wsUrl; - ws = new WebSocket(wsUrl); - } catch { - pendingWsUrl = null; - scheduleReconnect(); - return; - } + const socket = new WebSocket(wsUrl); + ws = socket; + + socket.onopen = () => { + if (ws !== socket) return; + console.log('[opencli] Connected to daemon'); + pendingWsUrl = null; + activeWsUrl = wsUrl; + reconnectAttempts = 0; // Reset on successful connection + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + // Send version so the daemon can report mismatches to the CLI + socket.send(JSON.stringify({ type: 'hello', version: chrome.runtime.getManifest().version })); + }; - ws.onopen = () => { - console.log('[opencli] Connected to daemon'); - pendingWsUrl = null; - activeWsUrl = wsUrl; - reconnectAttempts = 0; // Reset on successful connection - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - // Send version so the daemon can report mismatches to the CLI - ws?.send(JSON.stringify({ type: 'hello', version: chrome.runtime.getManifest().version })); - }; + socket.onmessage = async (event) => { + if (ws !== socket) return; + try { + const command = JSON.parse(event.data as string) as Command; + const result = await handleCommand(command); + if (ws !== socket || socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify(result)); + } catch (err) { + console.error('[opencli] Message handling error:', err); + } + }; - ws.onmessage = async (event) => { - try { - const command = JSON.parse(event.data as string) as Command; - const result = await handleCommand(command); - ws?.send(JSON.stringify(result)); - } catch (err) { - console.error('[opencli] Message handling error:', err); - } - }; + socket.onclose = () => { + if (ws !== socket) return; + console.log('[opencli] Disconnected from daemon'); + ws = null; + activeWsUrl = null; + pendingWsUrl = null; + scheduleReconnect(); + }; - ws.onclose = () => { - console.log('[opencli] Disconnected from daemon'); - ws = null; - activeWsUrl = null; + socket.onerror = () => { + if (ws !== socket) return; + socket.close(); + }; + } catch { pendingWsUrl = null; scheduleReconnect(); - }; - - ws.onerror = () => { - ws?.close(); - }; + return; + } } /** @@ -261,12 +269,13 @@ chrome.alarms.onAlarm.addListener((alarm) => { chrome.storage.onChanged.addListener((changes, area) => { if (area !== 'local') return; if (!changes[STORAGE_KEYS.host] && !changes[STORAGE_KEYS.port]) return; - try { - ws?.close(); - } catch { /* ignore */ } + const previousSocket = ws; ws = null; activeWsUrl = null; pendingWsUrl = null; + try { + previousSocket?.close(); + } catch { /* ignore */ } reconnectAttempts = 0; void connect(); }); @@ -675,11 +684,19 @@ async function handleSessions(cmd: Command): Promise { } export const __test__ = { + connect, handleNavigate, isTargetUrl, handleTabs, handleSessions, resolveTabId, + getConnectionState: () => ({ + ws, + activeWsUrl, + pendingWsUrl, + reconnectAttempts, + reconnecting: reconnectTimer !== null, + }), resetWindowIdleTimer, getSession: (workspace: string = 'default') => automationSessions.get(workspace) ?? null, getAutomationWindowId: (workspace: string = 'default') => automationSessions.get(workspace)?.windowId ?? null, diff --git a/extension/src/protocol.test.ts b/extension/src/protocol.test.ts new file mode 100644 index 00000000..c64c3619 --- /dev/null +++ b/extension/src/protocol.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { buildDaemonEndpoints, normalizeDaemonHost } from './protocol'; + +describe('daemon host normalization', () => { + it('strips legacy schemes and ports from stored daemon hosts', () => { + expect(normalizeDaemonHost('http://1.2.3.4')).toBe('1.2.3.4'); + expect(normalizeDaemonHost('example.com:19825')).toBe('example.com'); + expect(normalizeDaemonHost('[::1]:19825')).toBe('::1'); + }); + + it('builds valid daemon endpoints from normalized hosts', () => { + expect(buildDaemonEndpoints('http://1.2.3.4', 19825)).toEqual({ + ping: 'http://1.2.3.4:19825/ping', + ws: 'ws://1.2.3.4:19825/ext', + }); + expect(buildDaemonEndpoints('[::1]:19825', 19825)).toEqual({ + ping: 'http://[::1]:19825/ping', + ws: 'ws://[::1]:19825/ext', + }); + }); +}); diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index 8da04849..d16f27d1 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -55,9 +55,36 @@ export const DEFAULT_DAEMON_HOST = 'localhost'; /** Default daemon port */ export const DEFAULT_DAEMON_PORT = 19825; +/** Normalize legacy host input that may accidentally include a scheme or port. */ +export function normalizeDaemonHost(host: string | null | undefined): string { + let value = (host || '').trim(); + if (!value) return DEFAULT_DAEMON_HOST; + + if (value.includes('://')) { + try { + value = new URL(value).hostname || value; + } catch { + value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''); + } + } + + value = value.replace(/[/?#].*$/, ''); + + const bracketedIpv6Match = value.match(/^\[([^\]]+)\](?::\d+)?$/); + if (bracketedIpv6Match?.[1]) return bracketedIpv6Match[1]; + + const colonCount = (value.match(/:/g) || []).length; + if (colonCount === 1) { + const [hostname] = value.split(':'); + value = hostname || value; + } + + return value.trim() || DEFAULT_DAEMON_HOST; +} + /** Build ping / WebSocket URLs for a daemon host and port. */ export function buildDaemonEndpoints(host: string, port: number): { ping: string; ws: string } { - const h = (host || DEFAULT_DAEMON_HOST).trim() || DEFAULT_DAEMON_HOST; + const h = normalizeDaemonHost(host); const p = Number.isFinite(port) && port >= 1 && port <= 65535 ? port : DEFAULT_DAEMON_PORT; const hostPart = h.includes(':') && !h.startsWith('[') ? `[${h}]` : h; return { From c9541ef2c06d2167af25b3c5e41d0456e8d58aea Mon Sep 17 00:00:00 2001 From: zarkin404 Date: Thu, 2 Apr 2026 21:21:11 +0800 Subject: [PATCH 3/3] refactor(extension): simplify websocket connection state Use the socket's own url and readyState instead of duplicating active and pending URL state, while keeping the in-flight connect guard and reconnect timer cleanup. Made-with: Cursor --- extension/dist/background.js | 23 +++++-------- extension/src/background.test.ts | 58 ++++++++++++++++++++++++++++++-- extension/src/background.ts | 29 +++++++--------- 3 files changed, 77 insertions(+), 33 deletions(-) diff --git a/extension/dist/background.js b/extension/dist/background.js index 3883417e..20e57d4f 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -239,10 +239,9 @@ function registerListeners() { const STORAGE_KEYS = { host: "daemonHost", port: "daemonPort" }; let ws = null; -let activeWsUrl = null; -let pendingWsUrl = null; let reconnectTimer = null; let reconnectAttempts = 0; +let connectAttemptId = 0; async function getDaemonSettings() { const result = await chrome.storage.local.get({ [STORAGE_KEYS.host]: DEFAULT_DAEMON_HOST, @@ -277,15 +276,14 @@ console.error = (...args) => { forwardLog("error", args); }; async function connect() { + const attemptId = ++connectAttemptId; const { host, port } = await getDaemonSettings(); + if (attemptId !== connectAttemptId) return; const { ping: pingUrl, ws: wsUrl } = buildDaemonEndpoints(host, port); if (ws) { - if (ws.readyState === WebSocket.OPEN && activeWsUrl === wsUrl) return; - if (ws.readyState === WebSocket.CONNECTING && pendingWsUrl === wsUrl) return; + if (ws.url === wsUrl && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; const previousSocket = ws; ws = null; - activeWsUrl = null; - pendingWsUrl = null; try { previousSocket.close(); } catch { @@ -293,19 +291,17 @@ async function connect() { } try { const res = await fetch(pingUrl, { signal: AbortSignal.timeout(1e3) }); + if (attemptId !== connectAttemptId) return; if (!res.ok) return; } catch { return; } try { - pendingWsUrl = wsUrl; const socket = new WebSocket(wsUrl); ws = socket; socket.onopen = () => { if (ws !== socket) return; console.log("[opencli] Connected to daemon"); - pendingWsUrl = null; - activeWsUrl = wsUrl; reconnectAttempts = 0; if (reconnectTimer) { clearTimeout(reconnectTimer); @@ -328,8 +324,6 @@ async function connect() { if (ws !== socket) return; console.log("[opencli] Disconnected from daemon"); ws = null; - activeWsUrl = null; - pendingWsUrl = null; scheduleReconnect(); }; socket.onerror = () => { @@ -337,7 +331,6 @@ async function connect() { socket.close(); }; } catch { - pendingWsUrl = null; scheduleReconnect(); return; } @@ -433,9 +426,11 @@ chrome.storage.onChanged.addListener((changes, area) => { if (area !== "local") return; if (!changes[STORAGE_KEYS.host] && !changes[STORAGE_KEYS.port]) return; const previousSocket = ws; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } ws = null; - activeWsUrl = null; - pendingWsUrl = null; try { previousSocket?.close(); } catch { diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index dec79311..a0854c27 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -33,6 +33,19 @@ class MockWebSocket { } } +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + function createChromeMock() { let nextTabId = 10; const storageState = { @@ -281,8 +294,49 @@ describe('background tab isolation', () => { expect(mod.__test__.getConnectionState()).toEqual(expect.objectContaining({ ws: secondSocket, - activeWsUrl: 'ws://127.0.0.1:19825/ext', - pendingWsUrl: null, + wsUrl: 'ws://127.0.0.1:19825/ext', + readyState: MockWebSocket.OPEN, + reconnecting: false, + })); + }); + + it('ignores stale in-flight connect attempts after settings change', async () => { + const { chrome, storageState } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const oldFetch = createDeferred<{ ok: boolean }>(); + const newFetch = createDeferred<{ ok: boolean }>(); + vi.stubGlobal('fetch', vi.fn((url: string) => { + if (url === 'http://localhost:19825/ping') return oldFetch.promise; + if (url === 'http://127.0.0.1:19825/ping') return newFetch.promise; + throw new Error(`Unexpected fetch url: ${url}`); + })); + + const mod = await import('./background'); + + const firstConnect = mod.__test__.connect(); + await flushPromises(); + + storageState.daemonHost = '127.0.0.1'; + const secondConnect = mod.__test__.connect(); + await flushPromises(); + + newFetch.resolve({ ok: true }); + await secondConnect; + + const newSocket = MockWebSocket.instances.at(-1)!; + newSocket.readyState = MockWebSocket.OPEN; + newSocket.onopen?.(); + + oldFetch.resolve({ ok: true }); + await firstConnect; + + expect(MockWebSocket.instances).toHaveLength(1); + expect(newSocket.url).toBe('ws://127.0.0.1:19825/ext'); + expect(mod.__test__.getConnectionState()).toEqual(expect.objectContaining({ + ws: newSocket, + wsUrl: 'ws://127.0.0.1:19825/ext', + readyState: MockWebSocket.OPEN, reconnecting: false, })); }); diff --git a/extension/src/background.ts b/extension/src/background.ts index 3a7788cd..e1dae6ad 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -19,11 +19,9 @@ import * as executor from './cdp'; const STORAGE_KEYS = { host: 'daemonHost', port: 'daemonPort' } as const; let ws: WebSocket | null = null; -let activeWsUrl: string | null = null; -/** URL we are currently connecting to (before onopen). */ -let pendingWsUrl: string | null = null; let reconnectTimer: ReturnType | null = null; let reconnectAttempts = 0; +let connectAttemptId = 0; async function getDaemonSettings(): Promise<{ host: string; port: number }> { const result = await chrome.storage.local.get({ @@ -65,16 +63,15 @@ console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error * call site remains unchanged and the guard can never be accidentally skipped. */ async function connect(): Promise { + const attemptId = ++connectAttemptId; const { host, port } = await getDaemonSettings(); + if (attemptId !== connectAttemptId) return; const { ping: pingUrl, ws: wsUrl } = buildDaemonEndpoints(host, port); if (ws) { - if (ws.readyState === WebSocket.OPEN && activeWsUrl === wsUrl) return; - if (ws.readyState === WebSocket.CONNECTING && pendingWsUrl === wsUrl) return; + if (ws.url === wsUrl && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; const previousSocket = ws; ws = null; - activeWsUrl = null; - pendingWsUrl = null; try { previousSocket.close(); } catch { /* ignore */ } @@ -82,21 +79,19 @@ async function connect(): Promise { try { const res = await fetch(pingUrl, { signal: AbortSignal.timeout(1000) }); + if (attemptId !== connectAttemptId) return; if (!res.ok) return; // unexpected response — not our daemon } catch { return; // daemon not running — skip WebSocket to avoid console noise } try { - pendingWsUrl = wsUrl; const socket = new WebSocket(wsUrl); ws = socket; socket.onopen = () => { if (ws !== socket) return; console.log('[opencli] Connected to daemon'); - pendingWsUrl = null; - activeWsUrl = wsUrl; reconnectAttempts = 0; // Reset on successful connection if (reconnectTimer) { clearTimeout(reconnectTimer); @@ -122,8 +117,6 @@ async function connect(): Promise { if (ws !== socket) return; console.log('[opencli] Disconnected from daemon'); ws = null; - activeWsUrl = null; - pendingWsUrl = null; scheduleReconnect(); }; @@ -132,7 +125,6 @@ async function connect(): Promise { socket.close(); }; } catch { - pendingWsUrl = null; scheduleReconnect(); return; } @@ -270,9 +262,11 @@ chrome.storage.onChanged.addListener((changes, area) => { if (area !== 'local') return; if (!changes[STORAGE_KEYS.host] && !changes[STORAGE_KEYS.port]) return; const previousSocket = ws; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } ws = null; - activeWsUrl = null; - pendingWsUrl = null; try { previousSocket?.close(); } catch { /* ignore */ } @@ -692,9 +686,10 @@ export const __test__ = { resolveTabId, getConnectionState: () => ({ ws, - activeWsUrl, - pendingWsUrl, + wsUrl: ws?.url ?? null, + readyState: ws?.readyState ?? null, reconnectAttempts, + connectAttemptId, reconnecting: reconnectTimer !== null, }), resetWindowIdleTimer,