Skip to content

Commit 0a59513

Browse files
committed
feat(browser): make the extension relay work on remote browser nodes
Derives the relay auth token from a host-local secret in the credentials dir (created on first use) instead of gateway auth. Each machine that runs a browser — the gateway host and every browser node host — owns its own token, so the extension pairs with whichever machine hosts its Chrome and no gateway credential travels to a node. The node host already runs the shared browser control bootstrap, so this is all that was missing for cross-machine control. Also removes the "relay needs gateway auth before it can start" failure mode: startup and `openclaw browser extension pair` ensure the secret exists. Refs #53599
1 parent 6dbfb96 commit 0a59513

8 files changed

Lines changed: 184 additions & 96 deletions

File tree

docs/tools/chrome-extension.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ Three parts:
3131
tool calls.
3232
- **Extension relay** (loopback WebSocket): a small server the control service
3333
starts on `127.0.0.1`. It presents a Chrome DevTools Protocol endpoint to
34-
OpenClaw and speaks to the extension. Both sides are authenticated with a
35-
token derived from your Gateway auth.
34+
OpenClaw and speaks to the extension. Both sides authenticate with a
35+
host-local token (see below).
3636
- **OpenClaw Chrome extension** (MV3): attaches to tabs with `chrome.debugger`,
3737
forwards CDP traffic, and manages the **OpenClaw tab group**.
3838

@@ -60,9 +60,11 @@ the toolbar button) to revoke access instantly.
6060
4. Click the OpenClaw toolbar icon and paste the pairing string into the popup.
6161
The badge turns **ON** when the extension connects to the relay.
6262

63-
The pairing token is derived (HMAC-SHA256) from your Gateway auth material, so
64-
the raw Gateway credential never reaches Chrome. Rotating Gateway auth rotates
65-
the pairing token.
63+
The pairing token is a **host-local secret** created on first use and stored
64+
under `credentials/` in the state directory (mode `0600`). Each machine that
65+
runs a browser — the Gateway host and every browser node host — owns its own
66+
token, so no credential has to travel between machines. To rotate it, delete the
67+
`browser-extension-relay.secret` file and pair again.
6668

6769
## Use it
6870

@@ -89,6 +91,21 @@ openclaw config set browser.defaultProfile chrome
8991
- Revoke: click the button again, drag the tab out of the group, or dismiss
9092
Chrome's debugging banner. The agent loses access to that tab immediately.
9193

94+
## Remote browser nodes
95+
96+
The extension works whether Chrome runs on the Gateway host or on a separate
97+
[browser node host](/tools/browser#local-vs-remote-control). The relay is always
98+
loopback-only and runs **on the machine with the browser**:
99+
100+
- **Same host** (Gateway + Chrome on one machine): pair on that machine.
101+
- **Remote node** (Chrome on a node, Gateway elsewhere): run
102+
`openclaw browser extension path` / `pair` **on the node**, load and pair the
103+
extension there. The Gateway proxies browser actions to the node over its
104+
existing authenticated node link; the node's local relay drives the extension.
105+
No new inbound port is opened on the node.
106+
107+
The pairing token is per host, so each node prints its own string.
108+
92109
## Diagnostics
93110

94111
```bash

extensions/browser/src/browser/config.test.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// Browser tests cover config plugin behavior.
2+
import fs from "node:fs";
23
import os from "node:os";
34
import path from "node:path";
45
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
5-
import { describe, expect, it } from "vitest";
6+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
67
import type { BrowserConfig } from "../config/config.js";
78
import { resolveUserPath } from "../utils.js";
89
import {
@@ -14,6 +15,30 @@ import {
1415
} from "./config.js";
1516
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
1617

18+
// Isolate the extension relay secret (read from stateDir/credentials) so the
19+
// extension-token assertions do not pick up a developer's real secret file.
20+
let isolatedStateDir = "";
21+
const prevStateDir = process.env.OPENCLAW_STATE_DIR;
22+
beforeEach(() => {
23+
isolatedStateDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cfg-")));
24+
process.env.OPENCLAW_STATE_DIR = isolatedStateDir;
25+
});
26+
afterEach(() => {
27+
if (prevStateDir === undefined) {
28+
delete process.env.OPENCLAW_STATE_DIR;
29+
} else {
30+
process.env.OPENCLAW_STATE_DIR = prevStateDir;
31+
}
32+
fs.rmSync(isolatedStateDir, { recursive: true, force: true });
33+
});
34+
35+
/** Write a relay secret into the isolated state dir's credentials directory. */
36+
function writeRelaySecret(token: string): void {
37+
const dir = path.join(isolatedStateDir, "credentials");
38+
fs.mkdirSync(dir, { recursive: true });
39+
fs.writeFileSync(path.join(dir, "browser-extension-relay.secret"), `${token}\n`);
40+
}
41+
1742
function withEnv<T>(env: Record<string, string | undefined>, fn: () => T): T {
1843
const snapshot = new Map<string, string | undefined>();
1944
for (const [key] of Object.entries(env)) {
@@ -84,8 +109,8 @@ describe("browser config", () => {
84109
// Relay port sits just below the CDP allocation range (controlPort + 8).
85110
expect(chrome?.cdpPort).toBe(resolved.extensionRelayDefaultPort);
86111
expect(resolved.extensionRelayDefaultPort).toBe(resolved.controlPort + 8);
87-
// Without gateway auth material there is no derived token yet, so the relay
88-
// cdpUrl carries no Basic credentials.
112+
// No host-local relay secret exists yet (isolated state dir), so the relay
113+
// cdpUrl carries no Basic credentials until pairing/startup creates one.
89114
expect(chrome?.cdpUrl).toBe(`http://127.0.0.1:${resolved.extensionRelayDefaultPort}`);
90115
expect(chrome?.cdpIsLoopback).toBe(true);
91116
});
@@ -117,14 +142,14 @@ describe("browser config", () => {
117142
expect(resolveProfile(resolved, "work")?.cdpPort).toBe(20123);
118143
});
119144

