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/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..5f194674 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -38,6 +38,64 @@ .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; + } + .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; @@ -73,6 +131,17 @@

OpenCLI

Checking... +
+ + + + + +
+
+ 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 4bd3a7d4..909d5792 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -1,5 +1,47 @@ -// Query connection status from background service worker -chrome.runtime.sendMessage({ type: 'getStatus' }, (resp) => { +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'); const status = document.getElementById('status'); const hint = document.getElementById('hint'); @@ -22,4 +64,49 @@ 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 = normalizeHost(stored.daemonHost); + 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 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'; + 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.test.ts b/extension/src/background.test.ts index f9368251..a0854c27 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -14,21 +14,44 @@ 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?.(); } } +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 = { + 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 +114,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 +136,7 @@ describe('background tab isolation', () => { vi.resetModules(); vi.useRealTimers(); vi.stubGlobal('WebSocket', MockWebSocket); + MockWebSocket.instances = []; }); afterEach(() => { @@ -237,4 +270,74 @@ 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, + 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 36e3861b..14019c0e 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -6,12 +6,33 @@ */ 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, + normalizeDaemonHost, +} from './protocol'; import * as executor from './cdp'; +const STORAGE_KEYS = { host: 'daemonHost', port: 'daemonPort' } as const; + let ws: WebSocket | 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({ + [STORAGE_KEYS.host]: DEFAULT_DAEMON_HOST, + [STORAGE_KEYS.port]: DEFAULT_DAEMON_PORT, + }); + 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 }; +} // ─── Console log forwarding ────────────────────────────────────────── // Hook console.log/warn/error to forward logs to daemon via WebSocket. @@ -42,52 +63,71 @@ 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 attemptId = ++connectAttemptId; + const { host, port } = await getDaemonSettings(); + if (attemptId !== connectAttemptId) return; + const { ping: pingUrl, ws: wsUrl } = buildDaemonEndpoints(host, port); + + if (ws) { + if (ws.url === wsUrl && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; + const previousSocket = ws; + ws = null; + try { + previousSocket.close(); + } catch { /* ignore */ } + } try { - const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1000) }); + 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 { - ws = new WebSocket(DAEMON_WS_URL); - } catch { - scheduleReconnect(); - return; - } + const socket = new WebSocket(wsUrl); + ws = socket; + + socket.onopen = () => { + if (ws !== socket) return; + console.log('[opencli] Connected to daemon'); + 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'); - 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; + scheduleReconnect(); + }; - ws.onclose = () => { - console.log('[opencli] Disconnected from daemon'); - ws = null; + socket.onerror = () => { + if (ws !== socket) return; + socket.close(); + }; + } catch { scheduleReconnect(); - }; - - ws.onerror = () => { - ws?.close(); - }; + return; + } } /** @@ -218,14 +258,35 @@ 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; + const previousSocket = ws; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + ws = null; + try { + previousSocket?.close(); + } catch { /* ignore */ } + 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; }); @@ -664,11 +725,20 @@ async function handleSessions(cmd: Command): Promise { } export const __test__ = { + connect, handleNavigate, isTargetUrl, handleTabs, handleSessions, resolveTabId, + getConnectionState: () => ({ + ws, + wsUrl: ws?.url ?? null, + readyState: ws?.readyState ?? null, + reconnectAttempts, + connectAttemptId, + 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 381761c2..fda20da7 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -53,12 +53,49 @@ 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; + +/** 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 = normalizeDaemonHost(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;