From 8b5ed401913d875d2c907fd3a2cb7653f68b9b2c Mon Sep 17 00:00:00 2001 From: LiYunhang Date: Wed, 8 Apr 2026 23:06:02 +0800 Subject: [PATCH] feat(daemon): add configurable daemon endpoint --- README.md | 2 +- README.zh-CN.md | 18 +++- bun.lock | 14 +-- docs/guide/browser-bridge.md | 21 +++++ docs/guide/troubleshooting.md | 3 +- extension/dist/background.js | 129 ++++++++++++++++++++++++---- extension/manifest.json | 3 +- extension/popup.html | 55 ++++++++++++ extension/popup.js | 48 +++++++++-- extension/src/background.test.ts | 6 ++ extension/src/background.ts | 82 ++++++++++++++++-- extension/src/cdp.ts | 30 +++++-- extension/src/daemon-config.test.ts | 50 +++++++++++ extension/src/daemon-config.ts | 45 ++++++++++ extension/src/protocol.ts | 7 -- src/browser/bridge.ts | 4 +- src/browser/daemon-client.test.ts | 3 + src/browser/daemon-client.ts | 8 +- src/browser/errors.ts | 8 +- src/cli.ts | 21 ++++- src/commands/daemon.test.ts | 67 ++++++++++++++- src/commands/daemon.ts | 45 ++++++++++ src/daemon-config.test.ts | 77 +++++++++++++++++ src/daemon-config.ts | 82 ++++++++++++++++++ src/daemon.ts | 18 ++-- src/doctor.ts | 7 +- 26 files changed, 781 insertions(+), 72 deletions(-) create mode 100644 extension/src/daemon-config.test.ts create mode 100644 extension/src/daemon-config.ts create mode 100644 src/daemon-config.test.ts create mode 100644 src/daemon-config.ts diff --git a/README.md b/README.md index 7966823ee..ddd61a497 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ See **[TESTING.md](./TESTING.md)** for how to run and write tests. - **"attach failed: Cannot access a chrome-extension:// URL"** — Another extension may be interfering. Try disabling other extensions temporarily. - **Empty data or 'Unauthorized' error** — Your Chrome/Chromium login session may have expired. Navigate to the target site and log in again. - **Node API errors** — Ensure Node.js >= 20. Some dependencies require modern Node APIs. -- **Daemon issues** — Check status: `curl localhost:19825/status` · View logs: `curl localhost:19825/logs` +- **Daemon issues** — Check status: `opencli daemon status` · View logs: `curl http://127.0.0.1:19825/logs` ## Star History diff --git a/README.zh-CN.md b/README.zh-CN.md index c4e40986d..85a230262 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -56,6 +56,20 @@ OpenCLI 通过轻量化的 **Browser Bridge** Chrome/Chromium 扩展 + 微型 da 完成!运行任何 opencli 浏览器命令时,后台微型 daemon 会自动启动与浏览器通信。无需配 API Token,零代码配置。 +如果你需要自定义 daemon 的监听地址或端口,可以新建 `~/.opencli/daemon.yaml`: + +```yaml +host: 127.0.0.1 +port: 19825 +``` + +- `host`:daemon 监听地址 +- `port`:daemon 监听端口 + +如果 `host` 配成 `0.0.0.0`,CLI 会自动回退用 `127.0.0.1` 连接。 + +浏览器扩展弹窗里也可以单独设置它要连接的 daemon 地址和端口。 + > **Tip**:后续诊断和 daemon 管理: > ```bash > opencli doctor # 检查扩展和 daemon 连通性 @@ -406,8 +420,8 @@ opencli cascade https://api.example.com/data - **Node API 错误 (如 parseArgs, fs 等)** - 确保 Node.js 版本 `>= 20`。 - **Daemon 问题** - - 检查 daemon 状态:`curl localhost:19825/status` - - 查看扩展日志:`curl localhost:19825/logs` + - 检查 daemon 状态:`opencli daemon status` + - 查看扩展日志:`curl http://127.0.0.1:19825/logs` ## Star History diff --git a/bun.lock b/bun.lock index 87de32bf4..1740b95a1 100644 --- a/bun.lock +++ b/bun.lock @@ -10,12 +10,12 @@ "commander": "^14.0.3", "js-yaml": "^4.1.0", "turndown": "^7.2.2", - "undici": "^7.24.6", + "undici": "^8.0.2", "ws": "^8.18.0", }, "devDependencies": { "@types/js-yaml": "^4.0.9", - "@types/node": "^22.13.10", + "@types/node": "^25.5.2", "@types/turndown": "^5.0.6", "@types/ws": "^8.5.13", "tsx": "^4.19.3", @@ -268,7 +268,7 @@ "@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="], - "@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], + "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], "@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="], @@ -524,9 +524,9 @@ "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], - "undici": ["undici@7.24.6", "", {}, "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA=="], + "undici": ["undici@8.0.2", "", {}, "sha512-B9MeU5wuFhkFAuNeA19K2GDFcQXZxq33fL0nRy2Aq30wdufZbyyvxW3/ChaeipXVfy/wUweZyzovQGk39+9k2w=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], @@ -556,6 +556,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@types/ws/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], + "@vitest/mocker/vite": ["vite@8.0.2", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@vitejs/devtools", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "yaml"], "bin": "bin/vite.js" }, "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA=="], "@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], @@ -566,6 +568,8 @@ "vitest/vite": ["vite@8.0.2", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@vitejs/devtools", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "yaml"], "bin": "bin/vite.js" }, "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA=="], + "@types/ws/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], diff --git a/docs/guide/browser-bridge.md b/docs/guide/browser-bridge.md index 2fac087f9..0d5b1a399 100644 --- a/docs/guide/browser-bridge.md +++ b/docs/guide/browser-bridge.md @@ -47,3 +47,24 @@ 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. + +To customize the daemon bind address and port persistently, create `~/.opencli/daemon.yaml`: + +```yaml +host: 127.0.0.1 +port: 19825 +``` + +- `host`: the address the daemon listens on +- `port`: the daemon HTTP/WebSocket port + +If `host` is set to `0.0.0.0`, the CLI automatically connects via `127.0.0.1`. + +Environment variables still work and take precedence: + +```bash +OPENCLI_DAEMON_HOST=0.0.0.0 +OPENCLI_DAEMON_PORT=29876 +``` + +The browser extension popup also lets you set the daemon host and port it should connect to. diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index 9cb7b528b..150a2a3e5 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -35,7 +35,7 @@ OPENCLI_CDP_TARGET=detail.1688.com opencli 1688 item 841141931191 -f json opencli daemon status # View extension logs -curl localhost:19825/logs +curl http://127.0.0.1:19825/logs # Stop or restart the daemon opencli daemon stop @@ -46,6 +46,7 @@ opencli doctor ``` > The daemon auto-exits after 4 hours of inactivity (no CLI requests and no extension connection). Override with `OPENCLI_DAEMON_TIMEOUT` (milliseconds, `0` = never timeout). +> If you changed the daemon address or port, use the values from `~/.opencli/daemon.yaml` or `opencli daemon status`. ### Desktop adapter connection issues diff --git a/extension/dist/background.js b/extension/dist/background.js index bd1caa221..03acc0b93 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -1,10 +1,39 @@ -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 = 5e3; +const DEFAULT_DAEMON_HOST = "127.0.0.1"; +const DEFAULT_DAEMON_PORT = 19825; +function normalizeHost(value) { + return typeof value === "string" && value.trim() ? value.trim() : void 0; +} +function normalizePort(value) { + if (typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number.parseInt(value, 10); + if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) return parsed; + } + return void 0; +} +async function getDaemonEndpointConfig(storage = chrome.storage?.local) { + if (!storage) { + return { + host: DEFAULT_DAEMON_HOST, + port: DEFAULT_DAEMON_PORT + }; + } + const raw = await storage.get(["daemonHost", "daemonPort"]); + return { + host: normalizeHost(raw.daemonHost) ?? DEFAULT_DAEMON_HOST, + port: normalizePort(raw.daemonPort) ?? DEFAULT_DAEMON_PORT + }; +} +function buildDaemonUrls(config) { + return { + pingUrl: `http://${config.host}:${config.port}/ping`, + wsUrl: `ws://${config.host}:${config.port}/ext` + }; +} + const attached = /* @__PURE__ */ new Set(); const networkCaptures = /* @__PURE__ */ new Map(); function isDebuggableUrl$1(url) { @@ -248,8 +277,9 @@ function registerListeners() { const state = networkCaptures.get(tabId); if (!state) return; if (method === "Network.requestWillBeSent") { - const requestId = String(params?.requestId || ""); - const request = params?.request; + const networkParams = params; + const requestId = String(networkParams?.requestId || ""); + const request = networkParams?.request; const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { url: request?.url, method: request?.method, @@ -269,8 +299,9 @@ function registerListeners() { return; } if (method === "Network.responseReceived") { - const requestId = String(params?.requestId || ""); - const response = params?.response; + const networkParams = params; + const requestId = String(networkParams?.requestId || ""); + const response = networkParams?.response; const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { url: response?.url }); @@ -281,7 +312,8 @@ function registerListeners() { return; } if (method === "Network.loadingFinished") { - const requestId = String(params?.requestId || ""); + const networkParams = params; + const requestId = String(networkParams?.requestId || ""); const stateEntryIndex = state.requestToIndex.get(requestId); if (stateEntryIndex === void 0) return; const entry = state.entries[stateEntryIndex]; @@ -325,14 +357,16 @@ console.error = (...args) => { }; async function connect() { if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + const endpoint = await getDaemonEndpointConfig(); + const urls = buildDaemonUrls(endpoint); try { - const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) }); + const res = await fetch(urls.pingUrl, { signal: AbortSignal.timeout(1e3) }); if (!res.ok) return; } catch { return; } try { - ws = new WebSocket(DAEMON_WS_URL); + ws = new WebSocket(urls.wsUrl); } catch { scheduleReconnect(); return; @@ -479,10 +513,71 @@ chrome.alarms.onAlarm.addListener((alarm) => { }); chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg?.type === "getStatus") { - sendResponse({ - connected: ws?.readyState === WebSocket.OPEN, - reconnecting: reconnectTimer !== null + void getDaemonEndpointConfig().then((endpoint) => { + sendResponse({ + connected: ws?.readyState === WebSocket.OPEN, + reconnecting: reconnectTimer !== null, + host: endpoint.host, + port: endpoint.port + }); + }); + return true; + } + if (msg?.type === "getDaemonConfig") { + void getDaemonEndpointConfig().then((endpoint) => { + sendResponse(endpoint); + }); + return true; + } + if (msg?.type === "setDaemonConfig") { + const nextHost = typeof msg.host === "string" ? msg.host.trim() : ""; + const nextPort = Number.parseInt(String(msg.port ?? ""), 10); + const updates = {}; + const removals = []; + if (nextHost) updates.daemonHost = nextHost; + else removals.push("daemonHost"); + if (String(msg.port ?? "").trim()) { + if (!Number.isInteger(nextPort) || nextPort <= 0 || nextPort > 65535) { + sendResponse({ ok: false, error: "Invalid port" }); + return true; + } + updates.daemonPort = nextPort; + } else { + removals.push("daemonPort"); + } + void Promise.resolve().then(async () => { + if (Object.keys(updates).length > 0) { + await chrome.storage.local.set(updates); + } + if (removals.length > 0) { + await chrome.storage.local.remove(removals); + } + const endpoint = await getDaemonEndpointConfig(); + if (ws) { + try { + ws.close(); + } catch { + ws = null; + } + } else { + ws = null; + } + reconnectTimer = null; + void connect(); + sendResponse({ + ok: true, + host: endpoint.host, + port: endpoint.port, + connected: false, + reconnecting: true + }); + }).catch((err) => { + sendResponse({ + ok: false, + error: err instanceof Error ? err.message : String(err) + }); }); + return true; } return false; }); @@ -609,7 +704,7 @@ async function resolveTab(tabId, workspace, initialUrl) { } } const existingSession = automationSessions.get(workspace); - if (existingSession?.preferredTabId !== null) { + if (existingSession && existingSession.preferredTabId !== null) { try { const preferredTab = await chrome.tabs.get(existingSession.preferredTabId); if (isDebuggableUrl(preferredTab.url)) return { tabId: preferredTab.id, tab: preferredTab }; @@ -666,7 +761,7 @@ 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 aggressive = workspace.startsWith("operate:"); + const aggressive = workspace.startsWith("browser:") || workspace.startsWith("operate:"); const data = await evaluateAsync(tabId, cmd.code, aggressive); return { id: cmd.id, ok: true, data }; } catch (err) { @@ -870,7 +965,7 @@ async function handleCdp(cmd, workspace) { } const tabId = await resolveTabId(cmd.tabId, workspace); try { - const aggressive = workspace.startsWith("operate:"); + const aggressive = workspace.startsWith("browser:") || workspace.startsWith("operate:"); await ensureAttached(tabId, aggressive); const data = await chrome.debugger.sendCommand( { tabId }, diff --git a/extension/manifest.json b/extension/manifest.json index a630b554e..6e87f878b 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -8,7 +8,8 @@ "tabs", "cookies", "activeTab", - "alarms" + "alarms", + "storage" ], "host_permissions": [ "" diff --git a/extension/popup.html b/extension/popup.html index 02ca1b972..042c7ef60 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -60,6 +60,47 @@ font-size: 11px; color: #999; } + .config { + margin-top: 12px; + display: grid; + gap: 8px; + } + .config-row { + display: grid; + gap: 4px; + } + .config-row label { + font-size: 11px; + color: #666; + } + .config-row input { + width: 100%; + border: 1px solid #d8dde6; + border-radius: 6px; + padding: 7px 8px; + font-size: 12px; + } + .actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 2px; + } + .actions button { + border: 0; + border-radius: 6px; + padding: 7px 10px; + background: #007aff; + color: #fff; + font-size: 12px; + cursor: pointer; + } + .save-status { + font-size: 11px; + color: #666; + min-height: 14px; + } .footer a { color: #007aff; text-decoration: none; } .footer a:hover { text-decoration: underline; } @@ -76,6 +117,20 @@

