diff --git a/docs/cli/browser.md b/docs/cli/browser.md index 2a32f76b74f2..b80155dc82de 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -154,13 +154,26 @@ openclaw browser extension cdp --json challenge/complete binding. It never prints the relay key or an authorization header by default. +Automatic local bootstrap connects through the local Gateway's exact +`/browser/extension` route so the first authenticated extension connection +starts the lazy browser-control service. Keep `openclaw gateway run` or the +managed Gateway service running; no separate browser request or prewarm is +needed. Local OpenClaw and mcporter calls still use the profile relay port +reported by `extension pair` or `extension cdp` after that wakeup. Browser-node +pairings continue to use the relay on the browser-node host, while explicit +`--gateway-url` pairings remain direct-remote and manual-only. + +The advanced manual `extension pair` command without `--gateway-url` retains +the host-local `/extension` relay URL. It does not wake Browser control, so the +selected profile relay must already be running before the extension connects. + `extension cdp --legacy-bearer` is a temporary migration escape hatch. It prints the old Bearer header with a warning only while `browser.extensionRelay.allowLegacyAuth=true`; otherwise it exits with an error without printing a credential. Use `--json` for machine output; warnings remain on stderr so stdout stays valid JSON. -Setup, security model, and migration steps: [Chrome extension](/tools/chrome-extension). +Setup, security model, and recovery steps: [Chrome extension](/tools/chrome-extension). If the extension already attempted automatic setup before the native host existed, Chromium retains that miss for the running browser process. Restart diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md index 54ef1870a190..0cb37fd437d4 100644 --- a/docs/tools/chrome-extension.md +++ b/docs/tools/chrome-extension.md @@ -101,6 +101,18 @@ openclaw config set browser.defaultProfile chrome Fresh automatic pairings use **All tabs**. Existing valid pairings are never overwritten, and older pairings keep their stored access mode. +For local setup, native bootstrap connects the extension through the local +Gateway's exact `/browser/extension` route. That first authenticated connection +wakes the lazy browser-control service and starts the profile's loopback relay; +OpenClaw and local clients such as mcporter then use that profile relay port. +Keep `openclaw gateway run` or the managed Gateway service running. A separate +browser request or prewarm step is not required. + +Browser-node setup remains different: the extension connects to the relay on +the browser-node host while the node uses its configured remote Gateway. An +explicit `--gateway-url` pairing connects directly to that remote Gateway and +remains a manual-only flow. + ### Choose tab access - **All tabs** exposes every eligible ordinary tab in that Chrome profile, @@ -130,6 +142,11 @@ local setup** switch. - **Use local OpenClaw** clears the opt-out and retries the native host. - Saving an explicit manual pairing also clears the opt-out. +Pre-release development installs that paired before local Gateway wakeup +routing keep their existing pairing unchanged. In Settings, use **Disconnect +and disable automatic setup**, then **Use local OpenClaw** to create the new +local pairing. Released builds do not require this recovery step. + ### Upgrades from the retired tab copilot If Settings says automation is paused to protect a pre-upgrade copilot @@ -178,6 +195,10 @@ openclaw browser extension pair Manual pairing remains useful on Windows and for recovery. Treat the complete pairing string as a password. +Without `--gateway-url`, this command retains the host-local `/extension` relay +for standalone manual pairing. It does not wake Browser control; the selected +profile relay must already be running before the extension connects. + For a laptop that has Chrome but does not run OpenClaw or a browser node, pair directly to a remote Gateway: @@ -270,8 +291,10 @@ openclaw doctor OpenClaw**. - **Manual setup required:** use Settings for the advanced pairing flow. This is expected on Windows and direct extension-only remote Gateway setups. -- **Relay unavailable:** confirm the Gateway or browser node is running, then - run browser doctor. +- **Relay unavailable:** confirm `openclaw gateway run` or the managed Gateway + service is running for local setup, or confirm the browser node is running + for browser-node setup. Then run browser doctor. No separate browser prewarm + should be necessary. See [Browser](/tools/browser) for the full profile model and the managed `openclaw` and Chrome MCP `user` profiles. diff --git a/extensions/browser/chrome-extension/bootstrap.chromium.test.ts b/extensions/browser/chrome-extension/bootstrap.chromium.test.ts index 6f9d0f8abc5d..b66583aa027a 100644 --- a/extensions/browser/chrome-extension/bootstrap.chromium.test.ts +++ b/extensions/browser/chrome-extension/bootstrap.chromium.test.ts @@ -1,5 +1,6 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs/promises"; +import http from "node:http"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -12,8 +13,9 @@ import { stableChromeExtensionDir, } from "../src/browser/extension-install-layout.js"; import { installChromeExtensionBootstrap } from "../src/browser/extension-install.js"; -import { startExtensionRelayServer } from "../src/browser/extension-relay/relay-server.js"; +import { handleGatewayExtensionUpgrade } from "../src/browser/extension-relay/gateway-relay-route.js"; import { getFreePort } from "../src/browser/test-port.js"; +import { getBrowserControlState, stopBrowserControlService } from "../src/control-service.js"; import { relayTestKey } from "./relay-key.test-support.js"; declare const chrome: { @@ -146,7 +148,11 @@ describe.runIf(runE2E)("Chrome native bootstrap Chromium E2E", () => { const homeDir = path.join(root, "home"); const stateDir = path.join(root, "custom-state"); const configPath = path.join(root, "custom-config", "openclaw.json"); - const relayPort = await getFreePort(); + const gatewayPort = await getFreePort(); + let relayPort = await getFreePort(); + while (relayPort === gatewayPort) { + relayPort = await getFreePort(); + } const linuxConfigHome = path.join(homeDir, ".config"); const chromeRootEnv = process.platform === "linux" @@ -166,11 +172,15 @@ describe.runIf(runE2E)("Chrome native bootstrap Chromium E2E", () => { ); await fs.writeFile( configPath, - `${JSON.stringify({ browser: { profiles: { e2e: { driver: "extension", cdpPort: relayPort } } } })}\n`, + `${JSON.stringify({ gateway: { port: gatewayPort }, browser: { profiles: { e2e: { driver: "extension", cdpPort: relayPort } } } })}\n`, { mode: 0o600 }, ); await withEnvAsync( - { OPENCLAW_STATE_DIR: stateDir, OPENCLAW_CONFIG_PATH: configPath }, + { + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_CONFIG_PATH: configPath, + OPENCLAW_GATEWAY_PORT: String(gatewayPort), + }, async () => { const extensionSource = path.dirname(fileURLToPath(import.meta.url)); const nativeHostPath = await fs.realpath( @@ -187,12 +197,28 @@ describe.runIf(runE2E)("Chrome native bootstrap Chromium E2E", () => { ...chromeRootEnv, OPENCLAW_STATE_DIR: stateDir, OPENCLAW_CONFIG_PATH: configPath, + OPENCLAW_GATEWAY_PORT: String(gatewayPort), }, nodePath: tsxPath, nativeHostPath, }; - const relay = await startExtensionRelayServer({ port: relayPort, token }); - cleanups.push(relay.close); + const gatewayServer = http.createServer((_req, res) => { + res.writeHead(426); + res.end(); + }); + gatewayServer.on("upgrade", (req, socket, head) => { + void handleGatewayExtensionUpgrade(req, socket, head); + }); + await new Promise((resolve) => { + gatewayServer.listen(gatewayPort, "127.0.0.1", resolve); + }); + cleanups.push( + async () => + await new Promise((resolve) => { + gatewayServer.close(() => resolve()); + }), + ); + cleanups.push(stopBrowserControlService); const browserEnv: NodeJS.ProcessEnv = { ...process.env, HOME: homeDir, @@ -291,7 +317,13 @@ describe.runIf(runE2E)("Chrome native bootstrap Chromium E2E", () => { } expect(extensionStatus).toMatchObject({ paired: true, accessMode: "all" }); try { - await expect.poll(() => relay.bridge.extensionConnected, { timeout: 15_000 }).toBe(true); + await expect + .poll( + () => + getBrowserControlState()?.extensionRelays?.get("e2e")?.bridge.extensionConnected, + { timeout: 15_000 }, + ) + .toBe(true); } catch (error) { extensionStatus = await extensionPage.evaluate( async () => await chrome.runtime.sendMessage({ type: "getStatus" }), @@ -300,6 +332,10 @@ describe.runIf(runE2E)("Chrome native bootstrap Chromium E2E", () => { cause: error, }); } + const relay = getBrowserControlState()?.extensionRelays?.get("e2e"); + if (!relay || relay.port !== relayPort) { + throw new Error("Gateway wakeup did not start the configured extension relay"); + } const registration = status.registrations.find( (entry) => relevantManifestPaths.includes(entry.manifestPath) && entry.state === "owned", @@ -349,7 +385,9 @@ describe.runIf(runE2E)("Chrome native bootstrap Chromium E2E", () => { } if ( relayUrl.hostname !== "127.0.0.1" || - relayUrl.port !== String(relayPort) || + relayUrl.port !== String(gatewayPort) || + relayUrl.pathname !== "/browser/extension" || + relayUrl.searchParams.get("gateway") !== `ws://127.0.0.1:${gatewayPort}` || nativeResponse.pairingString.slice(fragmentAt + 1) !== token ) { throw new Error("native host did not use the custom installation context"); diff --git a/extensions/browser/chrome-extension/modules/relay-core.js b/extensions/browser/chrome-extension/modules/relay-core.js index fb7d503b05ab..768d7ad4cc18 100644 --- a/extensions/browser/chrome-extension/modules/relay-core.js +++ b/extensions/browser/chrome-extension/modules/relay-core.js @@ -155,7 +155,8 @@ function validatePairingFields(relayUrl, token, gatewayUrl) { /** * Parse a pairing string printed by `openclaw browser extension pair`. - * Shape: ws://127.0.0.1:/extension?gateway=# + * Native local and direct-remote pairings use the Gateway route; local manual, + * browser-node, and legacy local pairings use the host relay route. * The additive gateway hint is not a credential; old extensions safely pass * it through to the relay while new extensions remove it before connecting. */ diff --git a/extensions/browser/chrome-extension/modules/relay-core.test.ts b/extensions/browser/chrome-extension/modules/relay-core.test.ts index 38e08efeca0a..5664737bd2d7 100644 --- a/extensions/browser/chrome-extension/modules/relay-core.test.ts +++ b/extensions/browser/chrome-extension/modules/relay-core.test.ts @@ -131,11 +131,11 @@ describe("persisted pairing storage", () => { }, }, { - label: "a loopback relay with an independent Gateway hint", + label: "an SSH-tunneled browser-node pairing with a loopback Gateway hint", stored: { relayUrl: "ws://127.0.0.1:18797/extension", token: RELAY_SECRET, - gatewayUrl: "wss://gateway.example.com/base", + gatewayUrl: "ws://127.0.0.1:19089", }, }, { diff --git a/extensions/browser/native-host-entry.ts b/extensions/browser/native-host-entry.ts index 214440da9a22..47b863948b07 100644 --- a/extensions/browser/native-host-entry.ts +++ b/extensions/browser/native-host-entry.ts @@ -26,7 +26,11 @@ async function main(): Promise { write: (frame) => { responseFrame = frame; }, - buildPairing: async () => await buildBrowserExtensionPairing({ cfg: getRuntimeConfig() }), + buildPairing: async () => + await buildBrowserExtensionPairing({ + cfg: getRuntimeConfig(), + localTransport: "gateway", + }), }); const response = responseFrame; if (!response) { diff --git a/extensions/browser/src/browser/extension-install.test.ts b/extensions/browser/src/browser/extension-install.test.ts index 41cdb4038f69..b0370911c4c7 100644 --- a/extensions/browser/src/browser/extension-install.test.ts +++ b/extensions/browser/src/browser/extension-install.test.ts @@ -489,7 +489,7 @@ describe("native host registration", () => { v: 1, ok: true, nonce, - pairingString: `ws://127.0.0.1:${relayPort}/extension?gateway=ws%3A%2F%2F127.0.0.1%3A18789#${token}`, + pairingString: `ws://127.0.0.1:18789/browser/extension?gateway=ws%3A%2F%2F127.0.0.1%3A18789#${token}`, }); }, ); diff --git a/extensions/browser/src/browser/extension-pairing.test.ts b/extensions/browser/src/browser/extension-pairing.test.ts new file mode 100644 index 000000000000..b8c1d1092afb --- /dev/null +++ b/extensions/browser/src/browser/extension-pairing.test.ts @@ -0,0 +1,109 @@ +import { withEnvAsync } from "openclaw/plugin-sdk/test-env"; +import { describe, expect, it } from "vitest"; +import { relayTestKey } from "../../chrome-extension/relay-key.test-support.js"; +import { buildBrowserExtensionPairing } from "./extension-pairing.js"; + +const RELAY_KEY = relayTestKey(5); +const ensureToken = async () => RELAY_KEY; + +describe("buildBrowserExtensionPairing", () => { + it("preserves the standalone host relay for local manual pairing compatibility", async () => { + await withEnvAsync({ OPENCLAW_GATEWAY_PORT: undefined }, async () => { + await expect( + buildBrowserExtensionPairing({ + cfg: { + gateway: { port: 19_089 }, + browser: { + profiles: { chrome: { driver: "extension", cdpPort: 19_199 } }, + }, + }, + ensureToken, + }), + ).resolves.toEqual({ + pairingString: `ws://127.0.0.1:19199/extension?gateway=ws%3A%2F%2F127.0.0.1%3A19089#${RELAY_KEY}`, + relayPort: 19_199, + topology: "local", + }); + }); + }); + + it("routes local native bootstrap through the Gateway while retaining relay metadata", async () => { + await withEnvAsync({ OPENCLAW_GATEWAY_PORT: undefined }, async () => { + await expect( + buildBrowserExtensionPairing({ + cfg: { + gateway: { port: 19_089 }, + browser: { + profiles: { chrome: { driver: "extension", cdpPort: 19_199 } }, + }, + }, + localTransport: "gateway", + ensureToken, + }), + ).resolves.toEqual({ + pairingString: `ws://127.0.0.1:19089/browser/extension?gateway=ws%3A%2F%2F127.0.0.1%3A19089#${RELAY_KEY}`, + relayPort: 19_199, + topology: "local", + }); + }); + }); + + it.each([ + { + label: "remote TLS Gateway", + gatewayUrl: "wss://gateway.example.com:9444", + encodedGateway: "wss%3A%2F%2Fgateway.example.com%3A9444", + }, + { + label: "loopback SSH tunnel to a remote Gateway", + gatewayUrl: "ws://127.0.0.1:29089", + encodedGateway: "ws%3A%2F%2F127.0.0.1%3A29089", + }, + ])("keeps browser-node bootstrap on the host-local relay for $label", async (testCase) => { + await expect( + buildBrowserExtensionPairing({ + cfg: { + gateway: { + mode: "remote", + remote: { url: testCase.gatewayUrl }, + }, + browser: { + profiles: { chrome: { driver: "extension", cdpPort: 19_198 } }, + }, + }, + ensureToken, + }), + ).resolves.toEqual({ + pairingString: `ws://127.0.0.1:19198/extension?gateway=${testCase.encodedGateway}#${RELAY_KEY}`, + relayPort: 19_198, + topology: "browser-node", + }); + }); + + it("keeps an explicit remote Gateway direct and manual-only", async () => { + await expect( + buildBrowserExtensionPairing({ + cfg: { + browser: { + profiles: { chrome: { driver: "extension", cdpPort: 19_197 } }, + }, + }, + gatewayUrl: "wss://gateway.example.com:9443", + ensureToken, + }), + ).resolves.toEqual({ + pairingString: `wss://gateway.example.com:9443/browser/extension?gateway=wss%3A%2F%2Fgateway.example.com%3A9443#${RELAY_KEY}`, + relayPort: 19_197, + topology: "direct-remote", + }); + }); + + it("requires an explicit certificate hostname for local Gateway TLS", async () => { + await expect( + buildBrowserExtensionPairing({ + cfg: { gateway: { tls: { enabled: true } } }, + ensureToken, + }), + ).rejects.toThrow("--gateway-url wss://"); + }); +}); diff --git a/extensions/browser/src/browser/extension-pairing.ts b/extensions/browser/src/browser/extension-pairing.ts index 8496ba0a4c78..be0bb7f4bf0e 100644 --- a/extensions/browser/src/browser/extension-pairing.ts +++ b/extensions/browser/src/browser/extension-pairing.ts @@ -3,7 +3,7 @@ import { type BrowserConfig, type OpenClawConfig, resolveGatewayPort } from "../ import { resolveBrowserConfig } from "./config.js"; import { ensureExtensionRelayToken } from "./extension-relay/relay-auth.js"; -/** Gateway route for direct extension-only remote pairing. */ +/** Gateway route for extension pairing that must wake Browser control. */ const GATEWAY_EXTENSION_RELAY_PATH = "/browser/extension"; type BrowserExtensionPairing = { @@ -26,8 +26,8 @@ function firstExtensionRelayPort(cfg: PairingConfig): number { return resolved.extensionRelayDefaultPort; } -/** Resolve a safe direct-Gateway relay URL with the v2-bound route path. */ -function buildDirectGatewayRelayUrl(raw: string): string { +/** Resolve a safe Gateway relay URL with the v2-bound route path. */ +function buildGatewayExtensionRelayUrl(raw: string): string { let url: URL; try { url = new URL(raw.trim()); @@ -59,13 +59,14 @@ function buildDirectGatewayRelayUrl(raw: string): string { export async function buildBrowserExtensionPairing(params: { cfg: PairingConfig; gatewayUrl?: string; + localTransport?: "relay" | "gateway"; ensureToken?: typeof ensureExtensionRelayToken; }): Promise { const relayPort = firstExtensionRelayPort(params.cfg); const token = await (params.ensureToken ?? ensureExtensionRelayToken)(); const gateway = params.gatewayUrl?.trim(); if (gateway) { - const relayUrl = new URL(buildDirectGatewayRelayUrl(gateway)); + const relayUrl = new URL(buildGatewayExtensionRelayUrl(gateway)); relayUrl.searchParams.set("gateway", gateway); return { pairingString: `${relayUrl.toString()}#${token}`, @@ -80,7 +81,12 @@ export async function buildBrowserExtensionPairing(params: { throw new Error("Gateway TLS pairing requires --gateway-url wss://[:port]"); } const gatewayHint = configuredRemote || `ws://127.0.0.1:${resolveGatewayPort(params.cfg)}`; - const relayUrl = new URL(`ws://127.0.0.1:${relayPort}/extension`); + // Native local bootstrap needs the Gateway to wake Browser control. Manual + // local pairing and browser nodes target an already-running host relay. + const relayUrl = + !configuredRemote && params.localTransport === "gateway" + ? new URL(buildGatewayExtensionRelayUrl(gatewayHint)) + : new URL(`ws://127.0.0.1:${relayPort}/extension`); relayUrl.searchParams.set("gateway", gatewayHint); return { pairingString: `${relayUrl.toString()}#${token}`, diff --git a/extensions/browser/src/browser/extension-relay/gateway-relay-route.integration.test.ts b/extensions/browser/src/browser/extension-relay/gateway-relay-route.integration.test.ts new file mode 100644 index 000000000000..4c350feb89b8 --- /dev/null +++ b/extensions/browser/src/browser/extension-relay/gateway-relay-route.integration.test.ts @@ -0,0 +1,180 @@ +import { once } from "node:events"; +import fs from "node:fs/promises"; +import http, { type Server } from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { + clearRuntimeConfigSnapshot, + setRuntimeConfigSnapshot, +} from "openclaw/plugin-sdk/runtime-config-snapshot"; +import { withEnvAsync } from "openclaw/plugin-sdk/test-env"; +import { afterEach, describe, expect, it } from "vitest"; +import { WebSocket, type RawData } from "ws"; +import { parsePairingString } from "../../../chrome-extension/modules/relay-core.js"; +import { relayTestKey } from "../../../chrome-extension/relay-key.test-support.js"; +import { getBrowserControlState, stopBrowserControlService } from "../../control-service.js"; +import { buildBrowserExtensionPairing } from "../extension-pairing.js"; +import { getFreePort } from "../test-port.js"; +import { createRelayProof, randomRelayNonce, relayKeyIdFromHex } from "./auth-v2-crypto.js"; +import { BROWSER_RELAY_EXTENSION_SUBPROTOCOL } from "./auth-v2.js"; +import { handleGatewayExtensionUpgrade } from "./gateway-relay-route.js"; + +const RELAY_KEY = relayTestKey(8); + +function rawDataText(data: RawData): string { + if (Array.isArray(data)) { + return Buffer.concat(data).toString("utf8"); + } + if (data instanceof ArrayBuffer) { + return Buffer.from(data).toString("utf8"); + } + return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8"); +} + +async function closeServer(server: Server): Promise { + if (!server.listening) { + return; + } + await new Promise((resolve) => { + server.close(() => resolve()); + }); +} + +afterEach(async () => { + await stopBrowserControlService(); + clearRuntimeConfigSnapshot(); +}); + +describe.sequential("local Gateway extension relay wakeup", () => { + it("starts Browser control and the CDP relay from the first authenticated extension request", async () => { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-relay-wakeup-")); + try { + const gatewayPort = await getFreePort(); + let relayPort = await getFreePort(); + while (relayPort === gatewayPort) { + relayPort = await getFreePort(); + } + await fs.mkdir(path.join(stateDir, "credentials"), { recursive: true }); + await fs.writeFile( + path.join(stateDir, "credentials", "browser-extension-relay.secret"), + `${RELAY_KEY}\n`, + { mode: 0o600 }, + ); + + const config = { + gateway: { + port: gatewayPort, + auth: { mode: "token" as const, token: "gateway-integration-test" }, + }, + browser: { + enabled: true, + extensionRelay: { allowLegacyAuth: false }, + profiles: { chrome: { driver: "extension" as const, cdpPort: relayPort } }, + }, + }; + setRuntimeConfigSnapshot(config, config); + + await withEnvAsync( + { + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_GATEWAY_PORT: String(gatewayPort), + }, + async () => { + const gatewayServer = http.createServer((_req, res) => { + res.writeHead(426); + res.end(); + }); + gatewayServer.on("upgrade", (req, socket, head) => { + void handleGatewayExtensionUpgrade(req, socket, head); + }); + let extension: WebSocket | undefined; + try { + await new Promise((resolve) => { + gatewayServer.listen(gatewayPort, "127.0.0.1", resolve); + }); + expect(getBrowserControlState()).toBeNull(); + + const pairing = await buildBrowserExtensionPairing({ + cfg: config, + localTransport: "gateway", + ensureToken: async () => RELAY_KEY, + }); + expect(pairing).toMatchObject({ relayPort, topology: "local" }); + const parsed = parsePairingString(pairing.pairingString); + if (!parsed) { + throw new Error("local pairing did not parse"); + } + + extension = new WebSocket(parsed.relayUrl, BROWSER_RELAY_EXTENSION_SUBPROTOCOL, { + origin: "chrome-extension://gateway-wakeup-integration", + }); + await once(extension, "open"); + const clientNonce = randomRelayNonce(); + const challengeMessage = once(extension, "message"); + extension.send( + JSON.stringify({ + type: "auth.hello", + v: 2, + keyId: relayKeyIdFromHex(RELAY_KEY), + clientNonce, + }), + ); + const [challengeData] = (await challengeMessage) as [RawData]; + const challenge = JSON.parse(rawDataText(challengeData)); + const okMessage = once(extension, "message"); + extension.send( + JSON.stringify({ + type: "auth.response", + v: 2, + sessionId: challenge.sessionId, + clientProof: createRelayProof(RELAY_KEY, "client", challenge), + }), + ); + const [okData] = (await okMessage) as [RawData]; + expect(JSON.parse(rawDataText(okData))).toMatchObject({ type: "auth.ok", v: 2 }); + extension.send( + JSON.stringify({ + type: "hello", + userAgent: "gateway-wakeup-test", + browserVersion: "Chrome/test", + extensionVersion: "2", + tabs: [], + }), + ); + + await expect + .poll( + () => + getBrowserControlState()?.extensionRelays?.get("chrome")?.bridge + .extensionConnected, + ) + .toBe(true); + const relay = getBrowserControlState()?.extensionRelays?.get("chrome"); + expect(relay?.port).toBe(pairing.relayPort); + if (!relay) { + throw new Error("extension relay did not start"); + } + + const authorization = Buffer.from(`openclaw-internal:${relay.internalToken}`).toString( + "base64", + ); + const response = await fetch(`http://127.0.0.1:${pairing.relayPort}/json/version`, { + headers: { Authorization: `Basic ${authorization}` }, + }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + Browser: "Chrome/test", + webSocketDebuggerUrl: `ws://127.0.0.1:${pairing.relayPort}/cdp`, + }); + } finally { + extension?.terminate(); + await stopBrowserControlService(); + await closeServer(gatewayServer); + } + }, + ); + } finally { + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); +}); diff --git a/extensions/browser/src/cli/browser-cli-extension.test.ts b/extensions/browser/src/cli/browser-cli-extension.test.ts index e2ddd4a8dc41..95334a27a41c 100644 --- a/extensions/browser/src/cli/browser-cli-extension.test.ts +++ b/extensions/browser/src/cli/browser-cli-extension.test.ts @@ -2,7 +2,6 @@ import { Command } from "commander"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createCliRuntimeCapture } from "../../test-support.js"; import type { installChromeExtensionBootstrap } from "../browser/extension-install.js"; -import { buildBrowserExtensionPairing } from "../browser/extension-pairing.js"; import { relayKeyIdFromHex } from "../browser/extension-relay/auth-v2-crypto.js"; import * as cliCoreApiModule from "./core-api.js"; @@ -81,33 +80,6 @@ describe("browser extension pairing Gateway URL", () => { expect(output.at(-1)).toContain("deterministic extension identity verified"); }); - it("uses loopback only for a plaintext local Gateway", async () => { - await expect( - buildBrowserExtensionPairing({ cfg: {}, ensureToken: async () => relayMocks.relayKey }), - ).resolves.toMatchObject({ - pairingString: expect.stringContaining("gateway=ws%3A%2F%2F127.0.0.1%3A18789"), - topology: "local", - }); - }); - - it("requires the certificate hostname for a TLS Gateway", async () => { - await expect( - buildBrowserExtensionPairing({ - cfg: { gateway: { tls: { enabled: true } } }, - ensureToken: async () => relayMocks.relayKey, - }), - ).rejects.toThrow("--gateway-url wss://"); - await expect( - buildBrowserExtensionPairing({ - cfg: { gateway: { mode: "remote", remote: { url: "wss://gateway.example" } } }, - ensureToken: async () => relayMocks.relayKey, - }), - ).resolves.toMatchObject({ - pairingString: expect.stringContaining("gateway=wss%3A%2F%2Fgateway.example"), - topology: "browser-node", - }); - }); - it("rejects path-rewriting proxy prefixes for strict v2 resource binding", async () => { vi.spyOn(cliCoreApiModule, "getRuntimeConfig").mockReturnValue({}); const errorSpy = vi