120-
it("embeds the derived relay token as Basic auth in the extension cdpUrl", () => {
121-
const resolved = resolveBrowserConfig(undefined, {
122-
gateway: { auth: { mode: "token", token: "gw-secret" } },
123-
});
124-
expect(resolved.extensionRelayToken).toBeTruthy();
145+
it("embeds the host-local relay secret as Basic auth in the extension cdpUrl", () => {
146+
const token = "a".repeat(64);
147+
writeRelaySecret(token);
148+
const resolved = resolveBrowserConfig(undefined);
149+
expect(resolved.extensionRelayToken).toBe(token);
125150
const chrome = resolveProfile(resolved, "chrome");
126151
expect(chrome?.cdpUrl).toBe(
127-
`http://openclaw:${resolved.extensionRelayToken}@127.0.0.1:${resolved.extensionRelayDefaultPort}`,
152+
`http://openclaw:${token}@127.0.0.1:${resolved.extensionRelayDefaultPort}`,
128153
);
129154
});
130155

extensions/browser/src/browser/config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,9 @@ export function resolveBrowserConfig(
486486

487487
const headless = cfg?.headless === true;
488488
const headlessSource = typeof cfg?.headless === "boolean" ? "config" : "default";
489-
const extensionRelayToken = resolveExtensionRelayToken(rootConfig) ?? undefined;
489+
// Host-local relay secret (created lazily by relay startup / pairing). Null
490+
// here just means the extension driver has not been used on this host yet.
491+
const extensionRelayToken = resolveExtensionRelayToken() ?? undefined;
490492
const noSandbox = cfg?.noSandbox === true;
491493
const attachOnly = cfg?.attachOnly === true;
492494
const executablePath = normalizeExecutablePath(cfg?.executablePath);
Lines changed: 55 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,70 @@
1-
// Extension relay token derivation.
2-
import { describe, expect, it } from "vitest";
1+
// Extension relay host-local token secret.
2+
import fs from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
36
import {
4-
deriveExtensionRelayToken,
7+
ensureExtensionRelayToken,
58
extensionRelayTokenMatches,
9+
readExtensionRelayToken,
610
resolveExtensionRelayToken,
711
} from "./relay-auth.js";
812

9-
describe("extension relay auth", () => {
10-
it("derives a stable token from gateway auth material", () => {
11-
const a = deriveExtensionRelayToken("secret-token");
12-
const b = deriveExtensionRelayToken("secret-token");
13-
expect(a).toBe(b);
14-
expect(a).toHaveLength(64); // sha256 hex
15-
expect(a).not.toBe("secret-token");
13+
let stateDir = "";
14+
const prevStateDir = process.env.OPENCLAW_STATE_DIR;
15+
16+
beforeEach(() => {
17+
stateDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-relay-auth-")));
18+
process.env.OPENCLAW_STATE_DIR = stateDir;
19+
});
20+
afterEach(() => {
21+
if (prevStateDir === undefined) {
22+
delete process.env.OPENCLAW_STATE_DIR;
23+
} else {
24+
process.env.OPENCLAW_STATE_DIR = prevStateDir;
25+
}
26+
fs.rmSync(stateDir, { recursive: true, force: true });
27+
});
28+
29+
describe("extension relay host-local secret", () => {
30+
it("returns null before the secret is created", () => {
31+
expect(readExtensionRelayToken()).toBeNull();
32+
expect(resolveExtensionRelayToken()).toBeNull();
1633
});
1734

18-
it("produces distinct tokens for distinct material", () => {
19-
expect(deriveExtensionRelayToken("one")).not.toBe(deriveExtensionRelayToken("two"));
35+
it("creates a 64-hex secret on ensure and persists it privately", () => {
36+
const token = ensureExtensionRelayToken();
37+
expect(token).toMatch(/^[0-9a-f]{64}$/);
38+
const secretPath = path.join(stateDir, "credentials", "browser-extension-relay.secret");
39+
expect(fs.existsSync(secretPath)).toBe(true);
40+
if (process.platform !== "win32") {
41+
expect(fs.statSync(secretPath).mode & 0o777).toBe(0o600);
42+
}
2043
});
2144

22-
it("matches tokens in constant time and rejects mismatches", () => {
23-
const token = deriveExtensionRelayToken("material");
24-
expect(extensionRelayTokenMatches(token, token)).toBe(true);
25-
expect(extensionRelayTokenMatches(token, `${token}x`)).toBe(false);
26-
expect(extensionRelayTokenMatches(token, "short")).toBe(false);
45+
it("is stable across calls (does not rotate on read)", () => {
46+
const first = ensureExtensionRelayToken();
47+
expect(ensureExtensionRelayToken()).toBe(first);
48+
expect(readExtensionRelayToken()).toBe(first);
2749
});
2850

29-
it("resolves from token auth config and returns null without material", () => {
30-
const withToken = resolveExtensionRelayToken(
31-
{ gateway: { auth: { mode: "token", token: "gw-token" } } },
32-
{},
51+
it("gives different hosts (state dirs) different secrets", () => {
52+
const a = ensureExtensionRelayToken();
53+
const otherDir = fs.realpathSync(
54+
fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-relay-auth-2-")),
3355
);
34-
expect(withToken).toBe(deriveExtensionRelayToken("gw-token"));
56+
try {
57+
const b = ensureExtensionRelayToken({ ...process.env, OPENCLAW_STATE_DIR: otherDir });
58+
expect(b).not.toBe(a);
59+
} finally {
60+
fs.rmSync(otherDir, { recursive: true, force: true });
61+
}
62+
});
3563

36-
const withoutAuth = resolveExtensionRelayToken({ gateway: { auth: { mode: "none" } } }, {});
37-
expect(withoutAuth).toBeNull();
64+
it("matches tokens in constant time and rejects mismatches", () => {
65+
const token = ensureExtensionRelayToken();
66+
expect(extensionRelayTokenMatches(token, token)).toBe(true);
67+
expect(extensionRelayTokenMatches(token, `${token}x`)).toBe(false);
68+
expect(extensionRelayTokenMatches(token, "short")).toBe(false);
3869
});
3970
});

extensions/browser/src/browser/extension-relay/relay-auth.ts

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,58 @@
11
/**
22
* Extension relay auth material.
33
*
4-
* The relay token is derived (HMAC-SHA256) from the gateway auth material so
5-
* pairing never hands the raw gateway credential to Chrome and no new secret
6-
* needs persisting. Rotating gateway auth rotates the relay token.
7-
*
8-
* Imports resolveGatewayAuth directly (not control-auth.ts) because config.ts
9-
* consumes this module and control-auth pulls config-mutations back in — a cycle.
4+
* The relay authenticates the loopback link between OpenClaw and the paired
5+
* Chrome extension with a host-local secret. It is persisted per machine in the
6+
* credentials dir, so the gateway host and every browser node host each own an
7+
* independent token — the extension pairs with whichever machine runs its
8+
* Chrome, and no gateway credential ever has to travel to a node.
109
*/
1110
import crypto from "node:crypto";
12-
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
13-
import type { OpenClawConfig } from "../../config/config.js";
14-
import { resolveGatewayAuth } from "../../gateway/auth.js";
11+
import fs from "node:fs";
12+
import path from "node:path";
13+
import { resolveOAuthDir } from "openclaw/plugin-sdk/state-paths";
14+
15+
const RELAY_SECRET_FILE = "browser-extension-relay.secret";
1516

16-
const RELAY_TOKEN_CONTEXT = "openclaw-extension-relay-v2";
17+
// resolveOAuthDir returns `${stateDir}/credentials`, the shared credentials dir.
18+
function resolveExtensionRelaySecretPath(env: NodeJS.ProcessEnv = process.env): string {
19+
return path.join(resolveOAuthDir(env), RELAY_SECRET_FILE);
20+
}
21+
22+
function normalizeToken(raw: string): string | null {
23+
const value = raw.trim();
24+
return /^[0-9a-f]{64}$/.test(value) ? value : null;
25+
}
1726

18-
/** Derive the extension relay bearer token from gateway auth material. */
19-
export function deriveExtensionRelayToken(material: string): string {
20-
return crypto.createHmac("sha256", material).update(RELAY_TOKEN_CONTEXT).digest("hex");
27+
/** Read the host-local relay token, or null when it has not been created yet. */
28+
export function readExtensionRelayToken(env: NodeJS.ProcessEnv = process.env): string | null {
29+
try {
30+
return normalizeToken(fs.readFileSync(resolveExtensionRelaySecretPath(env), "utf8"));
31+
} catch {
32+
return null;
33+
}
2134
}
2235

2336
/**
24-
* Resolve the relay token for this install, or null when no gateway auth
25-
* material exists yet (gateway startup auto-generates it on first run).
37+
* Read the host-local relay token, creating it on first use. Called from relay
38+
* startup and `openclaw browser extension pair` — both run on the machine that
39+
* hosts the browser, so they resolve the same per-host secret.
2640
*/
27-
export function resolveExtensionRelayToken(
28-
cfg?: OpenClawConfig,
29-
env: NodeJS.ProcessEnv = process.env,
30-
): string | null {
31-
const auth = resolveGatewayAuth({
32-
authConfig: cfg?.gateway?.auth,
33-
env,
34-
tailscaleMode: cfg?.gateway?.tailscale?.mode,
35-
});
36-
const material = normalizeOptionalString(auth.token) ?? normalizeOptionalString(auth.password);
37-
if (!material) {
38-
return null;
41+
export function ensureExtensionRelayToken(env: NodeJS.ProcessEnv = process.env): string {
42+
const secretPath = resolveExtensionRelaySecretPath(env);
43+
const existing = readExtensionRelayToken(env);
44+
if (existing) {
45+
return existing;
3946
}
40-
return deriveExtensionRelayToken(material);
47+
const token = crypto.randomBytes(32).toString("hex");
48+
fs.mkdirSync(path.dirname(secretPath), { recursive: true });
49+
fs.writeFileSync(secretPath, `${token}\n`, { mode: 0o600 });
50+
return token;
51+
}
52+
53+
/** Resolve the relay token for config (read-only; null until first ensured). */
54+
export function resolveExtensionRelayToken(env: NodeJS.ProcessEnv = process.env): string | null {
55+
return readExtensionRelayToken(env);
4156
}
4257

4358
/** Constant-time token comparison. */

extensions/browser/src/browser/extension-relay/relay-lifecycle.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
*/
55
import { createSubsystemLogger } from "../../logging/subsystem.js";
66
import type { ResolvedBrowserProfile } from "../config.js";
7-
import { BrowserProfileUnavailableError } from "../errors.js";
87
import type { BrowserServerState } from "../server-context.types.js";
98
import { type ExtensionRelayHandle, startExtensionRelayServer } from "./relay-server.js";
109

@@ -34,13 +33,10 @@ export async function ensureExtensionRelayForProfile(
3433
profile: ResolvedBrowserProfile,
3534
): Promise<ExtensionRelayHandle> {
3635
const map = relays(state);
37-
const token = state.resolved.extensionRelayToken;
38-
if (!token) {
39-
throw new BrowserProfileUnavailableError(
40-
`Browser profile "${profile.name}" needs gateway auth before the extension relay can start. ` +
41-
"Start the gateway once (it generates gateway.auth.token) and retry.",
42-
);
43-
}
36+
// The host-local relay secret is created at browser-service startup and when
37+
// pairing; ensure it here too so a relay started on demand always has a token.
38+
const { ensureExtensionRelayToken } = await import("./relay-auth.js");
39+
const token = state.resolved.extensionRelayToken ?? ensureExtensionRelayToken();
4440
const existing = map.get(profile.name);
4541
if (existing) {
4642
if (existing.port === profile.cdpPort && existing.token === token) {

0 commit comments

Comments
 (0)