fix(browser): wake the paired extension relay

This commit is contained in:
Peter Steinberger
2026-08-26 20:52:42 -07:00
parent 461e799646
commit 6637eb2aaa
21 changed files with 453 additions and 172 deletions
-1
View File
@@ -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.
-1
View File
@@ -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
+62 -14
View File
@@ -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:<port>/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.
@@ -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() {
@@ -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();
});
});
@@ -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,
@@ -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" };
}
@@ -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",
});
});
@@ -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;
@@ -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) {
@@ -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();
});
});
+4 -6
View File
@@ -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<void> {
}),
// 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)),
}),
});
@@ -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<Parameters<typeof runBrowserNativeH
input: chunks(frame(requestJson())),
write: (value) => 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<typeof runExtensionRelayDaemon> | 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,
});
@@ -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<BrowserNativeRelayEnsureStatus>;
ensureRelay: (port: number) => Promise<BrowserNativeRelayEnsureStatus>;
stateDir?: string;
platform?: NodeJS.Platform;
}): Promise<BrowserNativeBootstrapResponse> {
@@ -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" };
@@ -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;
}
@@ -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") {
@@ -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<void>((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<void>((resolve) => {
server.close(() => resolve());
});
}
expect(await isRelayPortServed(port)).toBe(false);
expect(await ensureExtensionRelayDaemonProcess(params)).toBe("spawned");
expect(spawnProcess).toHaveBeenCalledOnce();
});
});
@@ -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<boolean> {
async function isRelayPortServed(port: number): Promise<boolean> {
return await new Promise<boolean>((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<boolean> {
*/
export async function ensureExtensionRelayDaemonProcess(params: {
port: number;
cfg: OpenClawConfig;
entryPath: string;
execPath?: string;
readToken?: () => string | null;
probe?: (port: number) => Promise<boolean>;
spawnProcess?: (command: string, args: string[]) => void;
}): Promise<BrowserNativeRelayEnsureStatus> {
// 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";
@@ -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<typeof import("openclaw/plugin-sdk/secret-file")>();
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<void>((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");
});
});
@@ -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<void>((resolve) => {
setTimeout(resolve, RELAY_SECRET_REREAD_DELAY_MS);
@@ -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<RelayDaemonExitReason>;
/** 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: () => {} };