From be87c7ad70a92457c2f8eeaec0545b4234105707 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 28 Mar 2026 11:39:54 +0800 Subject: [PATCH 1/3] fix(extension): probe daemon via HTTP before WebSocket to eliminate console noise When the daemon is offline, `new WebSocket()` logs uncatchable ERR_CONNECTION_REFUSED errors to Chrome's extension error page. Add `probeAndConnect()` that checks daemon reachability with a silent `fetch(HEAD)` before attempting WebSocket connection. All three auto-connect paths (initialize, keepalive alarm, eager reconnect) now go through the probe, eliminating the error noise entirely. Closes #505 --- extension/dist/background.js | 16 +++++++++++++--- extension/src/background.ts | 25 +++++++++++++++++++++---- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/extension/dist/background.js b/extension/dist/background.js index 8afcfaca..0894ab1f 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -1,6 +1,7 @@ const DAEMON_PORT = 19825; const DAEMON_HOST = "localhost"; const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; +const DAEMON_HTTP_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}`; const WS_RECONNECT_BASE_DELAY = 2e3; const WS_RECONNECT_MAX_DELAY = 6e4; @@ -192,7 +193,7 @@ function scheduleReconnect() { const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); reconnectTimer = setTimeout(() => { reconnectTimer = null; - connect(); + probeAndConnect(); }, delay); } const automationSessions = /* @__PURE__ */ new Map(); @@ -254,13 +255,22 @@ chrome.windows.onRemoved.addListener((windowId) => { } } }); +async function probeAndConnect() { + if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + try { + await fetch(DAEMON_HTTP_URL, { method: "HEAD", signal: AbortSignal.timeout(2e3) }); + } catch { + return; + } + connect(); +} let initialized = false; function initialize() { if (initialized) return; initialized = true; chrome.alarms.create("keepalive", { periodInMinutes: 0.4 }); registerListeners(); - connect(); + probeAndConnect(); console.log("[opencli] OpenCLI extension initialized"); } chrome.runtime.onInstalled.addListener(() => { @@ -270,7 +280,7 @@ chrome.runtime.onStartup.addListener(() => { initialize(); }); chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === "keepalive") connect(); + if (alarm.name === "keepalive") probeAndConnect(); }); chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg?.type === "getStatus") { diff --git a/extension/src/background.ts b/extension/src/background.ts index 14a641e4..d67b189c 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -6,7 +6,7 @@ */ import type { Command, Result } from './protocol'; -import { DAEMON_WS_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; +import { DAEMON_WS_URL, DAEMON_HTTP_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; import * as executor from './cdp'; let ws: WebSocket | null = null; @@ -90,7 +90,7 @@ function scheduleReconnect(): void { const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); reconnectTimer = setTimeout(() => { reconnectTimer = null; - connect(); + probeAndConnect(); }, delay); } @@ -178,6 +178,23 @@ chrome.windows.onRemoved.addListener((windowId) => { } }); +// ─── Daemon probe ──────────────────────────────────────────────────── + +/** + * Probe daemon via HTTP before attempting WebSocket connection. + * fetch() failures are silently catchable, unlike new WebSocket() which + * logs an uncatchable ERR_CONNECTION_REFUSED to the extensions error page. + */ +async function probeAndConnect(): Promise { + if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + try { + await fetch(DAEMON_HTTP_URL, { method: 'HEAD', signal: AbortSignal.timeout(2000) }); + } catch { + return; // daemon not running, skip connect to avoid console noise + } + connect(); +} + // ─── Lifecycle events ──────────────────────────────────────────────── let initialized = false; @@ -187,7 +204,7 @@ function initialize(): void { initialized = true; chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds executor.registerListeners(); - connect(); + probeAndConnect(); console.log('[opencli] OpenCLI extension initialized'); } @@ -200,7 +217,7 @@ chrome.runtime.onStartup.addListener(() => { }); chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === 'keepalive') connect(); + if (alarm.name === 'keepalive') probeAndConnect(); }); // ─── Popup status API ─────────────────────────────────────────────── From b52aa3eff2072816e6dd26958dcdf3d11a37812a Mon Sep 17 00:00:00 2001 From: jackwener Date: Sat, 28 Mar 2026 20:09:14 +0800 Subject: [PATCH 2/3] refactor(extension): inline probe into connect(), add /ping to daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of a separate probeAndConnect() wrapper that all call sites had to remember to use, bake the HTTP probe directly into connect() itself. This makes the guard impossible to accidentally skip when adding new connection paths in the future. Also adds a dedicated GET /ping endpoint to the daemon (no X-OpenCLI header required) so the probe has a clear semantic contract instead of relying on a 403 side-effect from the root path. - daemon: GET /ping → 200 {ok:true}, no auth needed, placed before the X-OpenCLI header check; only chrome-extension:// and no-origin requests reach it (origin check is still enforced above) - background: connect() is now async; probes /ping with a 1 s timeout before new WebSocket(); all call sites (initialize, keepalive alarm, scheduleReconnect) remain unchanged - probeAndConnect() removed — no longer needed --- extension/dist/background.js | 24 +++++++++------------ extension/src/background.ts | 42 +++++++++++++++++------------------- src/daemon.ts | 16 ++++++++++---- 3 files changed, 42 insertions(+), 40 deletions(-) diff --git a/extension/dist/background.js b/extension/dist/background.js index 0894ab1f..c6d353bb 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -1,7 +1,6 @@ const DAEMON_PORT = 19825; const DAEMON_HOST = "localhost"; const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; -const DAEMON_HTTP_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}`; const WS_RECONNECT_BASE_DELAY = 2e3; const WS_RECONNECT_MAX_DELAY = 6e4; @@ -150,8 +149,14 @@ console.error = (...args) => { _origError(...args); forwardLog("error", args); }; -function connect() { +const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; +async function connect() { if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + try { + await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) }); + } catch { + return; + } try { ws = new WebSocket(DAEMON_WS_URL); } catch { @@ -193,7 +198,7 @@ function scheduleReconnect() { const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); reconnectTimer = setTimeout(() => { reconnectTimer = null; - probeAndConnect(); + connect(); }, delay); } const automationSessions = /* @__PURE__ */ new Map(); @@ -255,22 +260,13 @@ chrome.windows.onRemoved.addListener((windowId) => { } } }); -async function probeAndConnect() { - if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; - try { - await fetch(DAEMON_HTTP_URL, { method: "HEAD", signal: AbortSignal.timeout(2e3) }); - } catch { - return; - } - connect(); -} let initialized = false; function initialize() { if (initialized) return; initialized = true; chrome.alarms.create("keepalive", { periodInMinutes: 0.4 }); registerListeners(); - probeAndConnect(); + connect(); console.log("[opencli] OpenCLI extension initialized"); } chrome.runtime.onInstalled.addListener(() => { @@ -280,7 +276,7 @@ chrome.runtime.onStartup.addListener(() => { initialize(); }); chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === "keepalive") probeAndConnect(); + if (alarm.name === "keepalive") connect(); }); chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg?.type === "getStatus") { diff --git a/extension/src/background.ts b/extension/src/background.ts index d67b189c..1cd98525 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -6,7 +6,7 @@ */ import type { Command, Result } from './protocol'; -import { DAEMON_WS_URL, DAEMON_HTTP_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; +import { DAEMON_WS_URL, DAEMON_HOST, DAEMON_PORT, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; import * as executor from './cdp'; let ws: WebSocket | null = null; @@ -34,9 +34,24 @@ console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error // ─── WebSocket connection ──────────────────────────────────────────── -function connect(): void { +const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; + +/** + * 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(): Promise { if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + try { + await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1000) }); + } catch { + return; // daemon not running — skip WebSocket to avoid console noise + } + try { ws = new WebSocket(DAEMON_WS_URL); } catch { @@ -90,7 +105,7 @@ function scheduleReconnect(): void { const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); reconnectTimer = setTimeout(() => { reconnectTimer = null; - probeAndConnect(); + connect(); }, delay); } @@ -178,23 +193,6 @@ chrome.windows.onRemoved.addListener((windowId) => { } }); -// ─── Daemon probe ──────────────────────────────────────────────────── - -/** - * Probe daemon via HTTP before attempting WebSocket connection. - * fetch() failures are silently catchable, unlike new WebSocket() which - * logs an uncatchable ERR_CONNECTION_REFUSED to the extensions error page. - */ -async function probeAndConnect(): Promise { - if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; - try { - await fetch(DAEMON_HTTP_URL, { method: 'HEAD', signal: AbortSignal.timeout(2000) }); - } catch { - return; // daemon not running, skip connect to avoid console noise - } - connect(); -} - // ─── Lifecycle events ──────────────────────────────────────────────── let initialized = false; @@ -204,7 +202,7 @@ function initialize(): void { initialized = true; chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds executor.registerListeners(); - probeAndConnect(); + connect(); console.log('[opencli] OpenCLI extension initialized'); } @@ -217,7 +215,7 @@ chrome.runtime.onStartup.addListener(() => { }); chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === 'keepalive') probeAndConnect(); + if (alarm.name === 'keepalive') connect(); }); // ─── Popup status API ─────────────────────────────────────────────── diff --git a/src/daemon.ts b/src/daemon.ts index 37700e22..01b2ad04 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -102,7 +102,18 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise return; } - // Require custom header on all HTTP requests. Browsers cannot attach + const url = req.url ?? '/'; + const pathname = url.split('?')[0]; + + // Health-check endpoint — no X-OpenCLI header required. + // Used by the extension to silently probe daemon reachability before + // attempting a WebSocket connection (avoids uncatchable ERR_CONNECTION_REFUSED). + if (req.method === 'GET' && pathname === '/ping') { + jsonResponse(res, 200, { ok: true }); + return; + } + + // Require custom header on all other HTTP requests. Browsers cannot attach // custom headers in "simple" requests, and our preflight returns no // Access-Control-Allow-Headers, so scripted fetch() from web pages is // blocked even if Origin check is somehow bypassed. @@ -111,9 +122,6 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise return; } - const url = req.url ?? '/'; - const pathname = url.split('?')[0]; - if (req.method === 'GET' && pathname === '/status') { jsonResponse(res, 200, { ok: true, From 99eadfd864f3220d7183451ed9dfc6f92858626c Mon Sep 17 00:00:00 2001 From: jackwener Date: Sat, 28 Mar 2026 20:14:52 +0800 Subject: [PATCH 3/3] fix(extension/daemon): address review feedback on probe refactor - protocol.ts: replace DAEMON_HTTP_URL with DAEMON_PING_URL (clearer semantics, single source of truth for the health-check URL) - background.ts: import DAEMON_PING_URL from protocol instead of defining a local constant; check res.ok so an unexpected non-200 response doesn't fall through to WebSocket; annotate all fire-and- forget connect() call sites with `void` to make intent explicit - daemon.ts: add security comment on /ping documenting the timing side-channel tradeoff (loopback-only, accepted risk) --- extension/dist/background.js | 11 ++++++----- extension/src/background.ts | 13 ++++++------- extension/src/protocol.ts | 3 ++- src/daemon.ts | 4 ++++ 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/extension/dist/background.js b/extension/dist/background.js index c6d353bb..3a8dee81 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -1,6 +1,7 @@ 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 = 6e4; @@ -149,11 +150,11 @@ console.error = (...args) => { _origError(...args); forwardLog("error", args); }; -const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; async function connect() { if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; try { - await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) }); + const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) }); + if (!res.ok) return; } catch { return; } @@ -198,7 +199,7 @@ function scheduleReconnect() { const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); reconnectTimer = setTimeout(() => { reconnectTimer = null; - connect(); + void connect(); }, delay); } const automationSessions = /* @__PURE__ */ new Map(); @@ -266,7 +267,7 @@ function initialize() { initialized = true; chrome.alarms.create("keepalive", { periodInMinutes: 0.4 }); registerListeners(); - connect(); + void connect(); console.log("[opencli] OpenCLI extension initialized"); } chrome.runtime.onInstalled.addListener(() => { @@ -276,7 +277,7 @@ chrome.runtime.onStartup.addListener(() => { initialize(); }); chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === "keepalive") connect(); + if (alarm.name === "keepalive") void connect(); }); chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg?.type === "getStatus") { diff --git a/extension/src/background.ts b/extension/src/background.ts index 1cd98525..167c3a55 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -6,7 +6,7 @@ */ import type { Command, Result } from './protocol'; -import { DAEMON_WS_URL, DAEMON_HOST, DAEMON_PORT, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; +import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; import * as executor from './cdp'; let ws: WebSocket | null = null; @@ -34,8 +34,6 @@ console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error // ─── WebSocket connection ──────────────────────────────────────────── -const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; - /** * Probe the daemon via its /ping HTTP endpoint before attempting a WebSocket * connection. fetch() failures are silently catchable; new WebSocket() is not @@ -47,7 +45,8 @@ async function connect(): Promise { if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; try { - await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1000) }); + const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1000) }); + if (!res.ok) return; // unexpected response — not our daemon } catch { return; // daemon not running — skip WebSocket to avoid console noise } @@ -105,7 +104,7 @@ function scheduleReconnect(): void { const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); reconnectTimer = setTimeout(() => { reconnectTimer = null; - connect(); + void connect(); }, delay); } @@ -202,7 +201,7 @@ function initialize(): void { initialized = true; chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds executor.registerListeners(); - connect(); + void connect(); console.log('[opencli] OpenCLI extension initialized'); } @@ -215,7 +214,7 @@ chrome.runtime.onStartup.addListener(() => { }); chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === 'keepalive') connect(); + if (alarm.name === 'keepalive') void connect(); }); // ─── Popup status API ─────────────────────────────────────────────── diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index 0d053863..53fe0d1b 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -49,7 +49,8 @@ export interface Result { export const DAEMON_PORT = 19825; export const DAEMON_HOST = 'localhost'; export const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; -export const DAEMON_HTTP_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}`; +/** Lightweight health-check endpoint — probed before each WebSocket attempt. */ +export const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; /** Base reconnect delay for extension WebSocket (ms) */ export const WS_RECONNECT_BASE_DELAY = 2000; diff --git a/src/daemon.ts b/src/daemon.ts index 01b2ad04..6297cfcc 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -108,6 +108,10 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise // Health-check endpoint — no X-OpenCLI header required. // Used by the extension to silently probe daemon reachability before // attempting a WebSocket connection (avoids uncatchable ERR_CONNECTION_REFUSED). + // Security note: this endpoint is reachable by any client that passes the + // origin check above (chrome-extension:// or no Origin header, e.g. curl). + // Timing side-channels can reveal daemon presence to local processes, which + // is an accepted risk given the daemon is loopback-only and short-lived. if (req.method === 'GET' && pathname === '/ping') { jsonResponse(res, 200, { ok: true }); return;