From 82b7fd030cd3f8a20ca388cdd34834d4b4f93fd4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 23 Aug 2026 16:04:37 -0700 Subject: [PATCH] fix(browser): make standalone relay daemon v2-only by default The daemon previously called startExtensionRelayServer without allowLegacyAuth, inheriting the permissive ?? true default and silently re-enabling legacy one-directional auth (Bearer/Basic/token-subprotocol) even where an operator set extensionRelay.allowLegacyAuth=false. Because legacy auth sends the secret to whoever holds the port, a process that squats the relay port could harvest the credential from a legacy client. Default the standalone daemon to v2-only (mutual HMAC, which the extension and mcporter both speak), honoring an explicit allowLegacyAuth=true opt-in read from the raw config. Adversarial review via subagents surfaced this as the one operator-config regression the PR introduced. --- CHANGELOG.md | 2 +- extensions/browser/relay-daemon-entry.ts | 21 +++++- .../browser/src/browser/relay-daemon.test.ts | 72 ++++++++++++++++++- .../browser/src/browser/relay-daemon.ts | 11 ++- 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dda839462b1..58a96348517f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ 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. +- **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. - **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/extensions/browser/relay-daemon-entry.ts b/extensions/browser/relay-daemon-entry.ts index 632f9aff18b1..69d961d594c6 100644 --- a/extensions/browser/relay-daemon-entry.ts +++ b/extensions/browser/relay-daemon-entry.ts @@ -4,10 +4,26 @@ * Playwright, chrome-devtools-mcp) attach through the same relay port. Spawned * on demand by the native messaging host, or run manually. */ +import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot"; import { runExtensionRelayDaemon } from "./src/browser/relay-daemon.js"; const DEFAULT_RELAY_PORT = 18_799; +/** + * The standalone daemon is v2-only by default. Honor an explicit + * `browser.extensionRelay.allowLegacyAuth=true` opt-in, but never fall back to + * legacy on a read/parse failure — fail closed to the stricter mode. Read the + * raw config value (not the resolved `?? true` default) so an unset key stays + * v2-only rather than inheriting the gateway's permissive default. + */ +function resolveAllowLegacyAuth(): boolean { + try { + return getRuntimeConfig().browser?.extensionRelay?.allowLegacyAuth === true; + } catch { + return false; + } +} + function resolvePortArgument(argv: string[]): number { const index = argv.indexOf("--port"); const raw = index >= 0 ? argv[index + 1] : undefined; @@ -22,7 +38,10 @@ function resolvePortArgument(argv: string[]): number { } async function main(): Promise { - const run = await runExtensionRelayDaemon({ port: resolvePortArgument(process.argv.slice(2)) }); + const run = await runExtensionRelayDaemon({ + port: resolvePortArgument(process.argv.slice(2)), + allowLegacyAuth: resolveAllowLegacyAuth(), + }); const stop = (): void => run.stop(); process.once("SIGINT", stop); process.once("SIGTERM", stop); diff --git a/extensions/browser/src/browser/relay-daemon.test.ts b/extensions/browser/src/browser/relay-daemon.test.ts index a85f4a353206..292b5ccf2f28 100644 --- a/extensions/browser/src/browser/relay-daemon.test.ts +++ b/extensions/browser/src/browser/relay-daemon.test.ts @@ -1,10 +1,39 @@ +import fs from "node:fs/promises"; import net from "node:net"; -import { describe, expect, it } from "vitest"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; import { relayTestKey } from "../../chrome-extension/relay-key.test-support.js"; import { runExtensionRelayDaemon } from "./relay-daemon.js"; const TOKEN = relayTestKey(1); +const tempStateDirs: string[] = []; +const savedStateDirEnv = process.env.OPENCLAW_STATE_DIR; + +afterEach(async () => { + if (savedStateDirEnv === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = savedStateDirEnv; + } + await Promise.all( + tempStateDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); +}); + +/** Point readExtensionRelayToken() at an isolated credentials dir holding TOKEN. */ +async function stageRelaySecret(): Promise { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-relay-daemon-")); + tempStateDirs.push(stateDir); + const credentialsDir = path.join(stateDir, "credentials"); + await fs.mkdir(credentialsDir, { recursive: true, mode: 0o700 }); + await fs.writeFile(path.join(credentialsDir, "browser-extension-relay.secret"), `${TOKEN}\n`, { + mode: 0o600, + }); + process.env.OPENCLAW_STATE_DIR = stateDir; +} + describe("runExtensionRelayDaemon", () => { it("refuses to start without a relay credential", async () => { const run = await runExtensionRelayDaemon({ port: 0, readToken: () => null }); @@ -51,6 +80,47 @@ describe("runExtensionRelayDaemon", () => { await expect(run.done).resolves.toBe("idle"); }); + it("is v2-only by default: rejects a VALID legacy Bearer credential so a squatter cannot harvest the secret", async () => { + await stageRelaySecret(); + const run = await runExtensionRelayDaemon({ + port: 0, + idleExitMs: 60_000, + pollMs: 60_000, + }); + try { + // Even the correct secret over legacy one-directional auth is refused + // when allowLegacyAuth is not explicitly enabled. + const status = await fetch(`http://127.0.0.1:${run.port}/json/version`, { + headers: { Authorization: `Bearer ${TOKEN}` }, + }); + expect(status.status).toBe(401); + } finally { + run.stop(); + await run.done; + } + }); + + it("honors an explicit allowLegacyAuth opt-in", async () => { + await stageRelaySecret(); + const run = await runExtensionRelayDaemon({ + port: 0, + allowLegacyAuth: true, + idleExitMs: 60_000, + pollMs: 60_000, + }); + try { + // With legacy auth enabled the credential is accepted; the request then + // reaches the "extension not connected" state (503) rather than 401. + const status = await fetch(`http://127.0.0.1:${run.port}/json/version`, { + headers: { Authorization: `Bearer ${TOKEN}` }, + }); + expect(status.status).toBe(503); + } finally { + run.stop(); + await run.done; + } + }); + it("stops on demand", async () => { const run = await runExtensionRelayDaemon({ port: 0, diff --git a/extensions/browser/src/browser/relay-daemon.ts b/extensions/browser/src/browser/relay-daemon.ts index 4b67bef1281f..50362c940aa8 100644 --- a/extensions/browser/src/browser/relay-daemon.ts +++ b/extensions/browser/src/browser/relay-daemon.ts @@ -30,11 +30,20 @@ export type RelayDaemonRun = { export async function runExtensionRelayDaemon(params: { port: number; readToken?: () => string | null; + /** + * Accept the legacy one-directional relay auth (Bearer/Basic/token + * subprotocol). Defaults to false: the standalone daemon is v2-only, so a + * process that squats the relay port cannot harvest the secret from a legacy + * client, and an operator's `extensionRelay.allowLegacyAuth=false` is never + * silently reverted. The extension and mcporter both speak v2. + */ + allowLegacyAuth?: boolean; idleExitMs?: number; pollMs?: number; now?: () => number; }): Promise { const readToken = params.readToken ?? readExtensionRelayToken; + const allowLegacyAuth = params.allowLegacyAuth ?? false; const now = params.now ?? Date.now; const idleExitMs = params.idleExitMs ?? RELAY_DAEMON_IDLE_EXIT_MS; const pollMs = params.pollMs ?? IDLE_POLL_MS; @@ -52,7 +61,7 @@ export async function runExtensionRelayDaemon(params: { let handle: ExtensionRelayHandle; try { - handle = await startExtensionRelayServer({ port: params.port, token }); + handle = await startExtensionRelayServer({ port: params.port, token, allowLegacyAuth }); } catch (error) { if ((error as NodeJS.ErrnoException | null)?.code === "EADDRINUSE") { log.info(`relay port ${params.port} is already served; standalone daemon not needed`);