mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
fix(browser): wake the paired extension relay
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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: () => {} };
|
||||
|
||||
Reference in New Issue
Block a user