diff --git a/package.json b/package.json index afc8636..783d820 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,8 @@ "start": "node dist/cli.js", "validate:skill": "node scripts/validate-skill.mjs openclaw/vibebrowser/SKILL.md", "prepublishOnly": "npm run build", - "test": "npm run test:e2e:docs-contract && npm run test:e2e:relay-race && npm run test:e2e:relay-roundtrip && npm run test:e2e:relay-heartbeat-pong && npm run test:e2e:cli-relay && npm run test:e2e:cli-autospawn && npm run test:e2e:http && npm run test:e2e:uuid-only-auth && npm run test:e2e:tool-annotations && npm run test:e2e:tools-list-budget && npm run test:e2e:browser-cli && npm run test:e2e:cli-package && npm run test:e2e:devtools-flag", - "test:ci": "npm run test:e2e:docs-contract && npm run test:e2e:relay-race && npm run test:e2e:relay-roundtrip && npm run test:e2e:relay-heartbeat-pong && npm run test:e2e:cli-relay && npm run test:e2e:cli-autospawn && npm run test:e2e:http && npm run test:e2e:uuid-only-auth && npm run test:e2e:tool-annotations && npm run test:e2e:tools-list-budget && npm run test:e2e:browser-cli && npm run test:e2e:cli-package && npm run test:e2e:devtools-flag", + "test": "npm run test:e2e:docs-contract && npm run test:e2e:relay-race && npm run test:e2e:relay-roundtrip && npm run test:e2e:relay-heartbeat-pong && npm run test:e2e:cli-relay && npm run test:e2e:cli-autospawn && npm run test:e2e:http && npm run test:e2e:uuid-only-auth && npm run test:e2e:remote-lifecycle && npm run test:e2e:tool-annotations && npm run test:e2e:tools-list-budget && npm run test:e2e:browser-cli && npm run test:e2e:cli-package && npm run test:e2e:devtools-flag", + "test:ci": "npm run test:e2e:docs-contract && npm run test:e2e:relay-race && npm run test:e2e:relay-roundtrip && npm run test:e2e:relay-heartbeat-pong && npm run test:e2e:cli-relay && npm run test:e2e:cli-autospawn && npm run test:e2e:http && npm run test:e2e:uuid-only-auth && npm run test:e2e:remote-lifecycle && npm run test:e2e:tool-annotations && npm run test:e2e:tools-list-budget && npm run test:e2e:browser-cli && npm run test:e2e:cli-package && npm run test:e2e:devtools-flag", "test:e2e:docs-contract": "node scripts/e2e-docs-contract.mjs", "test:e2e:tool-annotations": "node scripts/e2e-tool-annotations.mjs", "test:e2e:tools-list-budget": "node scripts/e2e-tools-list-startup-budget.mjs", @@ -34,6 +34,7 @@ "test:e2e:cli-autospawn": "node scripts/e2e-cli-autospawn.mjs", "test:e2e:http": "node scripts/e2e-http-streamable.mjs", "test:e2e:uuid-only-auth": "node scripts/e2e-uuid-only-auth.mjs", + "test:e2e:remote-lifecycle": "node scripts/e2e-remote-lifecycle.mjs", "test:e2e:devtools-flag": "node scripts/e2e-devtools-flag.mjs", "test:e2e:cli-package": "node scripts/e2e-cli-package-smoke.mjs", "test:e2e:browser-cli-live": "node scripts/e2e-browser-cli-live.mjs", diff --git a/scripts/e2e-browser-cli.mjs b/scripts/e2e-browser-cli.mjs index 0e81c19..33f2bf1 100644 --- a/scripts/e2e-browser-cli.mjs +++ b/scripts/e2e-browser-cli.mjs @@ -9,7 +9,8 @@ import { fileURLToPath } from 'node:url'; import { WebSocketServer } from 'ws'; const HOST = '127.0.0.1'; -const REMOTE_UUID = 'browser-cli-test-uuid'; +const REMOTE_UUID = '11111111-1111-4111-8111-111111111111'; +const REDACTED_REMOTE_ID = '[redacted]'; const RELAY_PORT = await findFreePort(); const RELAY_URL = `ws://${HOST}:${RELAY_PORT}`; const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); @@ -28,6 +29,8 @@ let cliInvocation = null; let delayToolResultMs = 0; let missingPageIdMode = false; let fillCompatibilityErrorMode = false; +let credentialErrorMode = false; +let credentialToolResultMode = false; const ONE_PIXEL_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5W6n8AAAAASUVORK5CYII='; @@ -106,6 +109,27 @@ try { } if (message.type === 'call_tool') { + if (credentialToolResultMode) { + ws.send(JSON.stringify({ + type: 'tool_result', + requestId: message.requestId, + data: { + success: false, + isError: true, + detail: `Relay rejected ${REMOTE_UUID.toUpperCase()}`, + content: [{ type: 'text', text: `Relay rejected ${REMOTE_UUID.toUpperCase()}` }], + }, + })); + return; + } + if (credentialErrorMode) { + ws.send(JSON.stringify({ + type: 'error', + requestId: message.requestId, + error: `Relay rejected ${REMOTE_UUID.toUpperCase()}`, + })); + return; + } if ( missingPageIdMode && (message.data?.name === 'navigate_page' || message.data?.name === 'click') @@ -159,14 +183,15 @@ try { assert(status.ok === true, `status failed: ${JSON.stringify(status)}`); assert(status.extensionConnected === true, `expected extension connected: ${JSON.stringify(status)}`); assert(Number(status.toolCount) >= 10, `expected toolCount >= 10: ${JSON.stringify(status)}`); - assert(status.sessionId === REMOTE_UUID, `expected status sessionId=${REMOTE_UUID}: ${JSON.stringify(status)}`); + assert(status.sessionId === REDACTED_REMOTE_ID, `expected redacted remote session: ${JSON.stringify(status)}`); + assert(!JSON.stringify(status).includes(REMOTE_UUID), `status leaked remote UUID: ${JSON.stringify(status)}`); const uuidStatus = await runCli(['status'], { remoteValue: REMOTE_UUID }); if (E2E_BROWSER_CLI_SOURCE === 'local') { assert(uuidStatus.ok === true, `UUID-only remote should return structured status locally: ${JSON.stringify(uuidStatus)}`); assert(uuidStatus.mode === 'remote', `UUID-only status should still be remote mode: ${JSON.stringify(uuidStatus)}`); assert(uuidStatus.extensionConnected === false, `UUID-only remote without public relay should report disconnected extension locally: ${JSON.stringify(uuidStatus)}`); - assert(uuidStatus.sessionId === REMOTE_UUID, `UUID-only status should parse UUID as sessionId: ${JSON.stringify(uuidStatus)}`); + assert(uuidStatus.sessionId === REDACTED_REMOTE_ID, `UUID-only status should redact sessionId: ${JSON.stringify(uuidStatus)}`); } const envStatus = await runCli(['status'], { @@ -175,11 +200,11 @@ try { }); assert(envStatus.ok === true, `status via VIBE_REMOTE_URL failed: ${JSON.stringify(envStatus)}`); assert(envStatus.extensionConnected === true, `VIBE_REMOTE_URL should connect extension: ${JSON.stringify(envStatus)}`); - assert(envStatus.sessionId === REMOTE_UUID, `VIBE_REMOTE_URL should target ${REMOTE_UUID}: ${JSON.stringify(envStatus)}`); + assert(envStatus.sessionId === REDACTED_REMOTE_ID, `VIBE_REMOTE_URL should redact sessionId: ${JSON.stringify(envStatus)}`); const sessions = await runCli(['sessions']); assert(Array.isArray(sessions.sessions) && sessions.sessions.length === 1, `sessions missing session list: ${JSON.stringify(sessions)}`); - assert(sessions.sessions[0].sessionId === REMOTE_UUID, `wrong remote session id: ${JSON.stringify(sessions)}`); + assert(sessions.sessions[0].sessionId === REDACTED_REMOTE_ID, `remote session id should be redacted: ${JSON.stringify(sessions)}`); const tabs = await runCli(['tabs']); assert(Array.isArray(tabs.pages) && tabs.pages.length === 2, `tabs missing pages: ${JSON.stringify(tabs)}`); @@ -310,6 +335,16 @@ try { assert(/"profile":\s*"user"/.test(missingPageMessage), `error payload should report user profile: ${missingPageMessage}`); missingPageIdMode = false; + credentialErrorMode = true; + const redactedRelayError = await expectCliFailure(['tabs'], /\[redacted\]/); + assert(!redactedRelayError.toLowerCase().includes(REMOTE_UUID.toLowerCase()), `CLI error leaked remote UUID: ${redactedRelayError}`); + credentialErrorMode = false; + + credentialToolResultMode = true; + const redactedToolResult = await expectCliFailure(['tabs'], /\[redacted\]/); + assert(!redactedToolResult.toLowerCase().includes(REMOTE_UUID.toLowerCase()), `CLI tool_result leaked remote UUID: ${redactedToolResult}`); + credentialToolResultMode = false; + console.log('browser cli e2e ok'); } finally { if (wss) { diff --git a/scripts/e2e-cli-autospawn.mjs b/scripts/e2e-cli-autospawn.mjs index 39cdc07..3c684b9 100644 --- a/scripts/e2e-cli-autospawn.mjs +++ b/scripts/e2e-cli-autospawn.mjs @@ -208,7 +208,7 @@ function attachFakeExtension(extensionPort, sessionId) { // Fake remote relay (agent-facing protocol) for the --remote bypass test // --------------------------------------------------------------------------- -const REMOTE_UUID = 'autospawn-remote-uuid'; +const REMOTE_UUID = '55555555-5555-4555-8555-555555555555'; function startFakeRemoteRelay(port) { const wss = new WebSocketServer({ host: HOST, port }); @@ -366,13 +366,13 @@ async function main() { assert(data.ok === true, `D: remote status not ok: ${JSON.stringify(data)}`); assert(data.mode === 'remote', `D: expected remote mode: ${JSON.stringify(data)}`); assert(data.extensionConnected === true, `D: remote extension should be connected: ${JSON.stringify(data)}`); - assert(data.sessionId === REMOTE_UUID, `D: expected sessionId=${REMOTE_UUID}: ${JSON.stringify(data)}`); + assert(data.sessionId === '[redacted]', `D: expected redacted remote session: ${JSON.stringify(data)}`); // Proof of bypass: no local relay was spawned → no local pidfile, and the // local agent port stays closed. assert(!existsSync(pidFileD), `D: --remote must NOT create a local pidfile, found ${pidFileD}`); assert(!(await probePort(remoteAgentPort)), `D: --remote must NOT open a local relay agent port ${remoteAgentPort}`); - console.log(` D remote bypass: no local relay/pidfile, connected to remote ${REMOTE_UUID} ✓`); + console.log(' D remote bypass: no local relay/pidfile, connected to configured remote target ✓'); rmSync(stateDirD, { recursive: true, force: true }); } diff --git a/scripts/e2e-devtools-flag.mjs b/scripts/e2e-devtools-flag.mjs index 79ec9cc..cc64f60 100644 --- a/scripts/e2e-devtools-flag.mjs +++ b/scripts/e2e-devtools-flag.mjs @@ -15,7 +15,7 @@ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const PACKAGE_ROOT = resolve(SCRIPT_DIR, '..'); const BROWSER_CLI = resolve(PACKAGE_ROOT, 'dist', 'browser-main.js'); const MCP_CLI = resolve(PACKAGE_ROOT, 'dist', 'cli.js'); -const REMOTE_UUID = 'devtools-mode-test-uuid'; +const REMOTE_UUID = '44444444-4444-4444-8444-444444444444'; function assert(condition, message) { if (!condition) throw new Error(message); diff --git a/scripts/e2e-http-streamable.mjs b/scripts/e2e-http-streamable.mjs index 3c8715d..c614d89 100644 --- a/scripts/e2e-http-streamable.mjs +++ b/scripts/e2e-http-streamable.mjs @@ -21,8 +21,8 @@ RESERVED_PORTS.add(RELAY_PORT); const RELAY_PORT_B = await findFreePort(RESERVED_PORTS); const RELAY_URL = `ws://${RELAY_HOST}:${RELAY_PORT}`; const RELAY_URL_B = `ws://${RELAY_HOST}:${RELAY_PORT_B}`; -const REMOTE_UUID = 'test-http-relay-uuid'; -const REMOTE_UUID_B = 'test-http-relay-uuid-b'; +const REMOTE_UUID = '22222222-2222-4222-8222-222222222222'; +const REMOTE_UUID_B = '33333333-3333-4333-8333-333333333333'; const SESSION_ID = REMOTE_UUID; const SESSION_ID_B = REMOTE_UUID_B; const MCP_URL = `http://${RELAY_HOST}:${MCP_HTTP_PORT}/mcp`; @@ -314,9 +314,12 @@ async function main() { throw new Error(`Expected set_remote text result: ${JSON.stringify(setRemoteResult)}`); } const setRemotePayload = JSON.parse(setRemoteText.text); - if (setRemotePayload.ok !== true || setRemotePayload.mode !== 'remote' || setRemotePayload.relayUrl !== RELAY_URL_B || setRemotePayload.uuid !== REMOTE_UUID_B) { + if (setRemotePayload.ok !== true || setRemotePayload.mode !== 'remote' || setRemotePayload.target !== '[redacted]') { throw new Error(`Unexpected set_remote payload: ${JSON.stringify(setRemotePayload)}`); } + if (setRemoteText.text.includes(REMOTE_UUID_B) || setRemoteText.text.includes(RELAY_URL_B)) { + throw new Error(`set_remote payload leaked relay credential: ${setRemoteText.text}`); + } const listToolsBPromise = waitForWebSocketMessage( extensionWsB, @@ -379,6 +382,31 @@ async function main() { throw new Error(`Unexpected relay B tool result: ${JSON.stringify(callResultB)}`); } + const redactedErrorRequestPromise = waitForWebSocketMessage( + extensionWsB, + (message) => message.type === 'call_tool' && message.data?.name === 'echo_b', + ); + const redactedErrorResultPromise = client.callTool({ + name: 'echo_b', + arguments: { text: 'reject' }, + }); + const redactedErrorRequest = await redactedErrorRequestPromise; + extensionWsB.send(JSON.stringify({ + type: 'tool_result', + requestId: redactedErrorRequest.requestId, + data: { + success: false, + isError: true, + detail: `Relay rejected ${REMOTE_UUID_B.toUpperCase()}`, + content: [{ type: 'text', text: `Relay rejected ${REMOTE_UUID_B.toUpperCase()}` }], + }, + })); + const redactedErrorResult = await withTimeout(redactedErrorResultPromise, 'redacted relay error response'); + const redactedErrorText = redactedErrorResult.content.find((item) => item.type === 'text')?.text ?? ''; + if (redactedErrorResult.isError !== true || !redactedErrorText.includes('[redacted]') || redactedErrorText.toLowerCase().includes(REMOTE_UUID_B.toLowerCase())) { + throw new Error(`MCP error leaked relay credential: ${JSON.stringify(redactedErrorResult)}`); + } + await withTimeout(client.close(), 'MCP client close'); console.log('http e2e ok'); } finally { diff --git a/scripts/e2e-local-startup-fallback.mjs b/scripts/e2e-local-startup-fallback.mjs index c505f7c..037e38e 100644 --- a/scripts/e2e-local-startup-fallback.mjs +++ b/scripts/e2e-local-startup-fallback.mjs @@ -98,9 +98,12 @@ async function main() { throw new Error(`Expected JSON response from set_remote with full URL, got: ${JSON.stringify(seedResult)}`); } const seedJson = JSON.parse(seedPayload.text); - if (!seedJson.ok || seedJson.uuid !== seedUuid || seedJson.relayUrl !== `ws://${HOST}:${remoteRelayPort}`) { + if (!seedJson.ok || seedJson.target !== '[redacted]') { throw new Error(`Unexpected set_remote full URL payload: ${seedPayload.text}`); } + if (seedPayload.text.includes(seedUuid) || seedPayload.text.includes(String(remoteRelayPort))) { + throw new Error(`set_remote full URL payload leaked relay credential: ${seedPayload.text}`); + } const uuidResult = await client.callTool({ name: 'set_remote', @@ -111,9 +114,12 @@ async function main() { throw new Error(`Expected JSON response from set_remote with UUID, got: ${JSON.stringify(uuidResult)}`); } const uuidJson = JSON.parse(uuidPayload.text); - if (!uuidJson.ok || uuidJson.uuid !== switchedUuid || uuidJson.relayUrl !== `ws://${HOST}:${remoteRelayPort}`) { + if (!uuidJson.ok || uuidJson.target !== '[redacted]') { throw new Error(`Unexpected set_remote UUID payload: ${uuidPayload.text}`); } + if (uuidPayload.text.includes(switchedUuid) || uuidPayload.text.includes(String(remoteRelayPort))) { + throw new Error(`set_remote UUID payload leaked relay credential: ${uuidPayload.text}`); + } console.log(PASS_MARKER); } finally { diff --git a/scripts/e2e-remote-lifecycle.mjs b/scripts/e2e-remote-lifecycle.mjs new file mode 100644 index 0000000..781b7e6 --- /dev/null +++ b/scripts/e2e-remote-lifecycle.mjs @@ -0,0 +1,641 @@ +#!/usr/bin/env node +import http from 'node:http'; +import net from 'node:net'; +import { setTimeout as delay } from 'node:timers/promises'; +import { WebSocketServer } from 'ws'; +import { + ExtensionConnection, + isPermanentRelayCloseCode, + isPermanentRelayHttpStatus, + parseRemoteRelayUrl, + redactRemoteTarget, + REDACTED_REMOTE_ID, +} from '../dist/connection.js'; + +const HOST = '127.0.0.1'; +const UUID_A = 'abcdefab-cdef-4abc-8def-abcdefabcdef'; +const UUID_B = 'fedcbafe-dcba-4fed-8cba-fedcbafedcba'; +const RECONNECT_DELAY_MS = 40; +const TOOL = { + name: 'lifecycle_test', + description: 'Lifecycle test tool', + inputSchema: { type: 'object', properties: {} }, +}; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function expectReject(fn, pattern, label) { + try { + await fn(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + assert(pattern.test(message), `${label}: expected ${pattern}, got ${message}`); + return message; + } + throw new Error(`${label}: expected rejection`); +} + +function findFreePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.once('error', reject); + server.listen(0, HOST, () => { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + server.close((error) => error ? reject(error) : resolve(port)); + }); + }); +} + +async function startSilentTcpServer(port) { + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, HOST, resolve); + }); + return { + close: async () => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(resolve)); + }, + }; +} + +function withTimeout(promise, label, timeoutMs = 3_000) { + let timer; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Timed out waiting for ${label}`)), timeoutMs); + }), + ]).finally(() => clearTimeout(timer)); +} + +function waitForEvent(emitter, event, predicate = () => true, timeoutMs = 3_000) { + return withTimeout(new Promise((resolve) => { + const listener = (value) => { + if (!predicate(value)) return; + emitter.off(event, listener); + resolve(value); + }; + emitter.on(event, listener); + }), event, timeoutMs); +} + +function startRelay(port, uuid) { + const wss = new WebSocketServer({ host: HOST, port }); + let connectionCount = 0; + const connected = new Promise((resolve) => { + wss.on('connection', (ws, request) => { + assert(request.url === `/${uuid}`, `unexpected relay path: ${request.url}`); + connectionCount += 1; + ws.send(JSON.stringify({ type: 'extension_status', connected: true })); + ws.send(JSON.stringify({ type: 'tools_list', data: [TOOL] })); + ws.send(JSON.stringify({ + type: 'sessions_list', + sessions: [{ sessionId: uuid, connected: true, toolCount: 1 }], + })); + resolve(ws); + }); + }); + return { + url: `ws://${HOST}:${port}/${uuid}`, + connected, + getConnectionCount: () => connectionCount, + close: async () => { + for (const client of wss.clients) client.terminate(); + await new Promise((resolve, reject) => wss.close((error) => error ? reject(error) : resolve())); + }, + }; +} + +function startClosingRelay(port, uuid, code, reason = '') { + const wss = new WebSocketServer({ host: HOST, port }); + let connectionCount = 0; + wss.on('connection', (ws, request) => { + assert(request.url === `/${uuid}`, `unexpected relay path: ${request.url}`); + connectionCount += 1; + setTimeout(() => ws.close(code, reason), 5); + }); + return { + url: `ws://${HOST}:${port}/${uuid}`, + getConnectionCount: () => connectionCount, + close: async () => { + for (const client of wss.clients) client.terminate(); + await new Promise((resolve, reject) => wss.close((error) => error ? reject(error) : resolve())); + }, + }; +} + +function startErrorRelay(port, uuid) { + const wss = new WebSocketServer({ host: HOST, port }); + const connected = new Promise((resolve) => { + wss.on('connection', (ws, request) => { + assert(request.url === `/${uuid}`, `unexpected relay path: ${request.url}`); + ws.send(JSON.stringify({ type: 'extension_status', connected: true })); + ws.send(JSON.stringify({ type: 'tools_list', data: [TOOL] })); + ws.on('message', (raw) => { + const message = JSON.parse(raw.toString()); + if (message.type === 'call_tool') { + ws.send(JSON.stringify({ + type: 'error', + requestId: message.requestId, + error: `Relay rejected WSS://${HOST}:${port}/${uuid.toUpperCase()} for ${uuid.toUpperCase()}`, + })); + } + }); + resolve(ws); + }); + }); + return { + url: `ws://${HOST}:${port}/${uuid}`, + connected, + close: async () => { + for (const client of wss.clients) client.terminate(); + await new Promise((resolve, reject) => wss.close((error) => error ? reject(error) : resolve())); + }, + }; +} + +function startAdversarialErrorRelay(port, uuid) { + const wss = new WebSocketServer({ host: HOST, port }); + const connected = new Promise((resolve) => { + wss.on('connection', (ws, request) => { + assert(request.url === `/${uuid}`, `unexpected relay path: ${request.url}`); + ws.send(JSON.stringify({ type: 'extension_status', connected: true })); + ws.send(JSON.stringify({ type: 'tools_list', data: [TOOL] })); + ws.on('message', (raw) => { + const message = JSON.parse(raw.toString()); + if (message.type !== 'call_tool') return; + if (message.data?.arguments?.malformed) { + ws.send(JSON.stringify({ + type: 'error', + requestId: message.requestId, + error: { credential: uuid.toUpperCase() }, + })); + return; + } + ws.send(JSON.stringify({ + type: 'tool_result', + requestId: message.requestId, + data: { + success: false, + isError: true, + content: [{ type: 'text', text: `Relay rejected ${uuid.toUpperCase()}` }], + }, + })); + }); + resolve(ws); + }); + }); + return { + url: `ws://${HOST}:${port}/${uuid}`, + connected, + close: async () => { + for (const client of wss.clients) client.terminate(); + await new Promise((resolve, reject) => wss.close((error) => error ? reject(error) : resolve())); + }, + }; +} + +async function startHttpRejectingRelay(port, statusCode) { + let attempts = 0; + const sockets = new Set(); + const server = http.createServer(); + server.on('upgrade', (_request, socket) => { + attempts += 1; + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + socket.end(`HTTP/1.1 ${statusCode} Rejected\r\nConnection: close\r\n\r\n`); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, HOST, resolve); + }); + return { + getAttempts: () => attempts, + close: async () => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(resolve)); + }, + }; +} + +async function startStallingReconnectRelay(port, uuid) { + const sockets = new Set(); + const wss = new WebSocketServer({ noServer: true }); + const server = http.createServer(); + let upgrades = 0; + let resolveReconnect; + const reconnectStarted = new Promise((resolve) => { resolveReconnect = resolve; }); + server.on('upgrade', (request, socket, head) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + upgrades += 1; + if (upgrades === 1) { + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit('connection', ws, request); + setTimeout(() => ws.close(1012, 'restart'), 5); + }); + return; + } + resolveReconnect(); + // Leave the stale reconnect handshake in CONNECTING until set_remote cancels it. + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, HOST, resolve); + }); + return { + url: `ws://${HOST}:${port}/${uuid}`, + reconnectStarted, + getUpgradeCount: () => upgrades, + close: async () => { + for (const client of wss.clients) client.terminate(); + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(resolve)); + }, + }; +} + +async function testUrlValidation() { + const valid = parseRemoteRelayUrl(`wss://relay.example.test/nested/${UUID_A}`); + assert(valid.uuid === UUID_A, 'valid WSS URL UUID was not parsed'); + parseRemoteRelayUrl(`ws://localhost:19888/${UUID_A}`); + parseRemoteRelayUrl(`ws://127.42.0.1:19888/${UUID_A}`); + parseRemoteRelayUrl(`ws://[::1]:19888/${UUID_A}`); + + await expectReject( + () => Promise.resolve(parseRemoteRelayUrl(`wss://relay.example.test/not-a-uuid`)), + /UUID path segment is not valid/, + 'invalid full URL UUID', + ); + await expectReject( + () => Promise.resolve(parseRemoteRelayUrl(`ws://relay.example.test/${UUID_A}`)), + /must use wss/, + 'non-loopback plaintext relay', + ); + await expectReject( + () => Promise.resolve(parseRemoteRelayUrl(`ws://192.0.2.10/${UUID_A}`)), + /must use wss/, + 'private/development non-loopback plaintext relay', + ); +} + +function testRetryPolicyMatrix() { + for (const code of [1002, 1003, 1007, 1008, 4001, 4003, 4004, 4009, 4401, 4403]) { + assert(isPermanentRelayCloseCode(code), `WS close ${code} should be permanent`); + } + for (const code of [1000, 1001, 1006, 1011, 1012, 4000, 4008]) { + assert(!isPermanentRelayCloseCode(code), `WS close ${code} should be retryable`); + } + for (const status of [400, 401, 403, 404]) { + assert(isPermanentRelayHttpStatus(status), `HTTP ${status} should be permanent`); + } + for (const status of [408, 425, 429, 500, 502, 503, 504]) { + assert(!isPermanentRelayHttpStatus(status), `HTTP ${status} should be retryable`); + } +} + +function testCaseInsensitiveRedaction() { + const fullUrl = `wss://relay.example.test/${UUID_A}`; + const upperUuid = UUID_A.toUpperCase(); + const message = `Relay rejected ${fullUrl.toUpperCase()} and ${upperUuid}`; + const redacted = redactRemoteTarget(message, fullUrl); + assert(!redacted.toLowerCase().includes(UUID_A.toLowerCase()), `redactor leaked UUID: ${redacted}`); + assert(!redacted.toLowerCase().includes(fullUrl.toLowerCase()), `redactor leaked full URL: ${redacted}`); + assert(redacted.includes(REDACTED_REMOTE_ID), `redactor omitted placeholder: ${redacted}`); +} + +async function testHandshakeTimeout() { + const port = await findFreePort(); + const server = await startSilentTcpServer(port); + const connection = new ExtensionConnection(0, false, { + uuid: UUID_A, + relayUrl: `ws://${HOST}:${port}`, + }, undefined, 75); + const started = Date.now(); + try { + await expectReject(() => connection.start(), /handshake timed out after 75ms/, 'handshake timeout'); + assert(Date.now() - started < 1_000, 'handshake timeout was not bounded'); + } finally { + await connection.stop(); + await server.close(); + } +} + +async function testUntrustedRelayErrorRedaction() { + const port = await findFreePort(); + const relay = startErrorRelay(port, UUID_A); + const debugOutput = []; + const originalConsoleError = console.error; + console.error = (...args) => debugOutput.push(args.map(String).join(' ')); + const connection = new ExtensionConnection(0, true, { uuid: relay.url }); + try { + await connection.start(); + await relay.connected; + const message = await expectReject( + () => connection.callTool(TOOL.name, {}), + /\[redacted\]/, + 'untrusted relay error redaction', + ); + assert(!message.toLowerCase().includes(UUID_A.toLowerCase()), `relay error leaked UUID: ${message}`); + assert(!message.toLowerCase().includes(relay.url.toLowerCase()), `relay error leaked full URL: ${message}`); + assert(!debugOutput.join('\n').toLowerCase().includes(UUID_A.toLowerCase()), `debug output leaked UUID: ${debugOutput.join('\n')}`); + } finally { + await connection.stop(); + await relay.close(); + console.error = originalConsoleError; + } +} + +async function testAdversarialRelayErrorsSettleAndRedact() { + const port = await findFreePort(); + const relay = startAdversarialErrorRelay(port, UUID_A); + const connection = new ExtensionConnection(0, false, { uuid: relay.url }); + try { + await connection.start(); + await relay.connected; + + const malformed = await expectReject( + () => withTimeout(connection.callTool(TOOL.name, { malformed: true }), 'malformed relay error settlement', 500), + /Unknown relay error/, + 'malformed relay error', + ); + assert(!malformed.toLowerCase().includes(UUID_A.toLowerCase()), `malformed relay error leaked UUID: ${malformed}`); + + const result = await connection.callTool(TOOL.name, {}); + const resultText = result.content?.find((entry) => entry.type === 'text')?.text ?? ''; + assert(result.isError === true, `expected error tool_result: ${JSON.stringify(result)}`); + assert(resultText.includes(REDACTED_REMOTE_ID), `error tool_result omitted redaction: ${JSON.stringify(result)}`); + assert(!JSON.stringify(result).toLowerCase().includes(UUID_A.toLowerCase()), `error tool_result leaked UUID: ${JSON.stringify(result)}`); + } finally { + await connection.stop(); + await relay.close(); + } +} + +async function testRepeatedStartIsIdempotent() { + const port = await findFreePort(); + const relay = startRelay(port, UUID_A); + const connection = new ExtensionConnection(0, false, { uuid: relay.url }, undefined, 500, RECONNECT_DELAY_MS); + try { + await Promise.all([connection.start(), connection.start()]); + const socket = await relay.connected; + await connection.start(); + + const toolsUpdated = waitForEvent(connection, 'tools_updated', (tools) => tools.some((tool) => tool.name === 'after_repeat_start')); + socket.send(JSON.stringify({ + type: 'tools_list', + data: [{ ...TOOL, name: 'after_repeat_start' }], + })); + await toolsUpdated; + assert(relay.getConnectionCount() === 1, `repeated start created ${relay.getConnectionCount()} sockets`); + + const disconnected = waitForEvent(connection, 'disconnected'); + socket.close(1012, 'restart'); + await disconnected; + await delay(RECONNECT_DELAY_MS * 3); + assert(relay.getConnectionCount() >= 2, 'repeated start invalidated reconnect callbacks'); + assert(connection.getStatus() === 'connected', `repeated start left status ${connection.getStatus()}`); + } finally { + await connection.stop(); + await relay.close(); + } +} + +async function testStateCleanupAndPermanentClose() { + const port = await findFreePort(); + const relay = startRelay(port, UUID_A); + const debugOutput = []; + const originalConsoleError = console.error; + console.error = (...args) => debugOutput.push(args.map(String).join(' ')); + const connection = new ExtensionConnection(0, true, { uuid: relay.url }); + try { + await connection.start(); + const socket = await relay.connected; + await waitForEvent(connection, 'tools_updated', (tools) => tools.length === 1).catch(() => {}); + await delay(25); + assert(connection.getTools().length === 1, 'tools did not populate before cleanup test'); + assert(connection.getSessions().length === 1, 'sessions did not populate before cleanup test'); + + const statusCleared = waitForEvent(connection, 'extension_status', (connected) => connected === false); + socket.send(JSON.stringify({ type: 'extension_status', connected: false })); + await statusCleared; + assert(connection.getTools().length === 0, 'extension_status:false retained tools'); + assert(connection.getSessions().length === 0, 'extension_status:false retained sessions'); + assert(connection.isExtensionConnected() === false, 'extension_status:false retained connected state'); + + socket.send(JSON.stringify({ type: 'extension_status', connected: true })); + socket.send(JSON.stringify({ type: 'tools_list', data: [TOOL] })); + socket.send(JSON.stringify({ + type: 'sessions_list', + sessions: [{ sessionId: UUID_A, connected: true, toolCount: 1 }], + })); + await delay(25); + + const disconnected = waitForEvent(connection, 'disconnected'); + socket.close(1008, `credential rejected: ${UUID_A}`); + const close = await disconnected; + assert(close.code === 1008, `close code was not surfaced: ${JSON.stringify(close)}`); + assert(close.permanent === true, `permanent close was not classified: ${JSON.stringify(close)}`); + assert(close.reason.includes(REDACTED_REMOTE_ID), `close reason was not redacted: ${JSON.stringify(close)}`); + assert(!close.reason.includes(UUID_A), `close reason leaked UUID: ${JSON.stringify(close)}`); + assert(connection.getTools().length === 0, 'socket close retained tools'); + assert(connection.getSessions().length === 0, 'socket close retained sessions'); + assert(connection.getConnectionErrorMessage().includes('code 1008'), 'connection error omitted close code'); + assert(!connection.getConnectionErrorMessage().includes(UUID_A), 'connection error leaked UUID'); + + await delay(2_250); + assert(relay.getConnectionCount() === 1, `permanent close retried ${relay.getConnectionCount()} times`); + assert(!debugOutput.join('\n').includes(UUID_A), `debug output leaked remote UUID: ${debugOutput.join('\n')}`); + } finally { + await connection.stop(); + await relay.close(); + console.error = originalConsoleError; + } +} + +async function testPermanentRelayCloseMatrix() { + for (const code of [4004, 4009]) { + const port = await findFreePort(); + const relay = startClosingRelay(port, UUID_A, code, code === 4004 ? 'unknown UUID' : 'revoked UUID'); + const connection = new ExtensionConnection( + 0, + false, + { uuid: relay.url }, + undefined, + 500, + RECONNECT_DELAY_MS, + ); + try { + await connection.start(); + const close = await waitForEvent(connection, 'disconnected'); + assert(close.code === code && close.permanent === true, `WS ${code} was not permanent: ${JSON.stringify(close)}`); + await delay(RECONNECT_DELAY_MS * 3); + assert(relay.getConnectionCount() === 1, `WS ${code} retried ${relay.getConnectionCount()} times`); + } finally { + await connection.stop(); + await relay.close(); + } + } +} + +async function testConcurrentSetRemote() { + const slowPort = await findFreePort(); + const finalPort = await findFreePort(); + const slowServer = await startSilentTcpServer(slowPort); + const finalRelay = startRelay(finalPort, UUID_B); + const connection = new ExtensionConnection(0, false, undefined, undefined, 100); + const uncaught = []; + const onUncaught = (error) => uncaught.push(error); + process.on('uncaughtException', onUncaught); + try { + const first = connection.setRemoteUrl(`ws://${HOST}:${slowPort}/${UUID_A}`); + const second = connection.setRemoteUrl(finalRelay.url); + await expectReject(() => first, /handshake timed out/, 'first concurrent set_remote'); + await withTimeout(second, 'second concurrent set_remote'); + await finalRelay.connected; + assert(connection.getRemoteConfig()?.uuid === UUID_B, 'last concurrent set_remote did not win'); + assert(connection.getStatus() === 'connected', 'final concurrent set_remote is not connected'); + await delay(50); + assert(uncaught.length === 0, `concurrent set_remote raised uncaught errors: ${uncaught}`); + } finally { + process.off('uncaughtException', onUncaught); + await connection.stop(); + await slowServer.close(); + await finalRelay.close(); + } +} + +async function testReconnectRaceWithSetRemote() { + const oldPort = await findFreePort(); + const finalPort = await findFreePort(); + const oldRelay = await startStallingReconnectRelay(oldPort, UUID_A); + const finalRelay = startRelay(finalPort, UUID_B); + const connection = new ExtensionConnection( + 0, + false, + { uuid: oldRelay.url }, + undefined, + 500, + RECONNECT_DELAY_MS, + ); + try { + await connection.start(); + await waitForEvent(connection, 'disconnected'); + await withTimeout(oldRelay.reconnectStarted, 'stale reconnect start'); + await connection.setRemoteUrl(finalRelay.url); + await finalRelay.connected; + await delay(RECONNECT_DELAY_MS * 3); + assert(connection.getRemoteConfig()?.uuid === UUID_B, 'reconnect race replaced final remote config'); + assert(connection.getStatus() === 'connected', 'reconnect race disconnected final target'); + assert(finalRelay.getConnectionCount() === 1, `reconnect race created ${finalRelay.getConnectionCount()} final sockets`); + assert(oldRelay.getUpgradeCount() === 2, `expected one stale reconnect, got ${oldRelay.getUpgradeCount() - 1}`); + } finally { + await connection.stop(); + await oldRelay.close(); + await finalRelay.close(); + } +} + +async function testStopCancelsQueuedSetRemote() { + const slowPort = await findFreePort(); + const queuedPort = await findFreePort(); + const slowServer = await startSilentTcpServer(slowPort); + const queuedRelay = startRelay(queuedPort, UUID_B); + const connection = new ExtensionConnection(0, false, undefined, undefined, 500, RECONNECT_DELAY_MS); + try { + const active = connection.setRemoteUrl(`ws://${HOST}:${slowPort}/${UUID_A}`); + const queued = connection.setRemoteUrl(queuedRelay.url); + await delay(25); + await connection.stop(); + await expectReject(() => active, /closed before the connection was established|connection closed/i, 'active set_remote after stop'); + await expectReject(() => queued, /Connection closed/, 'queued set_remote after stop'); + await delay(RECONNECT_DELAY_MS * 2); + assert(queuedRelay.getConnectionCount() === 0, 'queued set_remote connected after stop'); + assert(connection.getStatus() === 'disconnected', 'queued set_remote changed stopped status'); + } finally { + await connection.stop(); + await slowServer.close(); + await queuedRelay.close(); + } +} + +async function testStopWhileConnecting() { + const port = await findFreePort(); + const server = await startSilentTcpServer(port); + const connection = new ExtensionConnection(0, false, { + uuid: UUID_A, + relayUrl: `ws://${HOST}:${port}`, + }, undefined, 2_000); + const uncaught = []; + const onUncaught = (error) => uncaught.push(error); + process.on('uncaughtException', onUncaught); + try { + const start = connection.start(); + await delay(25); + await connection.stop(); + await expectReject(() => start, /closed before the connection was established|connection closed/i, 'stop while CONNECTING'); + await delay(25); + assert(uncaught.length === 0, `stop while CONNECTING raised uncaught errors: ${uncaught}`); + } finally { + process.off('uncaughtException', onUncaught); + await connection.stop(); + await server.close(); + } +} + +async function testHttpHandshakeRejectionMatrix() { + for (const { status, permanent } of [ + { status: 403, permanent: true }, + { status: 408, permanent: false }, + { status: 429, permanent: false }, + ]) { + const port = await findFreePort(); + const relay = await startHttpRejectingRelay(port, status); + const connection = new ExtensionConnection(0, false, { + uuid: UUID_A, + relayUrl: `ws://${HOST}:${port}`, + }, undefined, 500, RECONNECT_DELAY_MS); + try { + await expectReject(() => connection.start(), new RegExp(`HTTP ${status}`), `HTTP ${status} relay rejection`); + await delay(RECONNECT_DELAY_MS * 3); + const attempts = relay.getAttempts(); + if (permanent) { + assert(attempts === 1, `HTTP ${status} retried ${attempts} times`); + } else { + assert(attempts >= 2, `HTTP ${status} did not retry: ${attempts} attempt(s)`); + } + const lastClose = connection.getLastClose(); + assert(lastClose?.code === status, `HTTP ${status} diagnostic was overwritten: ${JSON.stringify(lastClose)}`); + assert(lastClose?.reason.includes(`HTTP ${status}`), `HTTP ${status} reason was overwritten: ${JSON.stringify(lastClose)}`); + } finally { + await connection.stop(); + await relay.close(); + } + } +} + +await testUrlValidation(); +testRetryPolicyMatrix(); +testCaseInsensitiveRedaction(); +await testHandshakeTimeout(); +await testUntrustedRelayErrorRedaction(); +await testAdversarialRelayErrorsSettleAndRedact(); +await testRepeatedStartIsIdempotent(); +await testStateCleanupAndPermanentClose(); +await testPermanentRelayCloseMatrix(); +await testConcurrentSetRemote(); +await testReconnectRaceWithSetRemote(); +await testStopCancelsQueuedSetRemote(); +await testStopWhileConnecting(); +await testHttpHandshakeRejectionMatrix(); +console.log('remote lifecycle e2e ok'); diff --git a/scripts/e2e-tool-annotations.mjs b/scripts/e2e-tool-annotations.mjs index e451a8d..bd8d9fb 100644 --- a/scripts/e2e-tool-annotations.mjs +++ b/scripts/e2e-tool-annotations.mjs @@ -40,7 +40,7 @@ import { CHROME_USE_TOOLS } from '../dist/chrome-use-connection.js'; import { SET_REMOTE_TOOL } from '../dist/server.js'; const RELAY_HOST = '127.0.0.1'; -const REMOTE_UUID = 'test-annotations-uuid'; +const REMOTE_UUID = '66666666-6666-4666-8666-666666666667'; const REQUIRED_HINTS = ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint']; diff --git a/scripts/e2e-tools-list-startup-budget.mjs b/scripts/e2e-tools-list-startup-budget.mjs index 5e8ceaf..f76e625 100644 --- a/scripts/e2e-tools-list-startup-budget.mjs +++ b/scripts/e2e-tools-list-startup-budget.mjs @@ -26,7 +26,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; const HOST = '127.0.0.1'; -const REMOTE_UUID = 'test-tools-list-budget-uuid'; +const REMOTE_UUID = '77777777-7777-4777-8777-777777777777'; const SESSION_ID = REMOTE_UUID; // Must beat a typical client startup budget (10s) and the old ~5.5s blocking path. const TOOLS_LIST_MAX_MS = 4_500; diff --git a/scripts/e2e-uuid-only-auth.mjs b/scripts/e2e-uuid-only-auth.mjs index e2cc1ed..0b93d0f 100644 --- a/scripts/e2e-uuid-only-auth.mjs +++ b/scripts/e2e-uuid-only-auth.mjs @@ -234,9 +234,19 @@ async function main() { assert(!conn.authorization, `browser-cli must not send Authorization header (UUID-only auth): got "${conn.authorization}"`); assert(cliStatus.json?.ok === true, `browser-cli status failed: ${cliStatus.stdout}\n${cliStatus.stderr}`); assert(cliStatus.json?.mode === 'remote', `browser-cli status mode mismatch: ${cliStatus.stdout}`); - assert(cliStatus.json?.sessionId === UUID, `browser-cli status sessionId mismatch: ${cliStatus.stdout}`); + assert(cliStatus.json?.sessionId === '[redacted]', `browser-cli status sessionId should be redacted: ${cliStatus.stdout}`); + assert(!cliStatus.stdout.includes(UUID), `browser-cli status leaked remote UUID: ${cliStatus.stdout}`); console.log(' browser-cli UUID-only connect, no Authorization header sent: PASS'); + const openClaw = await runNodeProcess(MCP_CLI, [ + 'openclaw', + '--remote', relay.url, + ], { expectCode: 0 }); + assert(!openClaw.stdout.includes(UUID), `openclaw output leaked remote UUID: ${openClaw.stdout}`); + assert(!openClaw.stdout.includes(relay.url), `openclaw output leaked full relay URL: ${openClaw.stdout}`); + assert(openClaw.stdout.includes('VIBE_REMOTE_URL'), `openclaw output omitted secure env placeholder: ${openClaw.stdout}`); + console.log(' openclaw output redacts relay credential: PASS'); + // 2. --remote-secret is a removed flag: commander must reject it outright. const rejectSecretFlag = await runNodeProcess(BROWSER_CLI, [ '--remote', relay.url, diff --git a/src/browser-cli.ts b/src/browser-cli.ts index 4a0428b..81bd4f6 100644 --- a/src/browser-cli.ts +++ b/src/browser-cli.ts @@ -2,7 +2,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { basename, extname, resolve } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; import { Command } from 'commander'; -import { ExtensionConnection } from './connection.js'; +import { ExtensionConnection, redactRemoteTarget, REDACTED_REMOTE_ID } from './connection.js'; import { ChromeUseConnection, type CdpConnector } from './chrome-use-connection.js'; import { DEFAULT_WS_PORT, type RelaySessionSummary, type ToolDefinition, type ToolResult } from './types.js'; @@ -508,7 +508,8 @@ async function runBrowserCommand( if (ctx) { emitError(Boolean(globalOptions.json), commandName, ctx, error); } else { - const message = error instanceof Error ? error.message : String(error); + const rawMessage = error instanceof Error ? error.message : String(error); + const message = redactRemoteTarget(rawMessage, globalOptions.remote); if (Boolean(globalOptions.json)) { console.log(JSON.stringify({ ok: false, @@ -661,6 +662,9 @@ export class BrowserCliContext { : false; const extensionConnected = this.isBackendConnected(); const shouldFailClosed = failIfDisconnected && Boolean(this.remoteUuid) && !extensionConnected; + const relayClose = this.connection instanceof ExtensionConnection + ? this.connection.getLastClose() + : null; return { ok: !shouldFailClosed, @@ -673,6 +677,9 @@ export class BrowserCliContext { ignoredCompatibilityOptions: this.ignoredCompatibilityOptions, relayConnected, extensionConnected, + ...(relayClose + ? { relayCloseCode: relayClose.code, relayCloseReason: relayClose.reason } + : {}), managedLifecycle: false, transport: 'vibebrowser-mcp', toolCount: this.tools.length, @@ -1199,6 +1206,9 @@ export class BrowserCliContext { } try { const result = await this.connection.callTool(tool.name, args, this.timeoutMs); + if (result.isError === true || result.success === false) { + throw new Error(firstText(result as ToolResult & Record) || `Browser tool ${tool.name} failed`); + } return { tool: tool.name, args, result: result as ToolResult & Record }; } catch (error) { if (!isToolArgumentCompatibilityError(error)) { @@ -1437,9 +1447,7 @@ export class BrowserCliContext { return undefined; } if (this.remoteUuid) { - return this.connection instanceof ExtensionConnection - ? this.connection.getRemoteConfig()?.uuid ?? this.remoteUuid - : this.remoteUuid; + return REDACTED_REMOTE_ID; } const connectedSessionIds = new Set( @@ -1473,6 +1481,12 @@ export class BrowserCliContext { return this.mode(); } + redactErrorMessage(message: string): string { + return this.connection instanceof ExtensionConnection + ? this.connection.redactErrorMessage(message) + : message; + } + outputContext(): Pick { return { profile: this.profile, @@ -1710,7 +1724,8 @@ function emitError( ctx: BrowserCliContext, error: unknown, ): void { - let message = error instanceof Error ? error.message : String(error); + const rawMessage = error instanceof Error ? error.message : String(error); + let message = ctx.redactErrorMessage(rawMessage); // Improve guidance when the extension requires a pageId that wasn't provided if (/\bpageId\b/i.test(message) && /\bmissing\b|\brequired\b/i.test(message)) { message += '\nHint: use `tabs` to list pages, then pass --page-id to target a specific tab.'; @@ -1739,6 +1754,8 @@ function formatHumanOutput(commandName: string, output: CommandOutput): string { output.sessionId ? `Session: ${String(output.sessionId)}` : null, output.requestedSessionId && output.requestedSessionId !== output.sessionId ? `Requested session: ${String(output.requestedSessionId)}` : null, `Relay connected: ${boolText(output.relayConnected)}`, + output.relayCloseCode !== undefined ? `Relay close code: ${String(output.relayCloseCode)}` : null, + output.relayCloseReason ? `Relay close reason: ${String(output.relayCloseReason)}` : null, `Extension connected: ${boolText(output.extensionConnected)}`, `Managed lifecycle: ${boolText(output.managedLifecycle)}`, output.toolCount !== undefined ? `Tools: ${String(output.toolCount)}` : null, diff --git a/src/cli.ts b/src/cli.ts index df83130..bec1cf4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,7 @@ import { program } from 'commander'; import { registerBrowserCommand } from './browser-cli.js'; +import { normalizeRemoteConfig } from './connection.js'; import { createServer } from './server.js'; import { DEFAULT_HTTP_PATH, @@ -84,6 +85,7 @@ program .option('--allow-host ', 'Allowed host header for HTTP transport (repeatable)', collectRepeatedOption, []) .action((options) => { try { + normalizeRemoteConfig({ uuid: String(options.remote) }); const httpPort = parsePort(options.httpPort, 'HTTP port'); const httpPath = normalizePath(options.httpPath); const host = options.host; @@ -105,7 +107,7 @@ program '--http-path', httpPath, '--remote', - options.remote, + '"$VIBE_REMOTE_URL"', ]; for (const allowedHost of options.allowHost as string[]) { @@ -121,6 +123,7 @@ program }; console.log('Start a local bridge on the machine running the Vibe extension:'); + console.log('Set VIBE_REMOTE_URL securely to the UUID or full relay URL, then run:'); console.log(`npx ${cliArgs.join(' ')}`); console.log(''); console.log('Local bridge MCP URL:'); diff --git a/src/connection.ts b/src/connection.ts index 0be3426..c688a39 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -34,9 +34,17 @@ const NO_CONNECTION_REMOTE_MESSAGE = `No connection to Vibe extension via remote const RELAY_CONNECT_TIMEOUT = 10000; const RELAY_RECONNECT_DELAY = 2000; +export const DEFAULT_RELAY_HANDSHAKE_TIMEOUT_MS = 10_000; +export const REDACTED_REMOTE_ID = '[redacted]'; const DEFAULT_RELAY_URL = 'wss://relay.api.vibebrowser.app'; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const PERMANENT_RELAY_CLOSE_CODES = new Set([ + 1002, 1003, 1007, 1008, + 4001, 4003, 4004, 4009, + 4401, 4403, +]); +const PERMANENT_RELAY_HTTP_STATUS_CODES = new Set([400, 401, 403, 404]); /** * Remote relay configuration. @@ -55,36 +63,104 @@ export interface ParsedRemoteRelayUrl { uuid: string; } +export interface RelayCloseInfo { + code: number; + reason: string; + permanent: boolean; +} + +export function isPermanentRelayCloseCode(code: number): boolean { + return PERMANENT_RELAY_CLOSE_CODES.has(code); +} + +export function isPermanentRelayHttpStatus(statusCode: number): boolean { + return PERMANENT_RELAY_HTTP_STATUS_CODES.has(statusCode); +} + +export function redactRemoteTarget(message: string, target?: string): string { + if (!target) { + return message; + } + + const candidates = new Set([target]); + try { + const parsed = new URL(target); + const uuid = parsed.pathname.split('/').filter(Boolean).at(-1); + if (uuid) candidates.add(uuid); + } catch { + // A bare UUID is already included as the target candidate. + } + + return [...candidates] + .sort((a, b) => b.length - a.length) + .reduce((redacted, candidate) => { + const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return redacted.replace(new RegExp(escaped, 'gi'), REDACTED_REMOTE_ID); + }, message); +} + function isRemoteRelayUrl(value: string): boolean { return /^wss?:\/\//i.test(value); } -export function parseRemoteRelayUrl(value: string): ParsedRemoteRelayUrl { - let parsed: URL; - try { - parsed = new URL(value); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Invalid remote relay URL: ${message}`); +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, ''); + if (normalized === 'localhost' || normalized === '::1') { + return true; } + const octets = normalized.split('.'); + return octets.length === 4 + && octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255) + && Number(octets[0]) === 127; +} + +function validateRelayUrl(parsed: URL): void { if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') { throw new Error('Invalid remote relay URL: protocol must be ws:// or wss://'); } - + if (parsed.protocol === 'ws:' && !isLoopbackHostname(parsed.hostname)) { + throw new Error('Invalid remote relay URL: non-loopback relays must use wss://'); + } if (parsed.username || parsed.password) { throw new Error('Invalid remote relay URL: credentials in URL are not allowed'); } - if (parsed.search || parsed.hash) { throw new Error('Invalid remote relay URL: query/fragments are not allowed'); } +} + +function normalizeRelayBaseUrl(value: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error('Invalid remote relay URL'); + } + + validateRelayUrl(parsed); + return parsed.toString().replace(/\/$/, ''); +} + +export function parseRemoteRelayUrl(value: string): ParsedRemoteRelayUrl { + let parsed: URL; + try { + parsed = new URL(value); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid remote relay URL: ${message}`); + } + + validateRelayUrl(parsed); const pathSegments = parsed.pathname.split('/').filter(Boolean); const uuid = pathSegments[pathSegments.length - 1]; if (!uuid) { throw new Error('Invalid remote relay URL: missing UUID path segment'); } + if (!UUID_PATTERN.test(uuid)) { + throw new Error('Invalid remote relay URL: UUID path segment is not valid'); + } pathSegments.pop(); parsed.pathname = pathSegments.length > 0 ? `/${pathSegments.join('/')}` : ''; @@ -92,7 +168,7 @@ export function parseRemoteRelayUrl(value: string): ParsedRemoteRelayUrl { parsed.hash = ''; return { - relayUrl: parsed.toString().replace(/\/$/, ''), + relayUrl: normalizeRelayBaseUrl(parsed.toString()), uuid, }; } @@ -107,7 +183,7 @@ function parseRemoteTarget(value: string, currentRelayUrl?: string): ParsedRemot } return { - relayUrl: (currentRelayUrl || DEFAULT_RELAY_URL).replace(/\/$/, ''), + relayUrl: normalizeRelayBaseUrl(currentRelayUrl || DEFAULT_RELAY_URL), uuid: value, }; } @@ -117,19 +193,15 @@ export function normalizeRemoteConfig(remote?: RemoteConfig): RemoteConfig | und return undefined; } - const relayUrl = remote.relayUrl?.replace(/\/$/, ''); - if (!isRemoteRelayUrl(remote.uuid)) { - return { uuid: remote.uuid, relayUrl }; - } - - const parsed = parseRemoteRelayUrl(remote.uuid); - if (relayUrl && relayUrl !== parsed.relayUrl) { - throw new Error(`Remote relay URL mismatch: remote URL includes ${parsed.relayUrl}, but configured relay URL is ${relayUrl}`); + const relayUrl = remote.relayUrl ? normalizeRelayBaseUrl(remote.relayUrl) : undefined; + const parsed = parseRemoteTarget(remote.uuid, relayUrl); + if (relayUrl && isRemoteRelayUrl(remote.uuid) && relayUrl !== parsed.relayUrl) { + throw new Error('Remote relay URL mismatch'); } return { uuid: parsed.uuid, - relayUrl: relayUrl || parsed.relayUrl, + relayUrl: parsed.relayUrl, }; } @@ -166,13 +238,29 @@ export class ExtensionConnection extends EventEmitter { private remoteConfig: RemoteConfig | null = null; private localSessionConfig: LocalSessionConfig; private stopping = false; - - constructor(port: number = AGENT_PORT, debug: boolean = false, remote?: RemoteConfig, localSessionConfig?: LocalSessionConfig) { + private connectionGeneration = 0; + private lifecycleEpoch = 0; + private shutdownEpoch = 0; + private startPromise: Promise | null = null; + private remoteUpdate: Promise = Promise.resolve(); + private reconnectSuppressed = false; + private lastClose: { code: number; reason: string } | null = null; + private readonly handshakeTimeoutMs: number; + + constructor( + port: number = AGENT_PORT, + debug: boolean = false, + remote?: RemoteConfig, + localSessionConfig?: LocalSessionConfig, + handshakeTimeoutMs: number = DEFAULT_RELAY_HANDSHAKE_TIMEOUT_MS, + private readonly reconnectDelayMs: number = RELAY_RECONNECT_DELAY, + ) { super(); this.port = port; this.debug = debug; this.remoteConfig = normalizeRemoteConfig(remote) || null; this.localSessionConfig = localSessionConfig || {}; + this.handshakeTimeoutMs = handshakeTimeoutMs; } /** @@ -180,45 +268,74 @@ export class ExtensionConnection extends EventEmitter { * In local mode: spawns relay daemon if needed, then connects. * In remote mode: connects directly to public relay. */ - async start(): Promise { - this.stopping = false; - if (this.remoteConfig) { - this.log(`Remote mode: connecting to relay for UUID ${this.remoteConfig.uuid}`); - await this.connectToRelay(); - return; + start(): Promise { + if (this.ws && this.status === 'connected') { + return Promise.resolve(); } - - // Local mode: check if relay is already running - if (!isRelayRunning()) { - this.log('Starting relay daemon...'); - await this.spawnRelay(); - // Wait for relay to start - await this.waitForRelay(); + if (this.startPromise) { + return this.startPromise; } - // Connect to relay - await this.connectToRelay(); + const operation = (async () => { + this.stopping = false; + this.reconnectSuppressed = false; + const lifecycleEpoch = ++this.lifecycleEpoch; + if (this.remoteConfig) { + this.log('Remote mode: connecting to configured relay target'); + await this.connectToRelay(lifecycleEpoch); + return; + } + + // Local mode: check if relay is already running + if (!isRelayRunning()) { + this.log('Starting relay daemon...'); + await this.spawnRelay(); + // Wait for relay to start + await this.waitForRelay(); + } + + // Connect to relay + await this.connectToRelay(lifecycleEpoch); + })(); + + this.startPromise = operation; + void operation.finally(() => { + if (this.startPromise === operation) { + this.startPromise = null; + } + }).catch(() => {}); + return operation; } async setRemoteUrl(url: string): Promise { - const parsed = parseRemoteTarget(url, this.remoteConfig?.relayUrl); + if (this.stopping) { + throw new Error('Connection closed'); + } + const shutdownEpoch = this.shutdownEpoch; + const update = this.remoteUpdate.then(async () => { + if (this.stopping || shutdownEpoch !== this.shutdownEpoch) { + throw new Error('Connection closed'); + } - this.stopping = true; - this.clearReconnectTimer(); - this.rejectPendingRequests(new Error('Remote relay changed')); - this.closeSocket(); + const parsed = parseRemoteTarget(url, this.remoteConfig?.relayUrl); + const lifecycleEpoch = ++this.lifecycleEpoch; - this.remoteConfig = { uuid: parsed.uuid, relayUrl: parsed.relayUrl }; - this.tools = []; - this.sessions = []; - this.extensionConnected = false; - this.status = 'disconnected'; - this.emit('tools_updated', this.tools); - this.emit('extension_status', false); + this.clearReconnectTimer(); + this.rejectPendingRequests(new Error('Remote relay changed')); + this.closeSocket(); + + this.remoteConfig = { uuid: parsed.uuid, relayUrl: parsed.relayUrl }; + this.status = 'disconnected'; + this.lastClose = null; + this.reconnectSuppressed = false; + this.clearExtensionState(); + + await this.connectToRelay(lifecycleEpoch); + return parsed; + }); - this.stopping = false; - await this.connectToRelay(); - return parsed; + this.remoteUpdate = update.then(() => undefined, () => undefined); + return update; } /** @@ -296,25 +413,83 @@ export class ExtensionConnection extends EventEmitter { return this.remoteConfig ? { ...this.remoteConfig } : null; } + getLastClose(): RelayCloseInfo | null { + return this.lastClose + ? { ...this.lastClose, permanent: this.reconnectSuppressed } + : null; + } + /** * Connect to the relay server (local or remote) */ - private async connectToRelay(): Promise { + private async connectToRelay(lifecycleEpoch: number): Promise { + if (this.stopping || lifecycleEpoch !== this.lifecycleEpoch) { + throw new Error('Relay connection superseded'); + } + if (this.ws) { + if (this.status === 'connected') { + return; + } + throw new Error('Relay connection already in progress'); + } + return new Promise((resolve, reject) => { const url = this.getRelayUrl(); - this.log(`Connecting to relay at ${url}...`); + const generation = ++this.connectionGeneration; + let settled = false; + let handshakeTimer: NodeJS.Timeout | null = null; + let handshakeFailure: { error: Error; info: RelayCloseInfo } | null = null; + let ws: WebSocket; + this.log(this.remoteConfig ? 'Connecting to configured remote relay...' : `Connecting to local relay at ${url}...`); + + const isCurrent = () => lifecycleEpoch === this.lifecycleEpoch + && generation === this.connectionGeneration + && this.ws === ws + && url === this.getRelayUrl(); + + const settleResolve = () => { + if (settled) return; + settled = true; + if (handshakeTimer) clearTimeout(handshakeTimer); + resolve(); + }; + const settleReject = (error: Error) => { + if (settled) return; + settled = true; + if (handshakeTimer) clearTimeout(handshakeTimer); + reject(error); + }; try { - this.ws = new WebSocket(url); + ws = new WebSocket(url); + this.ws = ws; + this.status = 'connecting'; + handshakeTimer = setTimeout(() => { + const error = new Error(`Relay handshake timed out after ${this.handshakeTimeoutMs}ms`); + this.log(error.message); + settleReject(error); + if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) { + ws.terminate(); + } + }, this.handshakeTimeoutMs); - this.ws.on('open', () => { + ws.on('open', () => { + if (!isCurrent()) { + ws.terminate(); + settleReject(new Error('Relay connection superseded')); + return; + } this.log('Connected to relay'); this.status = 'connected'; + this.lastClose = null; this.emit('connected'); - resolve(); + settleResolve(); }); - this.ws.on('message', (data) => { + ws.on('message', (data) => { + if (!isCurrent()) { + return; + } try { const message = JSON.parse(data.toString()); this.handleMessage(message); @@ -323,36 +498,67 @@ export class ExtensionConnection extends EventEmitter { } }); - this.ws.on('close', () => { - this.log('Disconnected from relay'); + ws.on('close', (code, reasonBuffer) => { + const reason = reasonBuffer.toString(); + const safeReason = this.redactRemoteCredential(reason); + const closeInfo = handshakeFailure?.info ?? { + code, + reason: safeReason, + permanent: isPermanentRelayCloseCode(code), + }; + const closeError = handshakeFailure?.error ?? new Error(this.formatCloseMessage(code, reason)); + settleReject(closeError); + + if (!isCurrent()) { + return; + } + + this.lastClose = { code: closeInfo.code, reason: closeInfo.reason }; + this.reconnectSuppressed = closeInfo.permanent; + if (!handshakeFailure) { + this.log(this.formatCloseMessage(code, reason)); + } this.ws = null; this.status = 'disconnected'; + this.rejectPendingRequests(closeError); + this.clearExtensionState(); + this.emit('disconnected', closeInfo); - // Reject all pending requests — responses will never arrive on a - // closed socket. Without this, requests sit until their individual - // timeouts fire, and if the server reconnects before that the MCP - // client may retry, causing duplicate tool execution. - for (const [id, request] of this.pendingRequests) { - clearTimeout(request.timeout); - request.reject(new Error('Relay connection lost')); + if (!this.stopping && !this.reconnectSuppressed) { + this.scheduleReconnect(lifecycleEpoch); } - this.pendingRequests.clear(); - - this.emit('disconnected'); + }); - if (!this.stopping) { - // Schedule reconnect - this.scheduleReconnect(); + ws.on('error', (error) => { + const safeError = new Error(this.redactRemoteCredential(error.message)); + if (isCurrent()) { + this.log(`WebSocket error: ${safeError.message}`); } + settleReject(safeError); }); - this.ws.on('error', (error) => { - this.log(`WebSocket error: ${error.message}`); - reject(error); + ws.on('unexpected-response', (_request, response) => { + const statusCode = response.statusCode ?? 0; + const safeError = new Error(`Relay handshake rejected with HTTP ${statusCode || 'unknown status'}`); + const permanent = isPermanentRelayHttpStatus(statusCode); + handshakeFailure = { + error: safeError, + info: { code: statusCode, reason: safeError.message, permanent }, + }; + if (isCurrent()) { + this.reconnectSuppressed = permanent; + this.lastClose = { code: statusCode, reason: safeError.message }; + this.status = 'disconnected'; + this.log(safeError.message); + } + settleReject(safeError); + response.resume(); + ws.terminate(); }); } catch (error) { - reject(error); + const message = error instanceof Error ? error.message : String(error); + settleReject(new Error(this.redactRemoteCredential(message))); } }); } @@ -360,20 +566,27 @@ export class ExtensionConnection extends EventEmitter { /** * Schedule reconnection attempt */ - private scheduleReconnect(): void { + private scheduleReconnect(lifecycleEpoch: number): void { + if (this.stopping || this.reconnectSuppressed || lifecycleEpoch !== this.lifecycleEpoch || this.ws) { + return; + } if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); } this.reconnectTimer = setTimeout(async () => { + this.reconnectTimer = null; + if (this.stopping || this.reconnectSuppressed || lifecycleEpoch !== this.lifecycleEpoch || this.ws) { + return; + } this.log('Attempting to reconnect to relay...'); try { - await this.connectToRelay(); + await this.connectToRelay(lifecycleEpoch); } catch (error) { this.log(`Reconnect failed: ${error}`); - this.scheduleReconnect(); + this.scheduleReconnect(lifecycleEpoch); } - }, RELAY_RECONNECT_DELAY); + }, this.reconnectDelayMs); } /** @@ -381,12 +594,16 @@ export class ExtensionConnection extends EventEmitter { */ async stop(): Promise { this.stopping = true; + ++this.shutdownEpoch; + ++this.lifecycleEpoch; + this.startPromise = null; this.clearReconnectTimer(); this.rejectPendingRequests(new Error('Connection closed')); this.closeSocket(); this.status = 'disconnected'; + this.clearExtensionState(); } private clearReconnectTimer(): void { @@ -409,11 +626,23 @@ export class ExtensionConnection extends EventEmitter { return; } - // Remove listeners so a deliberate reconnect does not trigger stale close - // handlers or schedule a reconnect against the previous relay URL. - this.ws.removeAllListeners(); - this.ws.terminate(); + const ws = this.ws; + ++this.connectionGeneration; this.ws = null; + // Keep the attempt's error listener attached: terminating CONNECTING sockets + // emits an error before close. Generation checks make both callbacks stale. + ws.terminate(); + } + + private clearExtensionState(): void { + const toolsChanged = this.tools.length > 0; + const sessionsChanged = this.sessions.length > 0; + this.extensionConnected = false; + this.tools = []; + this.sessions = []; + if (toolsChanged) this.emit('tools_updated', this.tools); + if (sessionsChanged) this.emit('sessions_updated', this.sessions); + this.emit('extension_status', false); } /** @@ -424,15 +653,19 @@ export class ExtensionConnection extends EventEmitter { // Handle extension status updates if (message.type === 'extension_status') { - this.extensionConnected = message.connected ?? false; - this.emit('extension_status', this.extensionConnected); + if (message.connected) { + this.extensionConnected = true; + this.emit('extension_status', true); + } else { + this.rejectPendingRequests(new Error('Extension disconnected from relay')); + this.clearExtensionState(); + } return; } if (message.type === 'extension_disconnected') { - this.extensionConnected = false; - this.tools = []; - this.sessions = []; + this.rejectPendingRequests(new Error('Extension disconnected from relay')); + this.clearExtensionState(); this.emit('extension_disconnected'); return; } @@ -453,13 +686,18 @@ export class ExtensionConnection extends EventEmitter { return; } - clearTimeout(pending.timeout); - this.pendingRequests.delete(message.requestId); - if (message.type === 'error') { - pending.reject(new Error(message.error || 'Unknown error')); + const errorMessage = typeof message.error === 'string' ? message.error : 'Unknown relay error'; + clearTimeout(pending.timeout); + this.pendingRequests.delete(message.requestId); + pending.reject(new Error(this.redactRemoteCredential(errorMessage))); } else { let payload: unknown = message.data; + if (message.type === 'tool_result') { + payload = this.redactErrorToolResult(payload); + } + clearTimeout(pending.timeout); + this.pendingRequests.delete(message.requestId); if (message.type === 'sessions_list') { payload = Array.isArray(message.sessions) ? message.sessions : message.data; this.sessions = Array.isArray(payload) ? payload as RelaySessionSummary[] : []; @@ -474,6 +712,9 @@ export class ExtensionConnection extends EventEmitter { return; } } + if (message.requestId) { + return; + } if (message.type === 'sessions_list') { this.sessions = Array.isArray(message.sessions) @@ -583,6 +824,9 @@ export class ExtensionConnection extends EventEmitter { getConnectionErrorMessage(): string { if (this.remoteConfig) { + if (this.lastClose) { + return `${this.formatCloseMessage(this.lastClose.code, this.lastClose.reason)}. ${NO_CONNECTION_REMOTE_MESSAGE}`; + } return NO_CONNECTION_REMOTE_MESSAGE; } @@ -601,7 +845,7 @@ export class ExtensionConnection extends EventEmitter { async listSessions(timeoutMs: number = 5_000): Promise { if (this.remoteConfig) { const session: RelaySessionSummary = { - sessionId: this.remoteConfig.uuid, + sessionId: REDACTED_REMOTE_ID, connected: this.extensionConnected, toolCount: this.tools.length, }; @@ -705,7 +949,50 @@ export class ExtensionConnection extends EventEmitter { */ private log(message: string): void { if (this.debug) { - console.error(`[vibebrowser-mcp] ${message}`); + console.error(`[vibebrowser-mcp] ${this.redactRemoteCredential(message)}`); } } + + private redactRemoteCredential(message: string): string { + return redactRemoteTarget(message, this.remoteConfig ? this.getRelayUrl() : undefined); + } + + private redactErrorToolResult(payload: unknown): unknown { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return payload; + } + + const result = payload as Record; + if (result.isError !== true && result.success !== false) { + return payload; + } + + return this.redactUnknownStrings(result); + } + + private redactUnknownStrings(value: unknown): unknown { + if (typeof value === 'string') { + return this.redactRemoteCredential(value); + } + if (Array.isArray(value)) { + return value.map((entry) => this.redactUnknownStrings(entry)); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, this.redactUnknownStrings(entry)]), + ); + } + return value; + } + + redactErrorMessage(message: string): string { + return this.redactRemoteCredential(message); + } + + private formatCloseMessage(code: number, reason: string): string { + const safeReason = this.redactRemoteCredential(reason.trim()); + return safeReason + ? `Relay connection closed (code ${code}: ${safeReason})` + : `Relay connection closed (code ${code})`; + } } diff --git a/src/server.ts b/src/server.ts index 505c56c..0448653 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,7 +15,7 @@ import { isInitializeRequest, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; -import { ExtensionConnection, type RemoteConfig } from './connection.js'; +import { ExtensionConnection, redactRemoteTarget, type RemoteConfig } from './connection.js'; import { ChromeUseConnection } from './chrome-use-connection.js'; import { DEFAULT_HTTP_PATH, @@ -140,7 +140,7 @@ export class VibeMcpServer { this.log('chrome-use DevTools backend unavailable; server started without tools'); } } else if (this.config.remoteUuid) { - this.log(`Connected to remote relay for UUID ${this.config.remoteUuid}`); + this.log('Connected to configured remote relay target'); } else { this.log(`Waiting for Vibe extension connection on port ${this.config.port}...`); } @@ -300,7 +300,9 @@ export class VibeMcpServer { try { return await this.handleSetRemoteTool(toRecord(args)); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const rawMessage = error instanceof Error ? error.message : String(error); + const target = typeof args?.url === 'string' ? args.url : undefined; + const message = redactRemoteTarget(rawMessage, target); return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true, @@ -329,7 +331,10 @@ export class VibeMcpServer { isError: enriched.isError, }; } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const rawMessage = error instanceof Error ? error.message : String(error); + const message = this.connection instanceof ExtensionConnection + ? this.connection.redactErrorMessage(rawMessage) + : rawMessage; return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true, @@ -355,7 +360,7 @@ export class VibeMcpServer { }; } - const remote = await this.connection.setRemoteUrl(args.url.trim()); + await this.connection.setRemoteUrl(args.url.trim()); this.notifyToolListChanged(); return { @@ -364,8 +369,7 @@ export class VibeMcpServer { text: JSON.stringify({ ok: true, mode: 'remote', - relayUrl: remote.relayUrl, - uuid: remote.uuid, + target: '[redacted]', }), }], };