Skip to content

Commit 75ddb63

Browse files
Astro-Hanjackwener
andauthored
fix(extension): probe daemon before WebSocket to eliminate console noise (#534)
* 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 * refactor(extension): inline probe into connect(), add /ping to daemon 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 * 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) --------- Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent 5ec34eb commit 75ddb63

4 files changed

Lines changed: 48 additions & 14 deletions

File tree

extension/dist/background.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const DAEMON_PORT = 19825;
22
const DAEMON_HOST = "localhost";
33
const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
4+
const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`;
45
const WS_RECONNECT_BASE_DELAY = 2e3;
56
const WS_RECONNECT_MAX_DELAY = 6e4;
67

@@ -149,8 +150,14 @@ console.error = (...args) => {
149150
_origError(...args);
150151
forwardLog("error", args);
151152
};
152-
function connect() {
153+
async function connect() {
153154
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
155+
try {
156+
const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) });
157+
if (!res.ok) return;
158+
} catch {
159+
return;
160+
}
154161
try {
155162
ws = new WebSocket(DAEMON_WS_URL);
156163
} catch {
@@ -192,7 +199,7 @@ function scheduleReconnect() {
192199
const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
193200
reconnectTimer = setTimeout(() => {
194201
reconnectTimer = null;
195-
connect();
202+
void connect();
196203
}, delay);
197204
}
198205
const automationSessions = /* @__PURE__ */ new Map();
@@ -260,7 +267,7 @@ function initialize() {
260267
initialized = true;
261268
chrome.alarms.create("keepalive", { periodInMinutes: 0.4 });
262269
registerListeners();
263-
connect();
270+
void connect();
264271
console.log("[opencli] OpenCLI extension initialized");
265272
}
266273
chrome.runtime.onInstalled.addListener(() => {
@@ -270,7 +277,7 @@ chrome.runtime.onStartup.addListener(() => {
270277
initialize();
271278
});
272279
chrome.alarms.onAlarm.addListener((alarm) => {
273-
if (alarm.name === "keepalive") connect();
280+
if (alarm.name === "keepalive") void connect();
274281
});
275282
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
276283
if (msg?.type === "getStatus") {

extension/src/background.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
import type { Command, Result } from './protocol';
9-
import { DAEMON_WS_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
9+
import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
1010
import * as executor from './cdp';
1111

1212
let ws: WebSocket | null = null;
@@ -34,9 +34,23 @@ console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error
3434

3535
// ─── WebSocket connection ────────────────────────────────────────────
3636

37-
function connect(): void {
37+
/**
38+
* Probe the daemon via its /ping HTTP endpoint before attempting a WebSocket
39+
* connection. fetch() failures are silently catchable; new WebSocket() is not
40+
* — Chrome logs ERR_CONNECTION_REFUSED to the extension error page before any
41+
* JS handler can intercept it. By keeping the probe inside connect() every
42+
* call site remains unchanged and the guard can never be accidentally skipped.
43+
*/
44+
async function connect(): Promise<void> {
3845
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
3946

47+
try {
48+
const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1000) });
49+
if (!res.ok) return; // unexpected response — not our daemon
50+
} catch {
51+
return; // daemon not running — skip WebSocket to avoid console noise
52+
}
53+
4054
try {
4155
ws = new WebSocket(DAEMON_WS_URL);
4256
} catch {
@@ -90,7 +104,7 @@ function scheduleReconnect(): void {
90104
const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
91105
reconnectTimer = setTimeout(() => {
92106
reconnectTimer = null;
93-
connect();
107+
void connect();
94108
}, delay);
95109
}
96110

@@ -187,7 +201,7 @@ function initialize(): void {
187201
initialized = true;
188202
chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds
189203
executor.registerListeners();
190-
connect();
204+
void connect();
191205
console.log('[opencli] OpenCLI extension initialized');
192206
}
193207

@@ -200,7 +214,7 @@ chrome.runtime.onStartup.addListener(() => {
200214
});
201215

202216
chrome.alarms.onAlarm.addListener((alarm) => {
203-
if (alarm.name === 'keepalive') connect();
217+
if (alarm.name === 'keepalive') void connect();
204218
});
205219

206220
// ─── Popup status API ───────────────────────────────────────────────

extension/src/protocol.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ export interface Result {
4949
export const DAEMON_PORT = 19825;
5050
export const DAEMON_HOST = 'localhost';
5151
export const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
52-
export const DAEMON_HTTP_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
52+
/** Lightweight health-check endpoint — probed before each WebSocket attempt. */
53+
export const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`;
5354

5455
/** Base reconnect delay for extension WebSocket (ms) */
5556
export const WS_RECONNECT_BASE_DELAY = 2000;

src/daemon.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,22 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
102102
return;
103103
}
104104

105-
// Require custom header on all HTTP requests. Browsers cannot attach
105+
const url = req.url ?? '/';
106+
const pathname = url.split('?')[0];
107+
108+
// Health-check endpoint — no X-OpenCLI header required.
109+
// Used by the extension to silently probe daemon reachability before
110+
// attempting a WebSocket connection (avoids uncatchable ERR_CONNECTION_REFUSED).
111+
// Security note: this endpoint is reachable by any client that passes the
112+
// origin check above (chrome-extension:// or no Origin header, e.g. curl).
113+
// Timing side-channels can reveal daemon presence to local processes, which
114+
// is an accepted risk given the daemon is loopback-only and short-lived.
115+
if (req.method === 'GET' && pathname === '/ping') {
116+
jsonResponse(res, 200, { ok: true });
117+
return;
118+
}
119+
120+
// Require custom header on all other HTTP requests. Browsers cannot attach
106121
// custom headers in "simple" requests, and our preflight returns no
107122
// Access-Control-Allow-Headers, so scripted fetch() from web pages is
108123
// blocked even if Origin check is somehow bypassed.
@@ -111,9 +126,6 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
111126
return;
112127
}
113128

114-
const url = req.url ?? '/';
115-
const pathname = url.split('?')[0];
116-
117129
if (req.method === 'GET' && pathname === '/status') {
118130
jsonResponse(res, 200, {
119131
ok: true,

0 commit comments

Comments
 (0)