OpenCLI

This is normal. The extension connects automatically when you run any opencli command.
+
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/extension/popup.js b/extension/popup.js index 4bd3a7d48..5782a5bed 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -1,25 +1,61 @@ -// Query connection status from background service worker -chrome.runtime.sendMessage({ type: 'getStatus' }, (resp) => { +function renderStatus(resp) { const dot = document.getElementById('dot'); const status = document.getElementById('status'); const hint = document.getElementById('hint'); - if (chrome.runtime.lastError || !resp) { + const hostInput = document.getElementById('daemon-host'); + const portInput = document.getElementById('daemon-port'); + if (!resp) { dot.className = 'dot disconnected'; status.innerHTML = 'No daemon connected'; hint.style.display = 'block'; return; } + hostInput.value = resp.host || '127.0.0.1'; + portInput.value = String(resp.port || 19825); if (resp.connected) { dot.className = 'dot connected'; - status.innerHTML = 'Connected to daemon'; + status.innerHTML = `Connected to ${resp.host}:${resp.port}`; hint.style.display = 'none'; } else if (resp.reconnecting) { dot.className = 'dot connecting'; - status.innerHTML = 'Reconnecting...'; + status.innerHTML = `Reconnecting to ${resp.host}:${resp.port}...`; hint.style.display = 'none'; } else { dot.className = 'dot disconnected'; - status.innerHTML = 'No daemon connected'; + status.innerHTML = `No daemon connected (${resp.host}:${resp.port})`; hint.style.display = 'block'; } +} + +function refreshStatus() { + chrome.runtime.sendMessage({ type: 'getStatus' }, (resp) => { + if (chrome.runtime.lastError || !resp) { + renderStatus(null); + return; + } + renderStatus(resp); + }); +} + +refreshStatus(); + +document.getElementById('save').addEventListener('click', () => { + const host = document.getElementById('daemon-host').value.trim(); + const port = document.getElementById('daemon-port').value.trim(); + const saveStatus = document.getElementById('save-status'); + const saveButton = document.getElementById('save'); + saveStatus.textContent = 'Saving...'; + saveButton.disabled = true; + chrome.runtime.sendMessage({ type: 'setDaemonConfig', host, port }, (resp) => { + saveButton.disabled = false; + if (chrome.runtime.lastError || !resp?.ok) { + saveStatus.textContent = resp?.error || 'Save failed'; + return; + } + saveStatus.textContent = 'Saved'; + renderStatus(resp); + setTimeout(() => { + refreshStatus(); + }, 300); + }); }); diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index f15964f14..a9fec9c6c 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -100,6 +100,12 @@ function createChromeMock() { cookies: { getAll: vi.fn(async () => []), }, + storage: { + local: { + get: vi.fn(async () => ({})), + set: vi.fn(async () => {}), + }, + }, }; return { chrome, tabs, query, create, update }; diff --git a/extension/src/background.ts b/extension/src/background.ts index 5544781ed..4f8f5096c 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -6,7 +6,8 @@ */ import type { Command, Result } from './protocol'; -import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; +import { WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; +import { buildDaemonUrls, getDaemonEndpointConfig } from './daemon-config'; import * as executor from './cdp'; let ws: WebSocket | null = null; @@ -43,16 +44,18 @@ console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error */ async function connect(): Promise { if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + const endpoint = await getDaemonEndpointConfig(); + const urls = buildDaemonUrls(endpoint); try { - const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1000) }); + const res = await fetch(urls.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); + ws = new WebSocket(urls.wsUrl); } catch { scheduleReconnect(); return; @@ -254,10 +257,75 @@ chrome.alarms.onAlarm.addListener((alarm) => { chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg?.type === 'getStatus') { - sendResponse({ - connected: ws?.readyState === WebSocket.OPEN, - reconnecting: reconnectTimer !== null, + void getDaemonEndpointConfig().then((endpoint) => { + sendResponse({ + connected: ws?.readyState === WebSocket.OPEN, + reconnecting: reconnectTimer !== null, + host: endpoint.host, + port: endpoint.port, + }); }); + return true; + } + + if (msg?.type === 'getDaemonConfig') { + void getDaemonEndpointConfig().then((endpoint) => { + sendResponse(endpoint); + }); + return true; + } + + if (msg?.type === 'setDaemonConfig') { + const nextHost = typeof msg.host === 'string' ? msg.host.trim() : ''; + const nextPort = Number.parseInt(String(msg.port ?? ''), 10); + const updates: Record = {}; + const removals: string[] = []; + if (nextHost) updates.daemonHost = nextHost; + else removals.push('daemonHost'); + if (String(msg.port ?? '').trim()) { + if (!Number.isInteger(nextPort) || nextPort <= 0 || nextPort > 65535) { + sendResponse({ ok: false, error: 'Invalid port' }); + return true; + } + updates.daemonPort = nextPort; + } else { + removals.push('daemonPort'); + } + void Promise.resolve() + .then(async () => { + if (Object.keys(updates).length > 0) { + await chrome.storage.local.set(updates); + } + if (removals.length > 0) { + await chrome.storage.local.remove(removals); + } + const endpoint = await getDaemonEndpointConfig(); + if (ws) { + try { + ws.close(); + } catch { + ws = null; + } + } else { + ws = null; + } + reconnectTimer = null; + void connect(); + sendResponse({ + ok: true, + host: endpoint.host, + port: endpoint.port, + connected: false, + reconnecting: true, + }); + }) + .catch((err) => { + sendResponse({ + ok: false, + error: err instanceof Error ? err.message : String(err), + }); + }); + return true; } return false; }); @@ -415,7 +483,7 @@ async function resolveTab(tabId: number | undefined, workspace: string, initialU } const existingSession = automationSessions.get(workspace); - if (existingSession?.preferredTabId !== null) { + if (existingSession && existingSession.preferredTabId !== null) { try { const preferredTab = await chrome.tabs.get(existingSession.preferredTabId); if (isDebuggableUrl(preferredTab.url)) return { tabId: preferredTab.id!, tab: preferredTab }; diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts index 8345b5951..a2244a5dd 100644 --- a/extension/src/cdp.ts +++ b/extension/src/cdp.ts @@ -370,8 +370,18 @@ export function registerListeners(): void { if (!state) return; if (method === 'Network.requestWillBeSent') { - const requestId = String(params?.requestId || ''); - const request = params?.request as { + const networkParams = params as { + requestId?: string; + request?: { + url?: string; + method?: string; + headers?: Record; + postData?: string; + hasPostData?: boolean; + }; + } | undefined; + const requestId = String(networkParams?.requestId || ''); + const request = networkParams?.request as { url?: string; method?: string; headers?: Record; @@ -399,8 +409,17 @@ export function registerListeners(): void { } if (method === 'Network.responseReceived') { - const requestId = String(params?.requestId || ''); - const response = params?.response as { + const networkParams = params as { + requestId?: string; + response?: { + url?: string; + mimeType?: string; + status?: number; + headers?: Record; + }; + } | undefined; + const requestId = String(networkParams?.requestId || ''); + const response = networkParams?.response as { url?: string; mimeType?: string; status?: number; @@ -417,7 +436,8 @@ export function registerListeners(): void { } if (method === 'Network.loadingFinished') { - const requestId = String(params?.requestId || ''); + const networkParams = params as { requestId?: string } | undefined; + const requestId = String(networkParams?.requestId || ''); const stateEntryIndex = state.requestToIndex.get(requestId); if (stateEntryIndex === undefined) return; const entry = state.entries[stateEntryIndex]; diff --git a/extension/src/daemon-config.test.ts b/extension/src/daemon-config.test.ts new file mode 100644 index 000000000..aa125fc63 --- /dev/null +++ b/extension/src/daemon-config.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + DEFAULT_DAEMON_HOST, + DEFAULT_DAEMON_PORT, + buildDaemonUrls, + getDaemonEndpointConfig, +} from './daemon-config'; + +describe('extension daemon-config', () => { + it('returns defaults when storage is empty', async () => { + const storage = { + get: vi.fn(async () => ({})), + }; + + await expect(getDaemonEndpointConfig(storage as any)).resolves.toEqual({ + host: DEFAULT_DAEMON_HOST, + port: DEFAULT_DAEMON_PORT, + }); + }); + + it('uses stored host and port when present', async () => { + const storage = { + get: vi.fn(async () => ({ daemonHost: '192.168.1.8', daemonPort: 29999 })), + }; + + await expect(getDaemonEndpointConfig(storage as any)).resolves.toEqual({ + host: '192.168.1.8', + port: 29999, + }); + }); + + it('ignores invalid stored values', async () => { + const storage = { + get: vi.fn(async () => ({ daemonHost: '', daemonPort: 'abc' })), + }; + + await expect(getDaemonEndpointConfig(storage as any)).resolves.toEqual({ + host: DEFAULT_DAEMON_HOST, + port: DEFAULT_DAEMON_PORT, + }); + }); + + it('builds ping and websocket urls from the endpoint config', () => { + expect(buildDaemonUrls({ host: 'daemon.internal', port: 28888 })).toEqual({ + pingUrl: 'http://daemon.internal:28888/ping', + wsUrl: 'ws://daemon.internal:28888/ext', + }); + }); +}); diff --git a/extension/src/daemon-config.ts b/extension/src/daemon-config.ts new file mode 100644 index 000000000..767ecaec7 --- /dev/null +++ b/extension/src/daemon-config.ts @@ -0,0 +1,45 @@ +export const DEFAULT_DAEMON_HOST = '127.0.0.1'; +export const DEFAULT_DAEMON_PORT = 19825; + +export interface DaemonEndpointConfig { + host: string; + port: number; +} + +type StorageLike = Pick; + +function normalizeHost(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function normalizePort(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 65535) return value; + if (typeof value === 'string' && value.trim()) { + const parsed = Number.parseInt(value, 10); + if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) return parsed; + } + return undefined; +} + +export async function getDaemonEndpointConfig( + storage: StorageLike | undefined = chrome.storage?.local, +): Promise { + if (!storage) { + return { + host: DEFAULT_DAEMON_HOST, + port: DEFAULT_DAEMON_PORT, + }; + } + const raw = await storage.get(['daemonHost', 'daemonPort']); + return { + host: normalizeHost(raw.daemonHost) ?? DEFAULT_DAEMON_HOST, + port: normalizePort(raw.daemonPort) ?? DEFAULT_DAEMON_PORT, + }; +} + +export function buildDaemonUrls(config: DaemonEndpointConfig): { pingUrl: string; wsUrl: string } { + return { + pingUrl: `http://${config.host}:${config.port}/ping`, + wsUrl: `ws://${config.host}:${config.port}/ext`, + }; +} diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index 3ed5ce86c..d865d8c68 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -74,13 +74,6 @@ export interface Result { error?: string; } -/** 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`; - /** Base reconnect delay for extension WebSocket (ms) */ export const WS_RECONNECT_BASE_DELAY = 2000; /** Max reconnect delay (ms) — kept short since daemon is long-lived */ diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index 451256837..12bacd2b6 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -8,9 +8,9 @@ import * as path from 'node:path'; import * as fs from 'node:fs'; import type { IPage } from '../types.js'; import type { IBrowserFactory } from '../runtime.js'; +import { resolveDaemonConfig } from '../daemon-config.js'; import { Page } from './page.js'; import { getDaemonHealth } from './daemon-client.js'; -import { DEFAULT_DAEMON_PORT } from '../constants.js'; import { BrowserConnectError } from '../errors.js'; const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension @@ -123,7 +123,7 @@ export class BrowserBridge implements IBrowserFactory { throw new BrowserConnectError( 'Failed to start opencli daemon', - `Try running manually:\n node ${daemonPath}\nMake sure port ${DEFAULT_DAEMON_PORT} is available.`, + `Try running manually:\n node ${daemonPath}\nMake sure ${resolveDaemonConfig().host}:${resolveDaemonConfig().port} is available.`, 'daemon-not-running', ); } diff --git a/src/browser/daemon-client.test.ts b/src/browser/daemon-client.test.ts index 7aadfe393..905d41073 100644 --- a/src/browser/daemon-client.test.ts +++ b/src/browser/daemon-client.test.ts @@ -25,6 +25,7 @@ describe('daemon-client', () => { pending: 0, lastCliRequestTime: Date.now(), memoryMB: 32, + host: '127.0.0.1', port: 19825, }; const fetchMock = vi.mocked(fetch); @@ -77,6 +78,7 @@ describe('daemon-client', () => { pending: 0, lastCliRequestTime: Date.now(), memoryMB: 16, + host: '127.0.0.1', port: 19825, }; vi.mocked(fetch).mockResolvedValue({ @@ -97,6 +99,7 @@ describe('daemon-client', () => { pending: 0, lastCliRequestTime: Date.now(), memoryMB: 32, + host: '127.0.0.1', port: 19825, }; vi.mocked(fetch).mockResolvedValue({ diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index b01b94b5a..65325724f 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -4,13 +4,11 @@ * Provides a typed send() function that posts a Command and returns a Result. */ -import { DEFAULT_DAEMON_PORT } from '../constants.js'; +import { getDaemonBaseUrl, resolveDaemonConfig } from '../daemon-config.js'; import type { BrowserSessionInfo } from '../types.js'; import { sleep } from '../utils.js'; import { isTransientBrowserError } from './errors.js'; -const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10); -const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`; const OPENCLI_HEADERS = { 'X-OpenCLI': '1' }; let _idCounter = 0; @@ -63,15 +61,17 @@ export interface DaemonStatus { pending: number; lastCliRequestTime: number; memoryMB: number; + host: string; port: number; } async function requestDaemon(pathname: string, init?: RequestInit & { timeout?: number }): Promise { + const daemonUrl = getDaemonBaseUrl(resolveDaemonConfig()); const { timeout = 2000, headers, ...rest } = init ?? {}; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { - return await fetch(`${DAEMON_URL}${pathname}`, { + return await fetch(`${daemonUrl}${pathname}`, { ...rest, headers: { ...OPENCLI_HEADERS, ...headers }, signal: controller.signal, diff --git a/src/browser/errors.ts b/src/browser/errors.ts index 39c571b41..fc67b7bd1 100644 --- a/src/browser/errors.ts +++ b/src/browser/errors.ts @@ -6,7 +6,7 @@ */ import { BrowserConnectError, type BrowserConnectKind } from '../errors.js'; -import { DEFAULT_DAEMON_PORT } from '../constants.js'; +import { resolveDaemonConfig } from '../daemon-config.js'; /** * Transient browser error patterns — shared across daemon-client, pipeline executor, @@ -35,12 +35,14 @@ export type ConnectFailureKind = BrowserConnectKind; export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: string): BrowserConnectError { switch (kind) { - case 'daemon-not-running': + case 'daemon-not-running': { + const daemon = resolveDaemonConfig(); return new BrowserConnectError( 'Cannot connect to opencli daemon.' + (detail ? `\n\n${detail}` : ''), - `The daemon should auto-start. If it keeps failing, make sure port ${DEFAULT_DAEMON_PORT} is available.`, + `The daemon should auto-start. If it keeps failing, make sure ${daemon.host}:${daemon.port} is available.`, kind, ); + } case 'extension-not-connected': return new BrowserConnectError( 'Browser Bridge extension is not connected.' + (detail ? `\n\n${detail}` : ''), diff --git a/src/cli.ts b/src/cli.ts index 0dbfa7c30..4fdda95ad 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,7 +20,7 @@ import { printCompletionScript } from './completion.js'; import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js'; import { registerAllCommands } from './commanderAdapter.js'; import { EXIT_CODES, getErrorMessage } from './errors.js'; -import { daemonStatus, daemonStop, daemonRestart } from './commands/daemon.js'; +import { daemonStatus, daemonStop, daemonRestart, daemonConfigGet, daemonConfigSet, daemonConfigUnset } from './commands/daemon.js'; const CLI_FILE = fileURLToPath(import.meta.url); @@ -946,6 +946,25 @@ cli({ .command('restart') .description('Restart the daemon') .action(async () => { await daemonRestart(); }); + const daemonConfigCmd = daemonCmd + .command('config') + .description('Read or update daemon config'); + daemonConfigCmd + .command('get') + .description('Show configured daemon host and port') + .action(() => { daemonConfigGet(); }); + daemonConfigCmd + .command('set') + .description('Set daemon host and port') + .option('--host ', 'Daemon listen host') + .option('--port ', 'Daemon listen port') + .action((opts) => { daemonConfigSet(opts); }); + daemonConfigCmd + .command('unset') + .description('Remove daemon host and/or port from config') + .option('--host', 'Remove configured host') + .option('--port', 'Remove configured port') + .action((opts) => { daemonConfigUnset(opts); }); // ── External CLIs ───────────────────────────────────────────────────────── diff --git a/src/commands/daemon.test.ts b/src/commands/daemon.test.ts index fd66cceda..d9c922e6c 100644 --- a/src/commands/daemon.test.ts +++ b/src/commands/daemon.test.ts @@ -3,9 +3,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; const { fetchDaemonStatusMock, requestDaemonShutdownMock, + loadDaemonConfigMock, + saveDaemonConfigMock, + getDaemonConfigPathMock, } = vi.hoisted(() => ({ fetchDaemonStatusMock: vi.fn(), requestDaemonShutdownMock: vi.fn(), + loadDaemonConfigMock: vi.fn(), + saveDaemonConfigMock: vi.fn(), + getDaemonConfigPathMock: vi.fn(() => '/tmp/.opencli/daemon.yaml'), })); vi.mock('chalk', () => ({ @@ -29,7 +35,13 @@ vi.mock('../browser/daemon-client.js', () => ({ requestDaemonShutdown: requestDaemonShutdownMock, })); -import { daemonStatus, daemonStop, daemonRestart } from './daemon.js'; +vi.mock('../daemon-config.js', () => ({ + loadDaemonConfig: loadDaemonConfigMock, + saveDaemonConfig: saveDaemonConfigMock, + getDaemonConfigPath: getDaemonConfigPathMock, +})); + +import { daemonStatus, daemonStop, daemonRestart, daemonConfigGet, daemonConfigSet, daemonConfigUnset } from './daemon.js'; describe('daemon commands', () => { let logSpy: ReturnType; @@ -40,6 +52,8 @@ describe('daemon commands', () => { errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); fetchDaemonStatusMock.mockReset(); requestDaemonShutdownMock.mockReset(); + loadDaemonConfigMock.mockReset(); + saveDaemonConfigMock.mockReset(); }); afterEach(() => { @@ -73,6 +87,7 @@ describe('daemon commands', () => { pending: 0, lastCliRequestTime: Date.now() - 30_000, memoryMB: 64, + host: '127.0.0.1', port: 19825, }; @@ -85,6 +100,7 @@ describe('daemon commands', () => { expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('1h 1m')); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('connected')); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('64 MB')); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('127.0.0.1')); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('19825')); }); @@ -97,6 +113,7 @@ describe('daemon commands', () => { pending: 0, lastCliRequestTime: Date.now() - 5000, memoryMB: 32, + host: '127.0.0.1', port: 19825, }; @@ -126,6 +143,7 @@ describe('daemon commands', () => { pending: 0, lastCliRequestTime: Date.now(), memoryMB: 50, + host: '127.0.0.1', port: 19825, }); requestDaemonShutdownMock.mockResolvedValue(true); @@ -145,6 +163,7 @@ describe('daemon commands', () => { pending: 0, lastCliRequestTime: Date.now(), memoryMB: 50, + host: '127.0.0.1', port: 19825, }); requestDaemonShutdownMock.mockResolvedValue(false); @@ -164,6 +183,7 @@ describe('daemon commands', () => { pending: 0, lastCliRequestTime: Date.now(), memoryMB: 50, + host: '127.0.0.1', port: 19825, }; @@ -201,4 +221,49 @@ describe('daemon commands', () => { expect(mockConnect).not.toHaveBeenCalled(); }); }); + + describe('daemon config', () => { + it('shows missing config when unset', () => { + loadDaemonConfigMock.mockReturnValue({}); + + daemonConfigGet(); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('No daemon config found')); + }); + + it('shows configured host and port', () => { + loadDaemonConfigMock.mockReturnValue({ host: '0.0.0.0', port: 29876 }); + + daemonConfigGet(); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('/tmp/.opencli/daemon.yaml')); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('0.0.0.0')); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('29876')); + }); + + it('writes updated daemon config', () => { + loadDaemonConfigMock.mockReturnValue({ host: '127.0.0.1' }); + + daemonConfigSet({ port: '29876' }); + + expect(saveDaemonConfigMock).toHaveBeenCalledWith({ host: '127.0.0.1', port: 29876 }); + }); + + it('rejects invalid port values', () => { + loadDaemonConfigMock.mockReturnValue({}); + + daemonConfigSet({ port: 'abc' }); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid port')); + expect(saveDaemonConfigMock).not.toHaveBeenCalled(); + }); + + it('unsets selected keys from daemon config', () => { + loadDaemonConfigMock.mockReturnValue({ host: '0.0.0.0', port: 29876 }); + + daemonConfigUnset({ host: true, port: false }); + + expect(saveDaemonConfigMock).toHaveBeenCalledWith({ port: 29876 }); + }); + }); }); diff --git a/src/commands/daemon.ts b/src/commands/daemon.ts index bc3a079f2..0d40c9583 100644 --- a/src/commands/daemon.ts +++ b/src/commands/daemon.ts @@ -6,6 +6,7 @@ */ import chalk from 'chalk'; +import { loadDaemonConfig, saveDaemonConfig, type DaemonFileConfig, getDaemonConfigPath } from '../daemon-config.js'; import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js'; import { formatDuration } from '../download/progress.js'; @@ -30,6 +31,7 @@ export async function daemonStatus(): Promise { console.log(`Extension: ${status.extensionConnected ? chalk.green('connected') : chalk.yellow('disconnected')}`); console.log(`Last CLI request: ${formatTimeSince(status.lastCliRequestTime)}`); console.log(`Memory: ${status.memoryMB} MB`); + console.log(`Host: ${status.host}`); console.log(`Port: ${status.port}`); } @@ -78,3 +80,46 @@ export async function daemonRestart(): Promise { process.exitCode = 1; } } + +export function daemonConfigGet(): void { + const config = loadDaemonConfig(); + const configPath = getDaemonConfigPath(); + if (config.host === undefined && config.port === undefined) { + console.log(chalk.dim(`No daemon config found at ${configPath}`)); + return; + } + + console.log(`Config: ${configPath}`); + if (config.host !== undefined) console.log(`Host: ${config.host}`); + if (config.port !== undefined) console.log(`Port: ${config.port}`); +} + +export function daemonConfigSet(opts: { host?: string; port?: string | number }): void { + const current = loadDaemonConfig(); + const next: DaemonFileConfig = { ...current }; + + if (typeof opts.host === 'string' && opts.host.trim()) { + next.host = opts.host.trim(); + } + if (opts.port !== undefined) { + const parsed = Number.parseInt(String(opts.port), 10); + if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) { + console.error(chalk.red(`Invalid port: ${opts.port}`)); + process.exitCode = 1; + return; + } + next.port = parsed; + } + + saveDaemonConfig(next); + console.log(chalk.green(`Saved daemon config to ${getDaemonConfigPath()}`)); +} + +export function daemonConfigUnset(opts: { host?: boolean; port?: boolean }): void { + const current = loadDaemonConfig(); + const next: DaemonFileConfig = { ...current }; + if (opts.host) delete next.host; + if (opts.port) delete next.port; + saveDaemonConfig(next); + console.log(chalk.green(`Updated daemon config at ${getDaemonConfigPath()}`)); +} diff --git a/src/daemon-config.test.ts b/src/daemon-config.test.ts new file mode 100644 index 000000000..00007e3d3 --- /dev/null +++ b/src/daemon-config.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + DEFAULT_DAEMON_HOST, + getDaemonBaseUrl, + getDaemonConnectHost, + getDaemonConfigPath, + parseDaemonConfig, + resolveDaemonConfig, +} from './daemon-config.js'; + +describe('daemon-config', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uses defaults when file config is empty', () => { + expect(resolveDaemonConfig({}, {})).toEqual({ + host: DEFAULT_DAEMON_HOST, + port: 19825, + }); + }); + + it('prefers environment variables over file config', () => { + expect(resolveDaemonConfig({ + OPENCLI_DAEMON_HOST: '0.0.0.0', + OPENCLI_DAEMON_PORT: '28888', + }, { + host: '127.0.0.1', + port: 19825, + })).toEqual({ + host: '0.0.0.0', + port: 28888, + }); + }); + + it('keeps host when it is already connectable', () => { + expect(resolveDaemonConfig({}, { host: '192.168.1.2', port: 28888 })).toEqual({ + host: '192.168.1.2', + port: 28888, + }); + }); + + it('parses daemon.yaml content', () => { + expect(parseDaemonConfig(` +host: 0.0.0.0 +port: 29876 +`)).toEqual({ + host: '0.0.0.0', + port: 29876, + }); + }); + + it('ignores malformed config values', () => { + expect(parseDaemonConfig(` +host: 123 +port: nope +`)).toEqual({}); + }); + + it('maps wildcard hosts to loopback for client connections', () => { + expect(getDaemonConnectHost('0.0.0.0')).toBe('127.0.0.1'); + expect(getDaemonConnectHost('::')).toBe('[::1]'); + expect(getDaemonConnectHost('192.168.1.8')).toBe('192.168.1.8'); + }); + + it('builds the client base url from the normalized host and port', () => { + expect(getDaemonBaseUrl({ + host: '0.0.0.0', + port: 29876, + })).toBe('http://127.0.0.1:29876'); + }); + + it('resolves the daemon config path from the home directory', () => { + expect(getDaemonConfigPath('/tmp/demo')).toBe('/tmp/demo/.opencli/daemon.yaml'); + }); +}); diff --git a/src/daemon-config.ts b/src/daemon-config.ts new file mode 100644 index 000000000..d7e9e7cee --- /dev/null +++ b/src/daemon-config.ts @@ -0,0 +1,82 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import yaml from 'js-yaml'; + +import { DEFAULT_DAEMON_PORT } from './constants.js'; + +export const DEFAULT_DAEMON_HOST = '127.0.0.1'; + +export interface DaemonFileConfig { + host?: string; + port?: number; +} + +export interface DaemonConfig { + host: string; + port: number; +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function readPort(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 65535) return value; + if (typeof value === 'string' && value.trim()) { + const parsed = Number.parseInt(value, 10); + if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) return parsed; + } + return undefined; +} + +export function getDaemonConfigPath(homeDir: string = os.homedir()): string { + return path.join(homeDir, '.opencli', 'daemon.yaml'); +} + +export function loadDaemonConfig(configPath: string = getDaemonConfigPath()): DaemonFileConfig { + try { + const raw = fs.readFileSync(configPath, 'utf-8'); + return parseDaemonConfig(raw); + } catch { + return {}; + } +} + +export function saveDaemonConfig(config: DaemonFileConfig, configPath: string = getDaemonConfigPath()): void { + const dir = path.dirname(configPath); + fs.mkdirSync(dir, { recursive: true }); + const normalized: DaemonFileConfig = {}; + if (readString(config.host)) normalized.host = readString(config.host); + if (readPort(config.port) !== undefined) normalized.port = readPort(config.port); + const serialized = yaml.dump(normalized, { indent: 2, lineWidth: 120, noRefs: true, sortKeys: true }); + fs.writeFileSync(configPath, serialized, 'utf-8'); +} + +export function parseDaemonConfig(raw: string): DaemonFileConfig { + const parsed = yaml.load(raw) as Record | null; + if (!parsed || typeof parsed !== 'object') return {}; + return { + host: readString(parsed.host), + port: readPort(parsed.port), + }; +} + +export function resolveDaemonConfig( + env: NodeJS.ProcessEnv = process.env, + fileConfig: DaemonFileConfig = loadDaemonConfig(), +): DaemonConfig { + const host = readString(env.OPENCLI_DAEMON_HOST) ?? fileConfig.host ?? DEFAULT_DAEMON_HOST; + const port = readPort(env.OPENCLI_DAEMON_PORT) ?? fileConfig.port ?? DEFAULT_DAEMON_PORT; + return { host, port }; +} + +export function getDaemonConnectHost(host: string): string { + if (host === '0.0.0.0') return '127.0.0.1'; + if (host === '::' || host === '::0') return '[::1]'; + return host; +} + +export function getDaemonBaseUrl(config: DaemonConfig = resolveDaemonConfig()): string { + return `http://${getDaemonConnectHost(config.host)}:${config.port}`; +} diff --git a/src/daemon.ts b/src/daemon.ts index d278f49d2..e749c2d45 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -16,16 +16,19 @@ * Lifecycle: * - Auto-spawned by opencli on first browser command * - Auto-exits after idle timeout (default 4h, configurable via OPENCLI_DAEMON_TIMEOUT) - * - Listens on localhost:19825 + * - Listens on 127.0.0.1:19825 by default */ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { WebSocketServer, WebSocket, type RawData } from 'ws'; -import { DEFAULT_DAEMON_PORT, DEFAULT_DAEMON_IDLE_TIMEOUT } from './constants.js'; +import { DEFAULT_DAEMON_IDLE_TIMEOUT } from './constants.js'; +import { resolveDaemonConfig } from './daemon-config.js'; import { EXIT_CODES } from './errors.js'; import { IdleManager } from './idle-manager.js'; -const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10); +const DAEMON_CONFIG = resolveDaemonConfig(); +const HOST = DAEMON_CONFIG.host; +const PORT = DAEMON_CONFIG.port; const IDLE_TIMEOUT = Number(process.env.OPENCLI_DAEMON_TIMEOUT ?? DEFAULT_DAEMON_IDLE_TIMEOUT); // ─── State ─────────────────────────────────────────────────────────── @@ -135,13 +138,14 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise pending: pending.size, lastCliRequestTime: idleManager.lastCliRequestTime, memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10, + host: HOST, port: PORT, }); return; } if (req.method === 'GET' && pathname === '/logs') { - const params = new URL(url, `http://localhost:${PORT}`).searchParams; + const params = new URL(url, `http://${HOST}:${PORT}`).searchParams; const level = params.get('level'); const filtered = level ? logBuffer.filter(e => e.level === level) @@ -210,7 +214,7 @@ const wss = new WebSocketServer({ verifyClient: ({ req }: { req: IncomingMessage }) => { // Block browser-originated WebSocket connections. Browsers don't // enforce CORS on WebSocket, so a malicious webpage could connect to - // ws://localhost:19825/ext and impersonate the Extension. Real Chrome + // ws://127.0.0.1:19825/ext and impersonate the Extension. Real Chrome // Extensions send origin chrome-extension://. const origin = req.headers['origin'] as string | undefined; return !origin || origin.startsWith('chrome-extension://'); @@ -308,8 +312,8 @@ wss.on('connection', (ws: WebSocket) => { // ─── Start ─────────────────────────────────────────────────────────── -httpServer.listen(PORT, '127.0.0.1', () => { - console.error(`[daemon] Listening on http://127.0.0.1:${PORT}`); +httpServer.listen(PORT, HOST, () => { + console.error(`[daemon] Listening on http://${HOST}:${PORT}`); idleManager.onCliRequest(); }); diff --git a/src/doctor.ts b/src/doctor.ts index bff86d368..d01342969 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -5,7 +5,6 @@ */ import chalk from 'chalk'; -import { DEFAULT_DAEMON_PORT } from './constants.js'; import { BrowserBridge } from './browser/index.js'; import { getDaemonHealth, listSessions } from './browser/daemon-client.js'; import { getErrorMessage } from './errors.js'; @@ -31,6 +30,8 @@ export type DoctorReport = { cliVersion?: string; daemonRunning: boolean; daemonFlaky?: boolean; + daemonHost?: string; + daemonPort?: number; extensionConnected: boolean; extensionFlaky?: boolean; extensionVersion?: string; @@ -128,6 +129,8 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise