diff --git a/.github/CHANGELOG.md b/.github/CHANGELOG.md index bceb6bc..00170ba 100644 --- a/.github/CHANGELOG.md +++ b/.github/CHANGELOG.md @@ -68,6 +68,16 @@ itself rather than reconstructing it at tag time. ### Fixed +- **DXT settings now reach the server.** The Claude Desktop DXT passed its + WebSocket Port, HTTP Port and Auto-Tunnel settings as env variables the server + never read, so changing them did nothing. The server now reads + `OPENDIA_WS_PORT`, `OPENDIA_HTTP_PORT`, `OPENDIA_ENABLE_TUNNEL` and + `OPENDIA_TOKEN` (flags still win), and the DXT maps its settings onto them. The + DXT gains an optional Tunnel Auth Token setting and drops its Safety Mode + setting, which had no effect; Safety Mode stays a toggle in the extension popup. + The popup also gets a Custom HTTP Port field, checked first during server + discovery, so the extension can find a server moved off the usual ports. (#85) + - **Tools no longer report failures to the model as success.** `get_history` rendered a browser API failure as "No history items found", `get_selected_text` rendered four distinct failures as "No text selected", `page_navigate` claimed diff --git a/.github/README.md b/.github/README.md index d048a9f..d15a80f 100644 --- a/.github/README.md +++ b/.github/README.md @@ -143,6 +143,13 @@ Wired it up with something else? Open a PR — the list grows as MCP grows. 2. Double-click the `.dxt` file to install automatically 3. The MCP will be added to your Claude Desktop configuration +The DXT settings in Claude Desktop (WebSocket Port, HTTP Port, Auto-Tunnel, +Tunnel Auth Token) are passed to the server as the `OPENDIA_*` environment +variables described under [Port Configuration](#port-configuration). If you move +the HTTP port outside the ports the extension checks (5556-5558, 3001, +6001-6003), enter it under **Custom HTTP Port** in the extension popup. Safety +Mode is a toggle in the extension popup, not a DXT setting. + **Option 2: Manual Configuration** Add to your Claude Desktop configuration: ```json @@ -209,6 +216,21 @@ npx opendia --ws-port=5555 --http-port=5556 # Specify individually # Note: Existing OpenDia processes are automatically terminated on startup ``` +The same settings can come from environment variables, which is how the Claude +Desktop DXT applies its settings. A command-line flag always wins over the +matching variable, and an empty variable counts as unset. + +| Variable | Same as | Notes | +| --- | --- | --- | +| `OPENDIA_WS_PORT` | `--ws-port=` | Integer 1-65535 | +| `OPENDIA_HTTP_PORT` | `--http-port=` | Integer 1-65535 | +| `OPENDIA_ENABLE_TUNNEL` | `--tunnel` | `1`, `true` or `yes` turns it on | +| `OPENDIA_TOKEN` | `--token=` | Bearer token for tunnel mode | + +The browser extension finds the server by checking HTTP ports 5556-5558, 3001 +and 6001-6003. For any other HTTP port, enter it under **Custom HTTP Port** in +the extension popup so it is checked first. + ### Auto-Tunnel Mode ```bash npx opendia --tunnel @@ -218,8 +240,8 @@ npx opendia --tunnel - Local functionality preserved Because the tunnel is public, `/sse` requires a bearer token in this mode. The -server prints one at startup; pass `--token=` to pin a fixed one across -restarts. Send it with every request: +server prints one at startup; pass `--token=` (or set `OPENDIA_TOKEN`) to +pin a fixed one across restarts. Send it with every request: ```bash curl -H "Authorization: Bearer " https://.ngrok-free.app/sse diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc269f7..a7858dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,8 @@ jobs: run: node test-protocol.js - name: Connection tests run: node test-connection.js + - name: Config tests + run: node test-config.js extension: name: Browser extension diff --git a/build-dxt.sh b/build-dxt.sh index 737d3c9..e124adf 100755 --- a/build-dxt.sh +++ b/build-dxt.sh @@ -161,10 +161,10 @@ cat > dist/opendia-dxt/manifest.json << 'EOF' "args": ["${__dirname}/server.js"], "env": { "NODE_ENV": "production", - "WS_PORT": "${user_config.ws_port}", - "HTTP_PORT": "${user_config.http_port}", - "ENABLE_TUNNEL": "${user_config.enable_tunnel}", - "SAFETY_MODE": "${user_config.safety_mode}" + "OPENDIA_WS_PORT": "${user_config.ws_port}", + "OPENDIA_HTTP_PORT": "${user_config.http_port}", + "OPENDIA_ENABLE_TUNNEL": "${user_config.enable_tunnel}", + "OPENDIA_TOKEN": "${user_config.auth_token}" } } }, @@ -173,7 +173,7 @@ cat > dist/opendia-dxt/manifest.json << 'EOF' "ws_port": { "type": "number", "title": "WebSocket Port", - "description": "Port for Chrome/Firefox extension connection", + "description": "Port for Chrome/Firefox extension connection. The extension finds it through the HTTP port.", "default": 5555, "min": 1024, "max": 65535 @@ -181,7 +181,7 @@ cat > dist/opendia-dxt/manifest.json << 'EOF' "http_port": { "type": "number", "title": "HTTP Port", - "description": "Port for HTTP/SSE server", + "description": "Port for HTTP/SSE server. If you change it to a port the extension does not probe (5556-5558, 3001, 6001-6003), enter the same port in the extension popup under Custom HTTP Port.", "default": 5556, "min": 1024, "max": 65535 @@ -189,14 +189,15 @@ cat > dist/opendia-dxt/manifest.json << 'EOF' "enable_tunnel": { "type": "boolean", "title": "Auto-Tunnel", - "description": "Automatically create ngrok tunnel for online AI access (requires ngrok)", + "description": "Automatically create ngrok tunnel for online AI access (requires ngrok). Safety Mode is set in the browser extension popup, not here.", "default": false }, - "safety_mode": { - "type": "boolean", - "title": "Safety Mode", - "description": "Block write/edit tools (element_click, element_fill) by default", - "default": false + "auth_token": { + "type": "string", + "title": "Tunnel Auth Token", + "description": "Bearer token required on /sse in tunnel mode. Leave blank to generate a new one at each start (printed in the server log).", + "sensitive": true, + "required": false } }, diff --git a/opendia-extension/README.md b/opendia-extension/README.md index 5d461a6..3ff31e0 100644 --- a/opendia-extension/README.md +++ b/opendia-extension/README.md @@ -183,6 +183,10 @@ const response = await fetch(`http://localhost:${port}/ports`); const portInfo = await response.json(); ``` +A **Custom HTTP Port** set in the popup (saved as `customHttpPort` in +`storage.local`) is checked before this list, for servers started on any other +HTTP port. + ### Background Tab Support All tools support background tab targeting: diff --git a/opendia-extension/src/background/background.js b/opendia-extension/src/background/background.js index 7c287ba..341e29c 100644 --- a/opendia-extension/src/background/background.js +++ b/opendia-extension/src/background/background.js @@ -29,6 +29,19 @@ browser.storage.local.get(['safetyMode'], (result) => { safetyModeEnabled = result.safetyMode || false; }); +// HTTP port the user set in the popup, or null. Read fresh on every discovery +// so a change applies without restarting the extension. Promise form, so it +// works on both Chrome MV3 and Firefox's native browser.* API. +async function getCustomHttpPort() { + try { + const result = await browser.storage.local.get('customHttpPort'); + const port = Number(result?.customHttpPort); + return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null; + } catch (error) { + return null; + } +} + // Cross-browser WebSocket connection manager class ConnectionManager { constructor() { @@ -164,10 +177,15 @@ class ConnectionManager { } async discoverServerPorts() { - // Try common HTTP ports to find the server + // Try common HTTP ports to find the server. A custom port from the popup + // goes first, for a server started on a port outside this list. const commonPorts = [5556, 5557, 5558, 3001, 6001, 6002, 6003]; + const customPort = await getCustomHttpPort(); + const ports = customPort + ? [customPort, ...commonPorts.filter((port) => port !== customPort)] + : commonPorts; - for (const httpPort of commonPorts) { + for (const httpPort of ports) { try { const response = await fetch(`http://localhost:${httpPort}/ports`); if (response.ok) { @@ -2013,6 +2031,14 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse) => { safetyModeEnabled = request.enabled; console.log(`šŸ›”ļø Safety Mode ${safetyModeEnabled ? 'ENABLED' : 'DISABLED'}`); sendResponse({ success: true }); + } else if (request.action === "setCustomHttpPort") { + // Force discovery on the next connect so the new port is probed, and try + // now if nothing is connected instead of waiting out the backoff. + MCP_SERVER_URL = 'ws://localhost:5555'; + if (!connectionManager.getStatus().connected) { + connectionManager.connect().catch(() => {}); + } + sendResponse({ success: true }); } return true; // Keep the message channel open }); \ No newline at end of file diff --git a/opendia-extension/src/popup/popup.html b/opendia-extension/src/popup/popup.html index 89989ad..bf19d20 100644 --- a/opendia-extension/src/popup/popup.html +++ b/opendia-extension/src/popup/popup.html @@ -237,7 +237,8 @@ transform: translateY(0); } - .safety-mode { + .safety-mode, + .port-setting { background: rgba(255, 255, 255, 0.6); padding: 16px; border-radius: 10px; @@ -247,13 +248,15 @@ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); } - .safety-row { + .safety-row, + .port-row { display: flex; justify-content: space-between; align-items: center; } - .safety-label { + .safety-label, + .port-label { color: #374151; font-weight: 600; font-size: 0.875rem; @@ -310,6 +313,24 @@ .safety-slider:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } + + .port-input { + width: 80px; + padding: 6px 8px; + border: 1px solid rgba(0, 129, 247, 0.2); + border-radius: 6px; + background: rgba(255, 255, 255, 0.5); + color: var(--text-color); + font-size: 0.875rem; + text-align: right; + transition: all 0.3s ease; + } + + .port-input:focus { + outline: none; + border-color: rgba(255, 117, 202, 0.4); + box-shadow: 0 2px 8px rgba(255, 117, 202, 0.2); + } @@ -365,6 +386,18 @@

OpenDia

+
+
+ + Custom HTTP Port + + Checked first when finding the server. Set it if the server runs on an HTTP port other than 5556-5558, 3001 or 6001-6003. Leave blank for auto-discovery. + + + +
+
+
diff --git a/opendia-extension/src/popup/popup.js b/opendia-extension/src/popup/popup.js index 3e4e7fc..d950d5a 100644 --- a/opendia-extension/src/popup/popup.js +++ b/opendia-extension/src/popup/popup.js @@ -135,6 +135,41 @@ safetyModeToggle.addEventListener('change', () => { }); }); +// Custom HTTP Port Management +const customHttpPortInput = document.getElementById("customHttpPort"); + +// Promise form (not a callback) so it behaves the same on Chrome MV3 and +// Firefox's native browser.* API. +function loadCustomHttpPort() { + storageAPI.local.get(['customHttpPort']).then((result) => { + customHttpPortInput.value = result?.customHttpPort ?? ''; + }); +} + +loadCustomHttpPort(); + +// Save on change; a blank field clears it and goes back to auto-discovery +customHttpPortInput.addEventListener('change', () => { + const raw = customHttpPortInput.value.trim(); + const port = Number(raw); + let save; + + if (raw === '' && !customHttpPortInput.validity.badInput) { + save = storageAPI.local.remove('customHttpPort'); + } else if (Number.isInteger(port) && port >= 1 && port <= 65535) { + save = storageAPI.local.set({ customHttpPort: port }); + } else { + loadCustomHttpPort(); // Invalid entry: show the saved value again + return; + } + + // Notify background script once the value is stored, so its next port + // discovery reads the new one + save.then(() => { + runtimeAPI.sendMessage({ action: "setCustomHttpPort" }); + }); +}); + // Video speed control based on mouse movement const logoVideo = document.querySelector('.logo video'); let mouseTimeout; diff --git a/opendia-mcp/package.json b/opendia-mcp/package.json index 790a971..3213aa8 100644 --- a/opendia-mcp/package.json +++ b/opendia-mcp/package.json @@ -11,7 +11,7 @@ "tunnel": "node server.js --tunnel", "sse-only": "node server.js --sse-only", "tunnel-sse": "node server.js --tunnel --sse-only", - "test": "node --check server.js && node test-protocol.js && node test-connection.js" + "test": "node --check server.js && node test-protocol.js && node test-connection.js && node test-config.js" }, "keywords": [ "mcp", diff --git a/opendia-mcp/server.js b/opendia-mcp/server.js index abd8d09..fedcd45 100755 --- a/opendia-mcp/server.js +++ b/opendia-mcp/server.js @@ -12,7 +12,6 @@ const { spawn } = require('child_process'); // Command line argument parsing const args = process.argv.slice(2); -const enableTunnel = args.includes('--tunnel') || args.includes('--auto-tunnel'); const sseOnly = args.includes('--sse-only'); function usageError(message) { @@ -20,6 +19,29 @@ function usageError(message) { process.exit(1); } +// Environment fallbacks for hosts that configure the server through env rather +// than argv: the Claude Desktop DXT maps its user_config settings onto these +// OPENDIA_* variables. A flag on the command line always wins over the env. +// +// Unlike an empty flag, an empty variable means "not set": an optional DXT +// field the user left blank arrives as "", and some hosts pass the raw +// `${user_config.x}` placeholder through instead of substituting it. +function envValue(name) { + const raw = process.env[name]; + if (raw === undefined) return null; + const value = raw.trim(); + if (value === '' || /^\$\{user_config\.[^}]*\}$/.test(value)) return null; + return value; +} + +function envFlag(name) { + const value = envValue(name); + return value !== null && ['1', 'true', 'yes'].includes(value.toLowerCase()); +} + +const enableTunnel = args.includes('--tunnel') || args.includes('--auto-tunnel') || + envFlag('OPENDIA_ENABLE_TUNNEL'); + // Returns the text after `=`, or null when the flag is absent. An empty value // (`--token=`) is a usage error, not an empty string: treating it as falsy is // what let `--token=` disable auth outright and `--http-host=` bind 0.0.0.0. @@ -32,8 +54,7 @@ function argValue(name) { } // Digits only — Number() would accept '0x1f' and parseInt() would accept '80abc'. -function portValue(name) { - const raw = argValue(name); +function parsePort(name, raw) { if (raw === null) return null; if (!/^\d+$/.test(raw) || raw < 1 || raw > 65535) { usageError(`${name}=${raw} is not a valid port (expected an integer 1-65535)`); @@ -41,6 +62,14 @@ function portValue(name) { return Number(raw); } +function portValue(name) { + return parsePort(name, argValue(name)); +} + +function envPortValue(name) { + return parsePort(name, envValue(name)); +} + // `POST /sse` hands whatever it receives to handleMCPRequest, which drives the // browser through the extension. Keep that surface on loopback by default; the // extension reaches it at localhost and `ngrok http` connects from this machine @@ -54,7 +83,7 @@ function isLoopbackHost(host) { // Once the MCP surface is reachable beyond this machine — widened bind or an // ngrok tunnel — loopback stops being the boundary and callers must present a // token. Locally it stays off so existing setups keep working untouched. -const explicitToken = argValue('--token'); +const explicitToken = argValue('--token') ?? envValue('OPENDIA_TOKEN'); const requiresToken = enableTunnel || !isLoopbackHost(HTTP_HOST); const AUTH_TOKEN = requiresToken ? (explicitToken ?? crypto.randomBytes(24).toString('hex')) @@ -68,8 +97,10 @@ const SERVER_VERSION = '1.1.3'; const wsPortArg = portValue('--ws-port'); const httpPortArg = portValue('--http-port'); const portArg = portValue('--port'); -let WS_PORT = wsPortArg ?? portArg ?? 5555; -let HTTP_PORT = httpPortArg ?? (portArg !== null ? portArg + 1 : 5556); +const wsPortEnv = envPortValue('OPENDIA_WS_PORT'); +const httpPortEnv = envPortValue('OPENDIA_HTTP_PORT'); +let WS_PORT = wsPortArg ?? portArg ?? wsPortEnv ?? 5555; +let HTTP_PORT = httpPortArg ?? (portArg !== null ? portArg + 1 : httpPortEnv ?? 5556); if (HTTP_PORT > 65535) { usageError(`--port=${portArg} leaves no room for the HTTP port (${HTTP_PORT})`); } @@ -1935,7 +1966,7 @@ async function startServer() { console.error(''); console.error('šŸ”‘ This URL is public — the tunnel requires a token:'); console.error(` Authorization: Bearer ${AUTH_TOKEN}`); - console.error(' Reuse a fixed one across restarts with --token='); + console.error(' Reuse a fixed one across restarts with --token= or OPENDIA_TOKEN'); console.error(''); console.error('šŸ’” ChatGPT: Settings → Connectors → Custom Connector'); console.error('šŸ’” Claude Web: Add as external MCP server (if supported)'); @@ -1985,6 +2016,7 @@ async function startServer() { console.error(` Current: WebSocket=${WS_PORT}, HTTP=${HTTP_PORT}`); console.error(' Custom: npx opendia --ws-port=6000 --http-port=6001'); console.error(' Or: npx opendia --port=6000 (uses 6000 and 6001)'); + console.error(' Env: OPENDIA_WS_PORT / OPENDIA_HTTP_PORT (flags take precedence)'); console.error(' Note: Existing processes are automatically terminated'); console.error(''); } diff --git a/opendia-mcp/test-config.js b/opendia-mcp/test-config.js new file mode 100644 index 0000000..afeab10 --- /dev/null +++ b/opendia-mcp/test-config.js @@ -0,0 +1,152 @@ +// Config tests: the OPENDIA_* environment fallbacks. +// +// The Claude Desktop DXT passes its user_config settings to the server as env +// variables, so these are the only way those settings reach it. Flags must keep +// winning over env, a bad env port must fail loudly, and an optional value the +// user left blank must count as unset. +const http = require('http'); +const { spawnSync } = require('child_process'); +const { SERVER, sleep, makeAsserter, startServer } = require('./test-helpers'); + +const { assert, state } = makeAsserter(); + +// Start from an env with no OPENDIA_* leaking in from the caller's shell. +const baseEnv = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith('OPENDIA_')) +); + +// Keeps ngrok off PATH so the tunnel path runs without opening a real tunnel. +const NO_NGROK = { PATH: '/nonexistent' }; + +function getJson(port, urlPath) { + return new Promise((resolve, reject) => { + http.get({ host: '127.0.0.1', port, path: urlPath }, (res) => { + let body = ''; + res.on('data', (c) => { body += c; }); + res.on('end', () => { + try { resolve({ status: res.statusCode, body: JSON.parse(body) }); } catch (e) { reject(e); } + }); + }).on('error', reject); + }); +} + +function postStatus(port, urlPath) { + return new Promise((resolve, reject) => { + const req = http.request({ host: '127.0.0.1', port, path: urlPath, method: 'POST', + headers: { 'Content-Type': 'application/json' } }, (res) => { + res.resume(); + resolve(res.statusCode); + }); + req.on('error', reject); + req.end('{}'); + }); +} + +async function withServer(opts, fn) { + const srv = await startServer({ ...opts, env: { ...baseEnv, ...opts.env } }); + srv.proc.stdout.on('data', () => {}); + try { + await fn(srv); + } finally { + srv.proc.kill(); + await sleep(200); + } +} + +function runSync(env) { + return spawnSync(process.execPath, [SERVER], { + env: { ...baseEnv, ...env }, + input: '', + encoding: 'utf8', + timeout: 10000, + }); +} + +async function run() { + // --- Env ports are used when no flag is given --- + await withServer({ args: [], env: { OPENDIA_WS_PORT: '45561', OPENDIA_HTTP_PORT: '45562' } }, + async (srv) => { + assert('env ports bind', srv.wsPort === 45561 && srv.httpPort === 45562, + `ws=${srv.wsPort} http=${srv.httpPort}`); + const { body } = await getJson(srv.httpPort, '/ports'); + assert('/ports reports the env ports', body.websocket === 45561 && body.http === 45562, + JSON.stringify(body)); + }); + + // --- Flags win over env --- + await withServer({ + args: ['--ws-port=45563', '--http-port=45564'], + env: { OPENDIA_WS_PORT: '45561', OPENDIA_HTTP_PORT: '45562' }, + }, async (srv) => { + assert('--ws-port/--http-port override env', srv.wsPort === 45563 && srv.httpPort === 45564, + `ws=${srv.wsPort} http=${srv.httpPort}`); + }); + + await withServer({ + args: ['--port=45565'], + env: { OPENDIA_WS_PORT: '45561', OPENDIA_HTTP_PORT: '45562' }, + }, async (srv) => { + assert('--port overrides both env ports (http = port+1)', + srv.wsPort === 45565 && srv.httpPort === 45566, `ws=${srv.wsPort} http=${srv.httpPort}`); + }); + + // --- Bad env ports fail with a usage error --- + for (const [name, value] of [['OPENDIA_WS_PORT', 'abc'], ['OPENDIA_HTTP_PORT', '70000']]) { + const res = runSync({ [name]: value }); + assert(`${name}=${value} exits non-zero`, res.status === 1, `status=${res.status}`); + assert(`${name}=${value} names the bad value`, + res.stderr.includes(`${name}=${value} is not a valid port`), res.stderr.trim().split('\n')[0]); + } + + // --- Tunnel + token from env --- + await withServer({ + args: ['--ws-port=45567', '--http-port=45568'], + env: { ...NO_NGROK, OPENDIA_ENABLE_TUNNEL: 'Yes', OPENDIA_TOKEN: 'env-token-123' }, + }, async (srv) => { + await sleep(300); + const out = srv.stderr(); + assert('OPENDIA_ENABLE_TUNNEL=Yes starts the tunnel', out.includes('Starting automatic tunnel')); + assert('OPENDIA_TOKEN is the /sse token', out.includes('/sse token: env-token-123')); + assert('/sse rejects a request without the token', await postStatus(srv.httpPort, '/sse') === 401); + }); + + await withServer({ + args: ['--ws-port=45567', '--http-port=45568', '--token=flag-token-456'], + env: { ...NO_NGROK, OPENDIA_ENABLE_TUNNEL: 'true', OPENDIA_TOKEN: 'env-token-123' }, + }, async (srv) => { + assert('--token overrides OPENDIA_TOKEN', srv.stderr().includes('/sse token: flag-token-456')); + }); + + // A blank optional DXT field arrives as "" or as the raw placeholder; either + // way the server must generate a token rather than adopt the literal. + for (const blank of ['', '${user_config.auth_token}']) { + await withServer({ + args: ['--ws-port=45567', '--http-port=45568'], + env: { ...NO_NGROK, OPENDIA_ENABLE_TUNNEL: '1', OPENDIA_TOKEN: blank }, + }, async (srv) => { + const token = srv.stderr().match(/\/sse token: (\S+)/)?.[1]; + assert(`OPENDIA_TOKEN=${JSON.stringify(blank)} counts as unset`, + /^[0-9a-f]{48}$/.test(token || ''), `token=${token}`); + }); + } + + await withServer({ + args: ['--ws-port=45567', '--http-port=45568'], + env: { ...NO_NGROK, OPENDIA_ENABLE_TUNNEL: 'false', OPENDIA_TOKEN: 'env-token-123' }, + }, async (srv) => { + const out = srv.stderr(); + assert('OPENDIA_ENABLE_TUNNEL=false stays local', + out.includes('LOCAL MODE') && !out.includes('Starting automatic tunnel')); + assert('no token required locally', !out.includes('/sse token:')); + }); +} + +run() + .then(() => { + console.log(state.failures === 0 ? '\nāœ… Config tests passed' : `\nāŒ ${state.failures} failure(s)`); + process.exit(state.failures === 0 ? 0 : 1); + }) + .catch((e) => { + console.error('āŒ Harness error:', e.message); + process.exit(1); + }); diff --git a/opendia-mcp/test-connection.js b/opendia-mcp/test-connection.js index f683d44..eb67a98 100644 --- a/opendia-mcp/test-connection.js +++ b/opendia-mcp/test-connection.js @@ -19,8 +19,10 @@ const HTTP_PORT = 45554; const { assert, state } = makeAsserter(); -// Loads the real background.js under stubbed extension globals. -function loadBackground(wsPort, httpPort) { +// Loads the real background.js under stubbed extension globals. `storage` seeds +// browser.storage.local and `probed` collects every URL discovery fetches. +function loadBackground(wsPort, httpPort, { storage = {}, probed = [] } = {}) { + const answerPort = storage.customHttpPort ?? 5556; const manifest = { manifest_version: 2, applications: { gecko: { id: 'opendia@test' } } }; const sandbox = { console: { log: () => {}, error: () => {}, warn: () => {} }, @@ -30,7 +32,8 @@ function loadBackground(wsPort, httpPort) { // discoverServerPorts probes a fixed port list that will not contain our // test port, so answer the first probe and point it at the real server. fetch: async (url) => { - if (!url.includes(':5556/ports')) throw new Error('connection refused'); + probed.push(url); + if (!url.includes(`:${answerPort}/ports`)) throw new Error('connection refused'); return { ok: true, json: async () => ({ @@ -46,7 +49,10 @@ function loadBackground(wsPort, httpPort) { sandbox.globalThis = sandbox; sandbox.browser = { runtime: { getManifest: () => manifest, onMessage: { addListener: () => {} }, lastError: null }, - storage: { local: { get: (_keys, cb) => cb({}), set: () => {} } }, + storage: { local: { + get: (_keys, cb) => (cb ? cb({ ...storage }) : Promise.resolve({ ...storage })), + set: () => {}, + } }, tabs: { query: async () => [], get: async () => ({}), sendMessage: () => {} }, }; @@ -117,6 +123,15 @@ async function run() { `_repeat=${cm.reconnectTimer && cm.reconnectTimer._repeat}`); cm.clearReconnectTimer(); assert('clearReconnectTimer disarms it', cm.reconnectTimer === null); + + // --- A custom HTTP port saved in the popup is probed first --- + const probed = []; + const cmCustom = loadBackground(wsPort, httpPort, { storage: { customHttpPort: 6002 }, probed }); + const found = await cmCustom.discoverServerPorts(); + assert('custom HTTP port probed first', probed[0] === 'http://localhost:6002/ports', probed[0]); + assert('custom HTTP port probed once', probed.filter((u) => u.includes(':6002/')).length === 1, + probed.join(' ')); + assert('discovery returns the custom port answer', found?.websocket === wsPort); } finally { srv.kill(); await sleep(200); diff --git a/opendia-mcp/test-helpers.js b/opendia-mcp/test-helpers.js index 664afa2..a16f3d4 100644 --- a/opendia-mcp/test-helpers.js +++ b/opendia-mcp/test-helpers.js @@ -22,9 +22,14 @@ function makeAsserter() { // conflict, which would otherwise leave the test polling an address nothing is // bound to. On failure the captured stderr is included, so a CI failure is // diagnosable from the log alone. -function startServer({ wsPort, httpPort, timeoutMs = 30000 } = {}) { - const proc = spawn('node', [SERVER, `--ws-port=${wsPort}`, `--http-port=${httpPort}`], { +// +// `args` replaces the default port flags and `env` is merged over the parent +// environment, for suites that configure the server some other way. +function startServer({ wsPort, httpPort, args, env, timeoutMs = 30000 } = {}) { + const argv = args ?? [`--ws-port=${wsPort}`, `--http-port=${httpPort}`]; + const proc = spawn(process.execPath, [SERVER, ...argv], { stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, ...env }, }); let stderr = ''; @@ -65,4 +70,4 @@ function startServer({ wsPort, httpPort, timeoutMs = 30000 } = {}) { }); } -module.exports = { sleep, makeAsserter, startServer }; +module.exports = { SERVER, sleep, makeAsserter, startServer };