diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d8981242672..c3ca753e0817 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,6 @@ Docs: https://docs.openclaw.ai ### Changes -- **Standalone browser relay:** ship a gateway-free extension relay daemon (`dist/extensions/browser/relay-daemon-entry.js`) and teach the native messaging host a rate-limited `ensure_relay` op, so the Chrome extension can wake the relay on demand and CDP clients (mcporter, Playwright) drive the paired browser without a running Gateway. The standalone daemon is v2-only by default (it never re-enables legacy one-directional auth unless `browser.extensionRelay.allowLegacyAuth=true` is set explicitly), so a process that squats the relay port cannot harvest the secret from a legacy client. The relay secret is also re-checked for owner and `0600` mode on every read (self-healing a drifted mode, refusing a foreign-owned or non-regular file), so a permission drift on a shared host can no longer expose it. - **Secret egress host binding:** bind each shared-store secret to exact HTTPS destination hosts across CLI, Gateway RPC, and Control UI so unbound sentinel substitution fails closed before plaintext egress. - **Release validation:** defer beta candidate Parallels smoke to postpublish `release:beta-smoke` by default, keep stable/full prepublish coverage, and bound nested release workflow monitors with explicit job timeouts. - **macOS app profiles:** isolate named app instances across state, preferences, Keychain, Gateway services, and duplicate-instance ownership while keeping host-global login and node services untouched. diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 7df2bc49757b..9ce8cf694b79 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -77,7 +77,6 @@ extensions/browser/src/browser/extension-install.ts 3 extensions/browser/src/browser/extension-native-host.ts 1 extensions/browser/src/browser/extension-native-protocol.ts 1 extensions/browser/src/browser/extension-relay/auth-v2.ts 10 -extensions/browser/src/browser/extension-relay/relay-auth.ts 1 extensions/browser/src/browser/extension-relay/relay-bridge.ts 12 extensions/browser/src/browser/extension-relay/relay-protocol.ts 4 extensions/browser/src/browser/output-files.ts 1 diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md index 65205eecf204..636afa1a431e 100644 --- a/docs/tools/chrome-extension.md +++ b/docs/tools/chrome-extension.md @@ -107,7 +107,7 @@ 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 +For fresh 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. @@ -119,6 +119,39 @@ 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. +### Standalone direct-loopback relay + +A pairing on `ws://127.0.0.1:/extension` can run without a local Gateway +or browser node. On macOS and Linux, the bundled extension can ask the installed +native host to start a standalone relay when reconnecting to that endpoint. +Automatic local setup must be enabled. Requests are limited to once per minute; +the extension still authenticates the relay with connection-bound v2 proofs. +This requires both the updated native host and an extension build containing +relay wake-up support. Do not assume the Store v2.2.0 build includes that code; +the bundled unpacked development copy is the source-build validation path. + +Wake-up uses the port in the extension's existing canonical pairing. It does +not switch to the first configured profile. The native host resolves current +`browser.profiles` and permits only an extension-driver relay port, including +automatically allocated ports and explicit `cdpPort` pins. A removed profile +or stale port fails closed; correct the pairing to match the current profile. +Gateway `/browser/extension` routes and remote pairings never trigger local +daemon wake-up. Browser-node pairings that use a direct loopback relay can use +it even when their Gateway hint points to a remote host. + +An existing listener keeps ownership of its port. Otherwise, the native host +spawns `dist/extensions/browser/relay-daemon-entry.js` as a detached process. +The daemon uses the same per-host relay key and stays alive while an extension +or CDP client is connected. After both disconnect, it exits following ten +minutes of inactivity, checked every 30 seconds. Closing Chrome alone does not +stop it while a CDP client remains connected. A later reconnect can wake it again. + +The standalone daemon defaults to **v2-only authentication**, independently of +the Gateway relay's legacy default. Only an explicit +`browser.extensionRelay.allowLegacyAuth=true` enables legacy authentication; +an unset value, `false`, or a config-read failure never enables it. Prefer v2 +clients so the persistent key is not disclosed to a process occupying the port. + ### Choose tab access - **All tabs** exposes every eligible ordinary tab in that Chrome profile, @@ -142,7 +175,7 @@ Settings shows redacted relay/native bootstrap status and an **Use automatic local setup** switch. - Turning automatic setup off preserves a valid existing pairing but prevents - new native bootstrap attempts. + new native bootstrap and standalone relay wake-up attempts. - **Disconnect and disable automatic setup** revokes the pairing immediately, detaches debugger sessions, and persists the opt-out. - **Use local OpenClaw** clears the opt-out and retries the native host. @@ -203,8 +236,10 @@ 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 standalone manual pairing. It does not wake Browser control. With native +wake-up support installed and automatic local setup enabled, the extension can +start that relay on reconnect without a local Gateway. Otherwise, the relay +must already be running, for example through Browser control or a browser node. For a laptop that has Chrome but does not run OpenClaw or a browser node, pair directly to a remote Gateway: @@ -246,20 +281,28 @@ The extension requests only: - `tabs` and `tabGroups`: discover tabs and enforce access mode; - `storage`: persist pairing, access mode, session pauses, and bootstrap opt-out; - `alarms`: wake the MV3 worker for relay/bootstrap retries; -- `nativeMessaging`: request one local bootstrap pairing. +- `nativeMessaging`: request a local bootstrap pairing or wake its configured relay. It does not request `activeTab`, `contextMenus`, `scripting`, or `sidePanel`. ## Native bootstrap security -The native host is `ai.openclaw.browser_bootstrap`. Each -`chrome.runtime.sendNativeMessage` call starts one process, reads one request, -writes one response, and exits. +The native host is `ai.openclaw.browser_bootstrap`. The extension opens a +`chrome.runtime.connectNative` port for one request, validates the response, +then disconnects. The host writes one response and exits; a spawned standalone +relay outlives this short-lived native connection. The request uses a versioned, length-prefixed JSON frame with a fresh 16-byte nonce. The host caps input at 4 KiB, requires fatal UTF-8 decoding and exact fields, verifies the caller origin against the exact installed manifest, and -returns only a locally generated pairing or a bounded non-secret failure code. +returns only a locally generated pairing, a relay status, or a bounded +non-secret failure code. The bootstrap request remains exactly +`{v:1, op:"bootstrap", nonce}`. Relay wake-up uses +`{v:1, op:"ensure_relay", nonce, relayPort}` with a required integer port from +1 through 65535. Missing, duplicate, malformed, or extra fields are rejected. +After manifest and caller validation, the host checks the requested port +against current extension profiles before probing or spawning. No request can +supply a host, executable path, or credential to the launcher. The response is below Chrome's 1 MiB native-message limit. Pairing keys never appear in launcher arguments, manifests, status JSON, or diagnostics. @@ -286,7 +329,10 @@ manifest has no `key`; only these development IDs depend on approved OpenClaw-owned realpaths. The relay itself uses connection-bound HMAC proofs. The persistent per-host key -is not sent in a URL, header, WebSocket subprotocol, or application frame. +is not sent in a URL, header, WebSocket subprotocol, or application frame during +v2 authentication. On POSIX hosts, each key read rejects foreign-owned and +non-regular files and tightens an owned group/other-accessible file to `0600`; +if tightening fails, the key is refused. Windows uses its existing ACL policy. ## Troubleshooting @@ -310,10 +356,12 @@ 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 `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. +- **Relay unavailable:** for `/browser/extension` pairings, confirm the target + Gateway is running. For direct loopback `/extension` pairings, check native + host registration, wake-up support in the extension build, automatic setup, + and that the paired port still belongs to an extension profile. Allow for the + one-minute wake-up throttle, then run browser doctor. No local Gateway is + required for the standalone path. 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/background.js b/extensions/browser/chrome-extension/background.js index 0133c0f0fddb..4211379ffcdb 100644 --- a/extensions/browser/chrome-extension/background.js +++ b/extensions/browser/chrome-extension/background.js @@ -18,7 +18,7 @@ import { ACCESS_MODE_SELECTED, OPENCLAW_TAB_GROUP_TITLE, createPairingConfigStore, - isDirectLoopbackRelayUrl, + directLoopbackRelayPort, reconnectDelayMs, toRelayTabInfo, } from "./modules/relay-core.js"; @@ -477,7 +477,7 @@ async function connectRelay(isConnectionAllowed = () => true) { if (!connectionIsCurrent()) { return; } - maybeEnsureRelayDaemon(relayUrl); + void maybeEnsureRelayDaemon(relayUrl, connectionIsCurrent).catch(() => {}); setBadge("connecting"); let ws; try { @@ -558,8 +558,14 @@ function handleRelayOpeningDeadline() { * host (rate-limited) to spawn the standalone relay daemon so the extension * has something to connect to without a running Gateway. */ -function maybeEnsureRelayDaemon(relayUrl) { - if (reconnectAttempt === 0 || !isDirectLoopbackRelayUrl(relayUrl)) { +async function maybeEnsureRelayDaemon(relayUrl, connectionIsCurrent) { + const relayPort = directLoopbackRelayPort(relayUrl); + if (reconnectAttempt === 0 || relayPort === null) { + return; + } + const { disabled } = await nativeBootstrap.status(); + // Opt-out or pair revocation can win the storage read above. + if (disabled || retiredCopilotCustodyBlocked || !connectionIsCurrent()) { return; } const now = Date.now(); @@ -567,7 +573,7 @@ function maybeEnsureRelayDaemon(relayUrl) { return; } lastRelayEnsureAtMs = now; - void requestRelayEnsure(chrome).catch(() => {}); + await requestRelayEnsure(relayPort, chrome); } function scheduleReconnect() { diff --git a/extensions/browser/chrome-extension/background.test.ts b/extensions/browser/chrome-extension/background.test.ts index 17b3795d274d..8950a729be20 100644 --- a/extensions/browser/chrome-extension/background.test.ts +++ b/extensions/browser/chrome-extension/background.test.ts @@ -418,3 +418,62 @@ describe("relay pairing and authentication", () => { expect(harness.storageValues).not.toHaveProperty("relayUrl"); }); }); + +describe("standalone relay wake-up", () => { + beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + vi.setSystemTime(100_000); + }); + + afterEach(async () => { + await cleanupBackgroundHarnesses(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it.each([18798, 20123])( + "wakes the paired port %i on reconnect, at most once per minute", + async (relayPort) => { + const harness = await loadBackground({ + storedConfig: { relayUrl: `ws://127.0.0.1:${relayPort}/extension`, token: TEST_RELAY_KEY }, + nativeMessage: async (request) => ({ + v: 1, + ok: true, + nonce: (request as { nonce: string }).nonce, + relay: "spawned", + }), + }); + expect(harness.sendNativeMessage).not.toHaveBeenCalled(); + harness.relaySockets.at(-1)?.close(); + await vi.advanceTimersByTimeAsync(1000); + expect(harness.sendNativeMessage).toHaveBeenCalledExactlyOnceWith( + "ai.openclaw.browser_bootstrap", + { v: 1, op: "ensure_relay", nonce: expect.any(String), relayPort }, + ); + harness.relaySockets.at(-1)?.close(); + await vi.advanceTimersByTimeAsync(2000); + expect(harness.relaySockets).toHaveLength(3); + expect(harness.sendNativeMessage).toHaveBeenCalledOnce(); + vi.setSystemTime(Date.now() + 60_000); + harness.relaySockets.at(-1)?.close(); + await vi.advanceTimersByTimeAsync(4000); + expect(harness.sendNativeMessage).toHaveBeenCalledTimes(2); + }, + ); + + it.each([ + ["local Gateway", "ws://127.0.0.1:18789/browser/extension", false], + ["remote Gateway", "wss://gateway.example.com/browser/extension", false], + ["secure loopback", "wss://localhost:18798/extension", false], + ["automatic setup opt-out", "ws://127.0.0.1:18798/extension", true], + ])("does not wake a daemon for %s", async (_label, relayUrl, nativeBootstrapDisabled) => { + const harness = await loadBackground({ + storedConfig: { relayUrl, token: TEST_RELAY_KEY, nativeBootstrapDisabled }, + }); + harness.relaySockets.at(-1)?.close(); + await vi.advanceTimersByTimeAsync(1000); + expect(harness.relaySockets).toHaveLength(2); + expect(harness.sendNativeMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/browser/chrome-extension/modules/native-bootstrap.d.ts b/extensions/browser/chrome-extension/modules/native-bootstrap.d.ts index b66032b36e6e..65163eb17e28 100644 --- a/extensions/browser/chrome-extension/modules/native-bootstrap.d.ts +++ b/extensions/browser/chrome-extension/modules/native-bootstrap.d.ts @@ -52,12 +52,15 @@ type RetiredCopilotStorage = { }; }; -export function requestRelayEnsure(chromeApi?: { - runtime: { - connectNative(name: string): NativeMessagePort; - lastError?: { message?: string }; - }; -}): Promise<{ status: "spawned" | "running" | "skipped" | "unavailable" }>; +export function requestRelayEnsure( + relayPort: number, + chromeApi?: { + runtime: { + connectNative(name: string): NativeMessagePort; + lastError?: { message?: string }; + }; + }, +): Promise<{ status: "spawned" | "running" | "skipped" | "unavailable" }>; export function prepareRetiredCopilotState( chromeApi?: RetiredCopilotStorage, diff --git a/extensions/browser/chrome-extension/modules/native-bootstrap.js b/extensions/browser/chrome-extension/modules/native-bootstrap.js index e9a623542fb7..9ca86f42dfd5 100644 --- a/extensions/browser/chrome-extension/modules/native-bootstrap.js +++ b/extensions/browser/chrome-extension/modules/native-bootstrap.js @@ -93,11 +93,11 @@ function sendNativeBootstrap(chromeApi, request) { const RELAY_ENSURE_STATUSES = new Set(["spawned", "running", "skipped"]); /** Ask the native host to start the standalone relay daemon when nothing serves the relay port. */ -export async function requestRelayEnsure(chromeApi = chrome) { +export async function requestRelayEnsure(relayPort, chromeApi = chrome) { const nonce = randomRelayBase64Url(crypto, 16); let response; try { - response = await sendNativeBootstrap(chromeApi, { v: 1, op: "ensure_relay", nonce }); + response = await sendNativeBootstrap(chromeApi, { v: 1, op: "ensure_relay", nonce, relayPort }); } catch { return { status: "unavailable" }; } diff --git a/extensions/browser/chrome-extension/modules/native-bootstrap.test.ts b/extensions/browser/chrome-extension/modules/native-bootstrap.test.ts index 34b8bc5ae80c..d4aeace3500a 100644 --- a/extensions/browser/chrome-extension/modules/native-bootstrap.test.ts +++ b/extensions/browser/chrome-extension/modules/native-bootstrap.test.ts @@ -420,13 +420,16 @@ function ensureChromeApi(script: EnsurePortScript | "disconnect") { describe("requestRelayEnsure", () => { it("returns the relay status when the native host answers with the echoed nonce", async () => { - const chromeApi = ensureChromeApi((request) => ({ - v: 1, - ok: true, - nonce: request.nonce, - relay: "spawned", - })); - await expect(requestRelayEnsure(chromeApi)).resolves.toEqual({ status: "spawned" }); + const chromeApi = ensureChromeApi((request) => { + expect(request).toEqual({ + v: 1, + op: "ensure_relay", + nonce: expect.any(String), + relayPort: 20123, + }); + return { v: 1, ok: true, nonce: request.nonce, relay: "spawned" }; + }); + await expect(requestRelayEnsure(20123, chromeApi)).resolves.toEqual({ status: "spawned" }); }); it("treats a nonce mismatch as unavailable", async () => { @@ -436,11 +439,11 @@ describe("requestRelayEnsure", () => { nonce: "AAAAAAAAAAAAAAAAAAAAAA", relay: "spawned", })); - await expect(requestRelayEnsure(chromeApi)).resolves.toEqual({ status: "unavailable" }); + await expect(requestRelayEnsure(20123, chromeApi)).resolves.toEqual({ status: "unavailable" }); }); it("treats a missing native host as unavailable", async () => { - await expect(requestRelayEnsure(ensureChromeApi("disconnect"))).resolves.toEqual({ + await expect(requestRelayEnsure(20123, ensureChromeApi("disconnect"))).resolves.toEqual({ status: "unavailable", }); }); diff --git a/extensions/browser/chrome-extension/modules/relay-core.d.ts b/extensions/browser/chrome-extension/modules/relay-core.d.ts index bae8adca99a0..618d13e1f198 100644 --- a/extensions/browser/chrome-extension/modules/relay-core.d.ts +++ b/extensions/browser/chrome-extension/modules/relay-core.d.ts @@ -35,7 +35,7 @@ export function createPairingConfigStore(storage: { export function buildRelayWsProtocols(): string[]; -export function isDirectLoopbackRelayUrl(raw: unknown): boolean; +export function directLoopbackRelayPort(raw: unknown): number | null; export function reconnectDelayMs(attempt: number): number; diff --git a/extensions/browser/chrome-extension/modules/relay-core.js b/extensions/browser/chrome-extension/modules/relay-core.js index 1c282ea5ba0f..b38908c6f15e 100644 --- a/extensions/browser/chrome-extension/modules/relay-core.js +++ b/extensions/browser/chrome-extension/modules/relay-core.js @@ -51,15 +51,26 @@ function isAllowedWebSocketUrl(url) { return url.protocol === "wss:" || (url.protocol === "ws:" && isLoopbackHost(url.hostname)); } -/** True for a loopback ws:// relay URL on the direct /extension path — the URL the standalone relay daemon serves. */ -export function isDirectLoopbackRelayUrl(raw) { +/** Return the paired standalone relay port; Gateway and remote routes cannot wake a local daemon. */ +export function directLoopbackRelayPort(raw) { let url; try { url = new URL(raw); } catch { - return false; + return null; } - return url.protocol === "ws:" && isLoopbackHost(url.hostname) && url.pathname === "/extension"; + const port = Number(url.port || 80); + return url.protocol === "ws:" && + isLoopbackHost(url.hostname) && + url.pathname === "/extension" && + port > 0 && + !url.username && + !url.password && + !url.hash && + normalizeRelayQuery(url) && + url.toString() === raw + ? port + : null; } function parseGatewayHint(raw) { diff --git a/extensions/browser/chrome-extension/modules/relay-core.test.ts b/extensions/browser/chrome-extension/modules/relay-core.test.ts index 7a57ce2d823c..60341d3126a4 100644 --- a/extensions/browser/chrome-extension/modules/relay-core.test.ts +++ b/extensions/browser/chrome-extension/modules/relay-core.test.ts @@ -7,7 +7,7 @@ import { nearestGroupColor, parsePairingString, reconnectDelayMs, - isDirectLoopbackRelayUrl, + directLoopbackRelayPort, } from "./relay-core.js"; const RELAY_SECRET = "a".repeat(64); @@ -394,19 +394,31 @@ describe("nearestGroupColor", () => { }); }); -describe("isDirectLoopbackRelayUrl", () => { +describe("directLoopbackRelayPort", () => { it("accepts only loopback ws:// URLs on the direct /extension path", () => { - expect(isDirectLoopbackRelayUrl("ws://127.0.0.1:18799/extension")).toBe(true); - expect(isDirectLoopbackRelayUrl("ws://localhost:18799/extension")).toBe(true); - expect(isDirectLoopbackRelayUrl("ws://[::1]:18799/extension")).toBe(true); + expect(directLoopbackRelayPort("ws://127.0.0.1:18799/extension")).toBe(18799); + expect(directLoopbackRelayPort("ws://localhost:18799/extension")).toBe(18799); + expect(directLoopbackRelayPort("ws://[::1]:18799/extension")).toBe(18799); + expect(directLoopbackRelayPort("ws://127.0.0.1:20123/extension?profile=work")).toBe(20123); }); it("rejects gateway routes, remote hosts, and malformed values", () => { - expect(isDirectLoopbackRelayUrl("ws://127.0.0.1:18789/browser/extension")).toBe(false); - expect(isDirectLoopbackRelayUrl("wss://gateway.example.com/browser/extension")).toBe(false); - expect(isDirectLoopbackRelayUrl("ws://10.0.0.5:18799/extension")).toBe(false); - expect(isDirectLoopbackRelayUrl("http://127.0.0.1:18799/extension")).toBe(false); - expect(isDirectLoopbackRelayUrl("not a url")).toBe(false); - expect(isDirectLoopbackRelayUrl(undefined)).toBe(false); + expect(directLoopbackRelayPort("ws://127.0.0.1:18789/browser/extension")).toBeNull(); + expect(directLoopbackRelayPort("wss://gateway.example.com/browser/extension")).toBeNull(); + expect(directLoopbackRelayPort("ws://10.0.0.5:18799/extension")).toBeNull(); + expect(directLoopbackRelayPort("http://127.0.0.1:18799/extension")).toBeNull(); + expect(directLoopbackRelayPort("not a url")).toBeNull(); + expect(directLoopbackRelayPort(undefined)).toBeNull(); + }); + + it.each([ + "ws://user:password@127.0.0.1:18799/extension", + "ws://127.0.0.1:18799/extension#secret", + "ws://127.0.0.1:18799/extension?host=remote", + "ws://127.0.0.1:18799/extension?profile=one&profile=two", + "ws://127.0.0.1:0/extension", + "wss://127.0.0.1:18799/extension", + ])("rejects noncanonical or unsupported wake-up target %s", (url) => { + expect(directLoopbackRelayPort(url)).toBeNull(); }); }); diff --git a/extensions/browser/native-host-entry.ts b/extensions/browser/native-host-entry.ts index 70855109671b..e74fae970e11 100644 --- a/extensions/browser/native-host-entry.ts +++ b/extensions/browser/native-host-entry.ts @@ -4,10 +4,7 @@ import { parseBrowserNativeHostOrigins, runBrowserNativeHost, } from "./src/browser/extension-native-host.js"; -import { - buildBrowserExtensionPairing, - firstExtensionRelayPort, -} from "./src/browser/extension-pairing.js"; +import { buildBrowserExtensionPairing } from "./src/browser/extension-pairing.js"; import { ensureExtensionRelayDaemonProcess } from "./src/browser/extension-relay-daemon-spawn.js"; function requiredArgument(name: string): string { @@ -38,9 +35,10 @@ async function main(): Promise { }), // The daemon entry is built as this entry's sibling, so resolve it from // this file's own location rather than a shared chunk path. - ensureRelay: async () => + ensureRelay: async (port) => await ensureExtensionRelayDaemonProcess({ - port: firstExtensionRelayPort(getRuntimeConfig()), + port, + cfg: getRuntimeConfig(), entryPath: fileURLToPath(new URL("./relay-daemon-entry.js", import.meta.url)), }), }); diff --git a/extensions/browser/src/browser/extension-native-host.test.ts b/extensions/browser/src/browser/extension-native-host.test.ts index fe368ff67ed1..5009a5750420 100644 --- a/extensions/browser/src/browser/extension-native-host.test.ts +++ b/extensions/browser/src/browser/extension-native-host.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { withEnvAsync } from "openclaw/plugin-sdk/test-env"; import { afterEach, describe, expect, it, vi } from "vitest"; import { relayTestKey } from "../../chrome-extension/relay-key.test-support.js"; import { parseBrowserNativeHostOrigins, runBrowserNativeHost } from "./extension-native-host.js"; @@ -9,6 +10,9 @@ import { encodeBrowserNativeResponse, readBrowserNativeFrame, } from "./extension-native-protocol.js"; +import { ensureExtensionRelayDaemonProcess } from "./extension-relay-daemon-spawn.js"; +import { runExtensionRelayDaemon } from "./relay-daemon.js"; +import { getFreePort } from "./test-port.js"; const EXTENSION_ID = "abcdefghijklmnopabcdefghijklmnop"; const ORIGIN = `chrome-extension://${EXTENSION_ID}/`; @@ -200,6 +204,7 @@ async function invokeHost(overrides: Partial writes.push(value), buildPairing: async () => ({ pairingString: PAIRING, topology: "local" }), + ensureRelay: async () => "skipped", ...overrides, }); return { response, writes, fixture }; @@ -293,6 +298,7 @@ describe("native host origin and topology boundary", () => { input: chunks(frame(requestJson())), write: vi.fn(), buildPairing, + ensureRelay: async () => "skipped", }); expect(response).toEqual({ v: 1, ok: false, code: "manifest_invalid" }); @@ -320,6 +326,7 @@ describe("native host origin and topology boundary", () => { input: chunks(frame(requestJson())), write: (value) => writes.push(value), buildPairing: async () => ({ pairingString: PAIRING, topology: "local" }), + ensureRelay: async () => "skipped", }); expect(response).toEqual({ v: 1, ok: false, code: "manifest_invalid" }); }); @@ -341,10 +348,118 @@ describe("native host origin and topology boundary", () => { }); describe("native host ensure_relay", () => { + it.each([ + ["missing port", requestJson({ op: "ensure_relay" })], + ...[0, -1, 65536, 18799.5, "18799", null, {}, [18799]].map((relayPort) => [ + `invalid port ${JSON.stringify(relayPort)}`, + requestJson({ op: "ensure_relay", relayPort }), + ]), + [ + "duplicate port", + `{"v":1,"op":"ensure_relay","nonce":"${NONCE}","relayPort":18799,"relayPort":18798}`, + ], + [ + "escaped duplicate port", + `{"v":1,"op":"ensure_relay","nonce":"${NONCE}","relayPort":18799,"relay\\u0050ort":18798}`, + ], + ...["host", "entryPath", "token", "profile"].map((key) => [ + key, + requestJson({ op: "ensure_relay", relayPort: 18799, [key]: "untrusted" }), + ]), + ["bootstrap with target", requestJson({ relayPort: 18799 })], + ])("rejects %s without invoking the relay launcher", async (_label, raw) => { + const ensureRelay = vi.fn(async () => "spawned" as const); + const result = await invokeHost({ input: chunks(frame(raw)), ensureRelay }); + expect(result.response).toEqual({ v: 1, ok: false, code: "invalid_request" }); + expect(ensureRelay).not.toHaveBeenCalled(); + }); + + it.each([ + ["unconfigured", 20124], + ["managed browser", 18800], + ["Gateway", 18789], + ["remote browser", 29443], + ])("rejects the %s port before probing or spawning", async (_label, relayPort) => { + const probe = vi.fn(async () => false); + const spawnProcess = vi.fn(); + const result = await invokeHost({ + input: chunks(frame(requestJson({ op: "ensure_relay", relayPort }))), + ensureRelay: async (port) => + await ensureExtensionRelayDaemonProcess({ + port, + cfg: { browser: { profiles: { remote: { cdpUrl: "https://browser.example:29443" } } } }, + entryPath: "/opt/openclaw/dist/extensions/browser/relay-daemon-entry.js", + probe, + spawnProcess, + }), + }); + expect(result.response).toEqual({ v: 1, ok: false, code: "relay_unavailable" }); + expect(probe).not.toHaveBeenCalled(); + expect(spawnProcess).not.toHaveBeenCalled(); + }); + + it.each(["non-first automatic", "explicitly pinned"])( + "wakes the %s profile through the native frame and config boundary", + async (allocation) => { + const fixture = await nativeFixture(); + const relayPort = await getFreePort(); + await fs.mkdir(path.join(fixture.stateDir, "credentials"), { mode: 0o700 }); + await fs.writeFile( + path.join(fixture.stateDir, "credentials", "browser-extension-relay.secret"), + relayTestKey(1), + { mode: 0o600 }, + ); + await withEnvAsync( + { OPENCLAW_STATE_DIR: fixture.stateDir, OPENCLAW_GATEWAY_PORT: undefined }, + async () => { + let daemon: ReturnType | undefined; + try { + const result = await invokeHost({ + ...fixture, + input: chunks(frame(requestJson({ op: "ensure_relay", relayPort }))), + ensureRelay: async (port) => + await ensureExtensionRelayDaemonProcess({ + port, + cfg: { + gateway: { port: relayPort - 9 }, + browser: { + profiles: { + chrome: { driver: "extension" }, + work: { + driver: "extension", + ...(allocation === "explicitly pinned" ? { cdpPort: relayPort } : {}), + }, + }, + }, + }, + entryPath: "/opt/openclaw/dist/extensions/browser/relay-daemon-entry.js", + // Keep the real config, port probe, credential read and relay server; + // only replace process creation so the test owns daemon cleanup. + spawnProcess: (_command, args) => { + daemon = runExtensionRelayDaemon({ port: Number(args[2]) }); + }, + }), + }); + expect(result.response).toEqual({ v: 1, ok: true, nonce: NONCE, relay: "spawned" }); + const run = await daemon; + expect(run?.port).toBe(relayPort); + const response = await fetch(`http://127.0.0.1:${relayPort}/json/version`); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "Unauthorized" }); + } finally { + const run = await daemon; + run?.stop(); + await run?.done; + } + }, + ); + }, + ); + it("reports the injected relay status with the echoed nonce", async () => { const ensureRelay = vi.fn(async () => "spawned" as const); const result = await invokeHost({ - input: chunks(frame(requestJson({ op: "ensure_relay" }))), + input: chunks(frame(requestJson({ op: "ensure_relay", relayPort: 18799 }))), ensureRelay, }); expect(result.response).toEqual({ v: 1, ok: true, nonce: NONCE, relay: "spawned" }); @@ -352,16 +467,9 @@ describe("native host ensure_relay", () => { expect(result.writes).toHaveLength(1); }); - it("reports skipped when no relay launcher is wired", async () => { - const result = await invokeHost({ - input: chunks(frame(requestJson({ op: "ensure_relay" }))), - }); - expect(result.response).toEqual({ v: 1, ok: true, nonce: NONCE, relay: "skipped" }); - }); - it("maps a relay launcher failure to relay_unavailable", async () => { const result = await invokeHost({ - input: chunks(frame(requestJson({ op: "ensure_relay" }))), + input: chunks(frame(requestJson({ op: "ensure_relay", relayPort: 18799 }))), ensureRelay: async () => { throw new Error("spawn failed"); }, @@ -372,7 +480,7 @@ describe("native host ensure_relay", () => { it("still validates the manifest before ensuring the relay", async () => { const ensureRelay = vi.fn(async () => "spawned" as const); const result = await invokeHost({ - input: chunks(frame(requestJson({ op: "ensure_relay" }))), + input: chunks(frame(requestJson({ op: "ensure_relay", relayPort: 18799 }))), callerOrigin: OTHER_ORIGIN, ensureRelay, }); diff --git a/extensions/browser/src/browser/extension-native-host.ts b/extensions/browser/src/browser/extension-native-host.ts index f96d7b8496fc..335be10b2662 100644 --- a/extensions/browser/src/browser/extension-native-host.ts +++ b/extensions/browser/src/browser/extension-native-host.ts @@ -138,7 +138,7 @@ export async function runBrowserNativeHost(params: { write: (frame: Buffer) => void; buildPairing: () => Promise<{ pairingString: string; topology: string }>; /** Ensure the standalone extension relay daemon is running (ensure_relay op). */ - ensureRelay?: () => Promise; + ensureRelay: (port: number) => Promise; stateDir?: string; platform?: NodeJS.Platform; }): Promise { @@ -166,7 +166,7 @@ export async function runBrowserNativeHost(params: { } if (decoded.request.op === "ensure_relay") { try { - const relay = params.ensureRelay ? await params.ensureRelay() : "skipped"; + const relay = await params.ensureRelay(decoded.request.relayPort); response = { v: 1, ok: true, nonce: decoded.request.nonce, relay }; } catch { response = { v: 1, ok: false, code: "relay_unavailable" }; diff --git a/extensions/browser/src/browser/extension-native-protocol.ts b/extensions/browser/src/browser/extension-native-protocol.ts index 1961d28e46b6..cc3a0d32f656 100644 --- a/extensions/browser/src/browser/extension-native-protocol.ts +++ b/extensions/browser/src/browser/extension-native-protocol.ts @@ -15,7 +15,9 @@ type BrowserNativeFailureCode = | "pairing_unavailable" | "relay_unavailable"; export type BrowserNativeRelayEnsureStatus = "spawned" | "running" | "skipped"; -type BrowserNativeBootstrapRequest = { v: 1; op: "bootstrap" | "ensure_relay"; nonce: string }; +type BrowserNativeBootstrapRequest = + | { v: 1; op: "bootstrap"; nonce: string } + | { v: 1; op: "ensure_relay"; nonce: string; relayPort: number }; export type BrowserNativeBootstrapResponse = | { v: 1; ok: true; nonce: string; pairingString: string } | { v: 1; ok: true; nonce: string; relay: BrowserNativeRelayEnsureStatus } @@ -142,10 +144,6 @@ function parseBrowserNativeRequest(raw: string): BrowserNativeBootstrapRequest | if (!keys || new Set(keys).size !== keys.length) { return null; } - const expected = ["v", "op", "nonce"]; - if (keys.length !== expected.length || !expected.every((key) => keys.includes(key))) { - return null; - } let parsed: unknown; try { parsed = JSON.parse(raw); @@ -153,10 +151,23 @@ function parseBrowserNativeRequest(raw: string): BrowserNativeBootstrapRequest | return null; } const record = asNullableRecord(parsed); - return record?.v === 1 && - (record.op === "bootstrap" || record.op === "ensure_relay") && - isCanonicalNonce(record.nonce) - ? { v: 1, op: record.op, nonce: record.nonce } + if (record?.v !== 1 || !isCanonicalNonce(record.nonce)) { + return null; + } + const expected = + record.op === "ensure_relay" ? ["v", "op", "nonce", "relayPort"] : ["v", "op", "nonce"]; + if (keys.length !== expected.length || !expected.every((key) => keys.includes(key))) { + return null; + } + if (record.op === "bootstrap") { + return { v: 1, op: "bootstrap", nonce: record.nonce }; + } + return record.op === "ensure_relay" && + typeof record.relayPort === "number" && + Number.isInteger(record.relayPort) && + record.relayPort >= 1 && + record.relayPort <= 65_535 + ? { v: 1, op: "ensure_relay", nonce: record.nonce, relayPort: record.relayPort } : null; } diff --git a/extensions/browser/src/browser/extension-pairing.ts b/extensions/browser/src/browser/extension-pairing.ts index d84906603eee..be0bb7f4bf0e 100644 --- a/extensions/browser/src/browser/extension-pairing.ts +++ b/extensions/browser/src/browser/extension-pairing.ts @@ -14,7 +14,7 @@ type BrowserExtensionPairing = { type PairingConfig = OpenClawConfig & { browser?: BrowserConfig }; -export function firstExtensionRelayPort(cfg: PairingConfig): number { +function firstExtensionRelayPort(cfg: PairingConfig): number { const resolved = resolveBrowserConfig(cfg.browser, cfg); for (const [name, profile] of Object.entries(resolved.profiles)) { if (profile.driver === "extension") { diff --git a/extensions/browser/src/browser/extension-relay-daemon-spawn.test.ts b/extensions/browser/src/browser/extension-relay-daemon-spawn.test.ts index b93a11d3c360..e4d049e4af04 100644 --- a/extensions/browser/src/browser/extension-relay-daemon-spawn.test.ts +++ b/extensions/browser/src/browser/extension-relay-daemon-spawn.test.ts @@ -1,9 +1,6 @@ import net from "node:net"; import { describe, expect, it, vi } from "vitest"; -import { - ensureExtensionRelayDaemonProcess, - isRelayPortServed, -} from "./extension-relay-daemon-spawn.js"; +import { ensureExtensionRelayDaemonProcess } from "./extension-relay-daemon-spawn.js"; const ENTRY = "/opt/openclaw/dist/extensions/browser/relay-daemon-entry.js"; @@ -11,6 +8,7 @@ describe("ensureExtensionRelayDaemonProcess", () => { it("skips when no relay credential exists", async () => { const spawnProcess = vi.fn(); const status = await ensureExtensionRelayDaemonProcess({ + cfg: {}, port: 18_799, entryPath: ENTRY, readToken: () => null, @@ -21,22 +19,10 @@ describe("ensureExtensionRelayDaemonProcess", () => { expect(spawnProcess).not.toHaveBeenCalled(); }); - it("reports running without spawning when the port is already served", async () => { - const spawnProcess = vi.fn(); - const status = await ensureExtensionRelayDaemonProcess({ - port: 18_799, - entryPath: ENTRY, - readToken: () => "a".repeat(64), - probe: async () => true, - spawnProcess, - }); - expect(status).toBe("running"); - expect(spawnProcess).not.toHaveBeenCalled(); - }); - it("spawns the daemon entry with the resolved port", async () => { const spawnProcess = vi.fn(); const status = await ensureExtensionRelayDaemonProcess({ + cfg: { browser: { profiles: { work: { driver: "extension", cdpPort: 19123 } } } }, port: 19_123, entryPath: ENTRY, execPath: "/usr/bin/node", @@ -49,21 +35,31 @@ describe("ensureExtensionRelayDaemonProcess", () => { }); }); -describe("isRelayPortServed", () => { - it("detects a listening loopback server and a closed port", async () => { +describe("relay port ownership", () => { + it("leaves an existing listener alone and spawns only after it closes", async () => { const server = net.createServer(); await new Promise((resolve) => { server.listen(0, "127.0.0.1", () => resolve()); }); const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; + const spawnProcess = vi.fn(); + const params = { + cfg: { browser: { profiles: { work: { driver: "extension" as const, cdpPort: port } } } }, + port, + entryPath: ENTRY, + readToken: () => "a".repeat(64), + spawnProcess, + }; try { - expect(await isRelayPortServed(port)).toBe(true); + expect(await ensureExtensionRelayDaemonProcess(params)).toBe("running"); + expect(spawnProcess).not.toHaveBeenCalled(); } finally { await new Promise((resolve) => { server.close(() => resolve()); }); } - expect(await isRelayPortServed(port)).toBe(false); + expect(await ensureExtensionRelayDaemonProcess(params)).toBe("spawned"); + expect(spawnProcess).toHaveBeenCalledOnce(); }); }); diff --git a/extensions/browser/src/browser/extension-relay-daemon-spawn.ts b/extensions/browser/src/browser/extension-relay-daemon-spawn.ts index 8f05c35901ae..718b27eaba00 100644 --- a/extensions/browser/src/browser/extension-relay-daemon-spawn.ts +++ b/extensions/browser/src/browser/extension-relay-daemon-spawn.ts @@ -1,12 +1,14 @@ import { spawn } from "node:child_process"; import net from "node:net"; +import type { OpenClawConfig } from "../sdk-config.js"; +import { resolveBrowserConfig, resolveProfile } from "./config.js"; import type { BrowserNativeRelayEnsureStatus } from "./extension-native-protocol.js"; import { readExtensionRelayToken } from "./extension-relay/relay-auth.js"; const PORT_PROBE_TIMEOUT_MS = 750; /** True when something already accepts connections on the loopback port. */ -export async function isRelayPortServed(port: number): Promise { +async function isRelayPortServed(port: number): Promise { return await new Promise((resolve) => { const socket = net.connect({ host: "127.0.0.1", port }); const finish = (served: boolean): void => { @@ -26,12 +28,25 @@ export async function isRelayPortServed(port: number): Promise { */ export async function ensureExtensionRelayDaemonProcess(params: { port: number; + cfg: OpenClawConfig; entryPath: string; execPath?: string; readToken?: () => string | null; probe?: (port: number) => Promise; spawnProcess?: (command: string, args: string[]) => void; }): Promise { + // Resolve current config only after native caller validation. A pairing may + // outlive its profile; never wake a removed target or substitute another port. + const resolved = resolveBrowserConfig(params.cfg.browser, params.cfg); + const configured = Object.keys(resolved.profiles).some((name) => { + const profile = resolved.profiles[name]; + return ( + profile?.driver === "extension" && resolveProfile(resolved, name)?.cdpPort === params.port + ); + }); + if (!resolved.enabled || !configured) { + throw new Error("Relay port is not configured for an extension profile"); + } const readToken = params.readToken ?? readExtensionRelayToken; if (!readToken()) { return "skipped"; diff --git a/extensions/browser/src/browser/extension-relay/relay-auth.test.ts b/extensions/browser/src/browser/extension-relay/relay-auth.test.ts index ba51297482c6..eaa44531519c 100644 --- a/extensions/browser/src/browser/extension-relay/relay-auth.test.ts +++ b/extensions/browser/src/browser/extension-relay/relay-auth.test.ts @@ -2,14 +2,19 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createSecretFileAtomic } from "openclaw/plugin-sdk/secret-file"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - classifyRelaySecretPrivacy, ensureExtensionRelayToken, readExtensionRelayToken, resolveExtensionRelayToken, } from "./relay-auth.js"; +vi.mock("openclaw/plugin-sdk/secret-file", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createSecretFileAtomic: vi.fn(actual.createSecretFileAtomic) }; +}); + let stateDir = ""; const prevStateDir = process.env.OPENCLAW_STATE_DIR; @@ -18,6 +23,7 @@ beforeEach(() => { process.env.OPENCLAW_STATE_DIR = stateDir; }); afterEach(() => { + vi.restoreAllMocks(); if (prevStateDir === undefined) { delete process.env.OPENCLAW_STATE_DIR; } else { @@ -57,6 +63,30 @@ describe("extension relay host-local secret", () => { expect(readExtensionRelayToken()).toBe(first); }); + it("waits for an exclusive first writer to finish its secret", async () => { + const winner = "ab".repeat(32); + let finishWrite = Promise.resolve(); + vi.mocked(createSecretFileAtomic).mockImplementationOnce(async ({ rootDir, filePath }) => { + fs.mkdirSync(rootDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(filePath, "", { mode: 0o600 }); + finishWrite = new Promise((resolve) => { + setTimeout(() => { + fs.writeFileSync(filePath, winner); + resolve(); + }, 25); + }); + throw Object.assign(new Error("another process created the secret"), { + code: "secret-exists", + }); + }); + try { + await expect(ensureExtensionRelayToken()).resolves.toBe(winner); + expect(readExtensionRelayToken()).toBe(winner); + } finally { + await finishWrite; + } + }); + it("gives different hosts (state dirs) different secrets", async () => { const a = await ensureExtensionRelayToken(); const otherDir = fs.realpathSync( @@ -73,18 +103,43 @@ describe("extension relay host-local secret", () => { const secretFilePath = (): string => path.join(stateDir, "credentials", "browser-extension-relay.secret"); - it.runIf(process.platform !== "win32")( - "self-heals a group/other-readable secret to 0600 and still reads it", - async () => { + it.runIf(process.platform !== "win32").each([0o644, 0o660])( + "self-heals a secret with mode %i to 0600 and still reads it", + async (mode) => { const token = await ensureExtensionRelayToken(); const secretPath = secretFilePath(); - fs.chmodSync(secretPath, 0o644); + fs.chmodSync(secretPath, mode); // Reading tightens the mode back to private and still returns the token. expect(readExtensionRelayToken()).toBe(token); expect(fs.statSync(secretPath).mode & 0o777).toBe(0o600); }, ); + it.runIf(process.platform !== "win32")( + "refuses a foreign-owned secret without changing it", + async () => { + const token = await ensureExtensionRelayToken(); + const secretPath = secretFilePath(); + const owner = fs.statSync(secretPath).uid; + vi.spyOn(process, "getuid").mockReturnValue(owner + 1); + expect(readExtensionRelayToken()).toBeNull(); + await expect(ensureExtensionRelayToken()).rejects.toThrow("unreadable/malformed"); + expect(fs.readFileSync(secretPath, "utf8").trim()).toBe(token); + }, + ); + + it.runIf(process.platform !== "win32")( + "refuses a broad-mode secret when tightening fails", + async () => { + await ensureExtensionRelayToken(); + fs.chmodSync(secretFilePath(), 0o644); + vi.spyOn(fs, "chmodSync").mockImplementation(() => { + throw new Error("permission denied"); + }); + expect(readExtensionRelayToken()).toBeNull(); + }, + ); + it("refuses a symlinked secret", async () => { const token = await ensureExtensionRelayToken(); const secretPath = secretFilePath(); @@ -95,23 +150,3 @@ describe("extension relay host-local secret", () => { expect(readExtensionRelayToken()).toBeNull(); }); }); - -describe("classifyRelaySecretPrivacy", () => { - it("accepts a private, self-owned file", () => { - expect(classifyRelaySecretPrivacy({ uid: 501, mode: 0o600 }, 501, "linux")).toBe("ok"); - }); - - it("flags a self-owned file with broad mode for healing", () => { - expect(classifyRelaySecretPrivacy({ uid: 501, mode: 0o644 }, 501, "linux")).toBe("heal"); - expect(classifyRelaySecretPrivacy({ uid: 501, mode: 0o660 }, 501, "linux")).toBe("heal"); - }); - - it("refuses a foreign-owned file regardless of mode", () => { - expect(classifyRelaySecretPrivacy({ uid: 0, mode: 0o600 }, 501, "linux")).toBe("refuse"); - }); - - it("trusts Windows ACLs and an unknown uid instead of POSIX bits", () => { - expect(classifyRelaySecretPrivacy({ uid: 0, mode: 0o777 }, 501, "win32")).toBe("ok"); - expect(classifyRelaySecretPrivacy({ uid: 0, mode: 0o777 }, undefined, "linux")).toBe("ok"); - }); -}); diff --git a/extensions/browser/src/browser/extension-relay/relay-auth.ts b/extensions/browser/src/browser/extension-relay/relay-auth.ts index dfc7a7d7fc46..800d0da7a902 100644 --- a/extensions/browser/src/browser/extension-relay/relay-auth.ts +++ b/extensions/browser/src/browser/extension-relay/relay-auth.ts @@ -12,6 +12,7 @@ import fs from "node:fs"; import path from "node:path"; import { createSecretFileAtomic, tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file"; import { resolveOAuthDir } from "openclaw/plugin-sdk/state-paths"; +import { extractErrorCode } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; const log = createSubsystemLogger("browser").child("extension-relay"); @@ -31,36 +32,6 @@ function normalizeToken(raw: string): string | null { return /^[0-9a-f]{64}$/.test(value) ? value : null; } -/** - * The relay secret is the whole auth model: anyone who can read it drives the - * user's real browser, and loopback is not a trust boundary on a multi-user - * host. The fs-safe reader rejects symlinks/hardlinks but does not re-check the - * file mode or owner on read, so a secret whose permissions drifted - * group/other-readable (loosened umask, restore, shared home) would still be - * trusted. Classify the file's privacy before use. - */ -export type RelaySecretPrivacy = "ok" | "heal" | "refuse"; - -/** - * Pure privacy decision for a secret file's stat. `heal`: we own it but the - * mode is too broad — tighten to 0600 and continue. `refuse`: owned by another - * user (never trust a foreign-owned credential). Windows uses ACLs, not POSIX - * mode bits, and the create path establishes them, so it is always `ok` here. - */ -export function classifyRelaySecretPrivacy( - stat: { uid: number; mode: number }, - selfUid: number | undefined, - platform: NodeJS.Platform = process.platform, -): RelaySecretPrivacy { - if (platform === "win32" || selfUid === undefined) { - return "ok"; - } - if (stat.uid !== selfUid) { - return "refuse"; - } - return (stat.mode & 0o077) === 0 ? "ok" : "heal"; -} - /** * Return the secret path only when it is safe to read: absent (caller handles * null), already private, or self-healable by tightening our own file's mode. @@ -74,18 +45,20 @@ function resolveUsableRelaySecretPath(env: NodeJS.ProcessEnv): string | null { stat = fs.lstatSync(secretPath); } catch (err) { // Absent is the normal "not paired yet" case; let the reader return null. - return (err as NodeJS.ErrnoException).code === "ENOENT" ? secretPath : null; + return extractErrorCode(err) === "ENOENT" ? secretPath : null; } if (stat.isSymbolicLink() || !stat.isFile()) { log.warn("ignoring extension relay secret: not a regular file"); return null; } - const decision = classifyRelaySecretPrivacy(stat, process.getuid?.()); - if (decision === "refuse") { + // The safe reader checks file type/link count, not ownership or POSIX mode. + // Windows creation uses ACLs; do not interpret its synthetic mode bits. + const selfUid = process.platform === "win32" ? undefined : process.getuid?.(); + if (selfUid !== undefined && stat.uid !== selfUid) { log.warn("ignoring extension relay secret: owned by another user"); return null; } - if (decision === "heal") { + if (selfUid !== undefined && (stat.mode & 0o077) !== 0) { try { fs.chmodSync(secretPath, PRIVATE_SECRET_FILE_MODE); log.warn("tightened extension relay secret permissions to 0600"); @@ -134,16 +107,20 @@ export async function ensureExtensionRelayToken( }); return token; } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "secret-exists") { + if (extractErrorCode(err) !== "secret-exists") { throw err; } // Another process created it first; its exclusive async write may still be // finishing after the final name appears, so adopt it with a bounded reread. // Reuse the hardened sync read so a foreign-owned file is never adopted here. for (let attempt = 0; attempt < RELAY_SECRET_REREAD_ATTEMPTS; attempt += 1) { - const winner = readExtensionRelayToken(env); - if (winner) { - return winner; + try { + const winner = readExtensionRelayToken(env); + if (winner) { + return winner; + } + } catch { + // The safe reader rejects an empty file during the exclusive write. } await new Promise((resolve) => { setTimeout(resolve, RELAY_SECRET_REREAD_DELAY_MS); diff --git a/extensions/browser/src/browser/relay-daemon.ts b/extensions/browser/src/browser/relay-daemon.ts index 50362c940aa8..e64b976c53cb 100644 --- a/extensions/browser/src/browser/relay-daemon.ts +++ b/extensions/browser/src/browser/relay-daemon.ts @@ -1,3 +1,4 @@ +import { extractErrorCode } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { readExtensionRelayToken } from "./extension-relay/relay-auth.js"; import { @@ -8,12 +9,12 @@ import { const log = createSubsystemLogger("browser").child("relay-daemon"); /** Default grace before a daemon with no extension and no CDP clients exits. */ -export const RELAY_DAEMON_IDLE_EXIT_MS = 10 * 60 * 1000; +const RELAY_DAEMON_IDLE_EXIT_MS = 10 * 60 * 1000; const IDLE_POLL_MS = 30 * 1000; -export type RelayDaemonExitReason = "port-in-use" | "no-credential" | "idle" | "stopped"; +type RelayDaemonExitReason = "port-in-use" | "no-credential" | "idle" | "stopped"; -export type RelayDaemonRun = { +type RelayDaemonRun = { /** Resolves when the daemon decides to exit; the caller owns process.exit. */ done: Promise; /** Bound relay port when the server started; null when startup was refused. */ @@ -63,7 +64,7 @@ export async function runExtensionRelayDaemon(params: { try { handle = await startExtensionRelayServer({ port: params.port, token, allowLegacyAuth }); } catch (error) { - if ((error as NodeJS.ErrnoException | null)?.code === "EADDRINUSE") { + if (extractErrorCode(error) === "EADDRINUSE") { log.info(`relay port ${params.port} is already served; standalone daemon not needed`); resolveDone("port-in-use"); return { done, port: null, stop: () => {} };