mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
feat(browser): standalone extension relay daemon with native-host wake-up
Host the extension relay outside the Gateway: a new relay-daemon-entry serves the existing relay server on the configured port, the native messaging host learns an origin-validated ensure_relay op that spawns it detached when nothing serves the port, and the extension asks for it (rate-limited) when a direct loopback /extension pairing cannot connect. Chrome becomes the supervisor: the daemon defers to a hosting Gateway on EADDRINUSE and idle-exits once both the extension and all CDP clients are gone, so browser control no longer depends on Gateway boot.
This commit is contained in:
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- **Standalone browser relay:** ship a gateway-free extension relay daemon (`dist/extensions/browser/relay-daemon-entry.js`) and teach the native messaging host a rate-limited `ensure_relay` op, so the Chrome extension can wake the relay on demand and CDP clients (mcporter, Playwright) drive the paired browser without a running Gateway.
|
||||
- **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.
|
||||
|
||||
@@ -696,6 +696,7 @@ const config = {
|
||||
"browser-profiles.ts!",
|
||||
// Built by tsdown as the native messaging executable; Chrome launches it by path.
|
||||
"native-host-entry.ts!",
|
||||
"relay-daemon-entry.ts!",
|
||||
// Chrome manifest/package scripts load these without TypeScript imports.
|
||||
"chrome-extension/background.js!",
|
||||
"chrome-extension/options.js!",
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
createNativeBootstrapController,
|
||||
discardRetiredCopilotState,
|
||||
prepareRetiredCopilotState,
|
||||
requestRelayEnsure,
|
||||
} from "./modules/native-bootstrap.js";
|
||||
import { createPopupMessageHandler } from "./modules/popup-background.js";
|
||||
import { createRelayCommandHandler } from "./modules/relay-command-handler.js";
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
ACCESS_MODE_SELECTED,
|
||||
OPENCLAW_TAB_GROUP_TITLE,
|
||||
createPairingConfigStore,
|
||||
isDirectLoopbackRelayUrl,
|
||||
reconnectDelayMs,
|
||||
toRelayTabInfo,
|
||||
} from "./modules/relay-core.js";
|
||||
@@ -30,6 +32,7 @@ const BADGE = {
|
||||
on: { text: "ON", color: "#0F9D58" },
|
||||
error: { text: "!", color: "#B91C1C" },
|
||||
};
|
||||
const RELAY_ENSURE_MIN_INTERVAL_MS = 60_000;
|
||||
const RELAY_WATCHDOG_ALARM = "openclaw-relay-watchdog";
|
||||
const RELAY_OPENING_DEADLINE_ALARM = "openclaw-relay-opening-deadline";
|
||||
const RELAY_AUTH_TIMEOUT_MS = 10_000;
|
||||
@@ -43,6 +46,7 @@ let relayOpeningDeadlineAt = 0;
|
||||
let relayOpeningDeadlineTimer = null;
|
||||
let relayAuthenticatedSocket = null;
|
||||
let relayStatusHint = "";
|
||||
let lastRelayEnsureAtMs = 0;
|
||||
let reconciledPairingInvalidationRevision = 0;
|
||||
let relayConnectionGeneration = 0;
|
||||
let relayConnectionsSuspended = false;
|
||||
@@ -473,6 +477,7 @@ async function connectRelay(isConnectionAllowed = () => true) {
|
||||
if (!connectionIsCurrent()) {
|
||||
return;
|
||||
}
|
||||
maybeEnsureRelayDaemon(relayUrl);
|
||||
setBadge("connecting");
|
||||
let ws;
|
||||
try {
|
||||
@@ -548,6 +553,23 @@ function handleRelayOpeningDeadline() {
|
||||
scheduleReconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* On a reconnect cycle against a direct loopback relay URL, ask the native
|
||||
* 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)) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now - lastRelayEnsureAtMs < RELAY_ENSURE_MIN_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
lastRelayEnsureAtMs = now;
|
||||
void requestRelayEnsure(chrome).catch(() => {});
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) {
|
||||
return;
|
||||
|
||||
@@ -52,6 +52,13 @@ type RetiredCopilotStorage = {
|
||||
};
|
||||
};
|
||||
|
||||
export function requestRelayEnsure(chromeApi?: {
|
||||
runtime: {
|
||||
connectNative(name: string): NativeMessagePort;
|
||||
lastError?: { message?: string };
|
||||
};
|
||||
}): Promise<{ status: "spawned" | "running" | "skipped" | "unavailable" }>;
|
||||
|
||||
export function prepareRetiredCopilotState(
|
||||
chromeApi?: RetiredCopilotStorage,
|
||||
): Promise<{ blocked: boolean }>;
|
||||
|
||||
@@ -90,6 +90,29 @@ 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) {
|
||||
const nonce = randomRelayBase64Url(crypto, 16);
|
||||
let response;
|
||||
try {
|
||||
response = await sendNativeBootstrap(chromeApi, { v: 1, op: "ensure_relay", nonce });
|
||||
} catch {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
if (
|
||||
hasExactKeys(response, ["v", "ok", "nonce", "relay"]) &&
|
||||
response.v === 1 &&
|
||||
response.ok === true &&
|
||||
response.nonce === nonce &&
|
||||
RELAY_ENSURE_STATUSES.has(response.relay)
|
||||
) {
|
||||
return { status: response.relay };
|
||||
}
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
|
||||
/** Own coalescing, retry policy, opt-out, and late-response revocation. */
|
||||
export function createNativeBootstrapController({ chromeApi = chrome, getPairing, applyPairing }) {
|
||||
let inFlight = null;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createNativeBootstrapController,
|
||||
discardRetiredCopilotState,
|
||||
prepareRetiredCopilotState,
|
||||
requestRelayEnsure,
|
||||
} from "./native-bootstrap.js";
|
||||
|
||||
const COPILOT_LOCAL_KEYS = [
|
||||
@@ -384,3 +385,63 @@ describe("native bootstrap timeout", () => {
|
||||
expect(disconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
type EnsurePortScript = (request: { nonce: string }) => unknown;
|
||||
|
||||
function ensureChromeApi(script: EnsurePortScript | "disconnect") {
|
||||
const connectNative = vi.fn(() => {
|
||||
let messageListener: ((response: unknown) => void) | undefined;
|
||||
let disconnectListener: (() => void) | undefined;
|
||||
return {
|
||||
disconnect: vi.fn(),
|
||||
onMessage: {
|
||||
addListener: (listener: (response: unknown) => void) => {
|
||||
messageListener = listener;
|
||||
},
|
||||
},
|
||||
onDisconnect: {
|
||||
addListener: (listener: () => void) => {
|
||||
disconnectListener = listener;
|
||||
},
|
||||
},
|
||||
postMessage: (request: { nonce: string }) => {
|
||||
queueMicrotask(() => {
|
||||
if (script === "disconnect") {
|
||||
disconnectListener?.();
|
||||
return;
|
||||
}
|
||||
messageListener?.(script(request));
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
return { runtime: { connectNative, lastError: undefined } };
|
||||
}
|
||||
|
||||
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" });
|
||||
});
|
||||
|
||||
it("treats a nonce mismatch as unavailable", async () => {
|
||||
const chromeApi = ensureChromeApi(() => ({
|
||||
v: 1,
|
||||
ok: true,
|
||||
nonce: "AAAAAAAAAAAAAAAAAAAAAA",
|
||||
relay: "spawned",
|
||||
}));
|
||||
await expect(requestRelayEnsure(chromeApi)).resolves.toEqual({ status: "unavailable" });
|
||||
});
|
||||
|
||||
it("treats a missing native host as unavailable", async () => {
|
||||
await expect(requestRelayEnsure(ensureChromeApi("disconnect"))).resolves.toEqual({
|
||||
status: "unavailable",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,8 @@ export function createPairingConfigStore(storage: {
|
||||
|
||||
export function buildRelayWsProtocols(): string[];
|
||||
|
||||
export function isDirectLoopbackRelayUrl(raw: unknown): boolean;
|
||||
|
||||
export function reconnectDelayMs(attempt: number): number;
|
||||
|
||||
export function nearestGroupColor(hex: unknown): string;
|
||||
|
||||
@@ -51,6 +51,17 @@ 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) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return url.protocol === "ws:" && isLoopbackHost(url.hostname) && url.pathname === "/extension";
|
||||
}
|
||||
|
||||
function parseGatewayHint(raw) {
|
||||
if (typeof raw !== "string") {
|
||||
return null;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
nearestGroupColor,
|
||||
parsePairingString,
|
||||
reconnectDelayMs,
|
||||
isDirectLoopbackRelayUrl,
|
||||
} from "./relay-core.js";
|
||||
|
||||
const RELAY_SECRET = "a".repeat(64);
|
||||
@@ -392,3 +393,20 @@ describe("nearestGroupColor", () => {
|
||||
expect(nearestGroupColor(undefined)).toBe("orange");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDirectLoopbackRelayUrl", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import {
|
||||
parseBrowserNativeHostOrigins,
|
||||
runBrowserNativeHost,
|
||||
} from "./src/browser/extension-native-host.js";
|
||||
import { buildBrowserExtensionPairing } from "./src/browser/extension-pairing.js";
|
||||
import {
|
||||
buildBrowserExtensionPairing,
|
||||
firstExtensionRelayPort,
|
||||
} from "./src/browser/extension-pairing.js";
|
||||
import { ensureExtensionRelayDaemonProcess } from "./src/browser/extension-relay-daemon-spawn.js";
|
||||
|
||||
function requiredArgument(name: string): string {
|
||||
const index = process.argv.indexOf(name);
|
||||
@@ -31,6 +36,13 @@ async function main(): Promise<void> {
|
||||
cfg: getRuntimeConfig(),
|
||||
localTransport: "gateway",
|
||||
}),
|
||||
// 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 () =>
|
||||
await ensureExtensionRelayDaemonProcess({
|
||||
port: firstExtensionRelayPort(getRuntimeConfig()),
|
||||
entryPath: fileURLToPath(new URL("./relay-daemon-entry.js", import.meta.url)),
|
||||
}),
|
||||
});
|
||||
const response = responseFrame;
|
||||
if (!response) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Standalone extension relay daemon. Hosts the loopback relay the OpenClaw
|
||||
* Chrome extension dials, with no Gateway required — CDP clients (mcporter,
|
||||
* Playwright, chrome-devtools-mcp) attach through the same relay port. Spawned
|
||||
* on demand by the native messaging host, or run manually.
|
||||
*/
|
||||
import { runExtensionRelayDaemon } from "./src/browser/relay-daemon.js";
|
||||
|
||||
const DEFAULT_RELAY_PORT = 18_799;
|
||||
|
||||
function resolvePortArgument(argv: string[]): number {
|
||||
const index = argv.indexOf("--port");
|
||||
const raw = index >= 0 ? argv[index + 1] : undefined;
|
||||
if (raw === undefined) {
|
||||
return DEFAULT_RELAY_PORT;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65_535) {
|
||||
throw new Error(`invalid --port value: ${raw}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const run = await runExtensionRelayDaemon({ port: resolvePortArgument(process.argv.slice(2)) });
|
||||
const stop = (): void => run.stop();
|
||||
process.once("SIGINT", stop);
|
||||
process.once("SIGTERM", stop);
|
||||
const reason = await run.done;
|
||||
process.exitCode = reason === "no-credential" ? 1 : 0;
|
||||
}
|
||||
|
||||
void main().catch((error: unknown) => {
|
||||
process.stderr.write(`openclaw relay daemon failed: ${String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -339,3 +339,44 @@ describe("native host origin and topology boundary", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("native host ensure_relay", () => {
|
||||
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" }))),
|
||||
ensureRelay,
|
||||
});
|
||||
expect(result.response).toEqual({ v: 1, ok: true, nonce: NONCE, relay: "spawned" });
|
||||
expect(ensureRelay).toHaveBeenCalledTimes(1);
|
||||
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" }))),
|
||||
ensureRelay: async () => {
|
||||
throw new Error("spawn failed");
|
||||
},
|
||||
});
|
||||
expect(result.response).toEqual({ v: 1, ok: false, code: "relay_unavailable" });
|
||||
});
|
||||
|
||||
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" }))),
|
||||
callerOrigin: OTHER_ORIGIN,
|
||||
ensureRelay,
|
||||
});
|
||||
expect(result.response).toEqual({ v: 1, ok: false, code: "origin_forbidden" });
|
||||
expect(ensureRelay).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
type BrowserNativeBootstrapResponse,
|
||||
type BrowserNativeRelayEnsureStatus,
|
||||
decodeBrowserNativeFrame,
|
||||
encodeBrowserNativeResponse,
|
||||
readBrowserNativeFrame,
|
||||
@@ -136,6 +137,8 @@ export async function runBrowserNativeHost(params: {
|
||||
input: AsyncIterable<Buffer>;
|
||||
write: (frame: Buffer) => void;
|
||||
buildPairing: () => Promise<{ pairingString: string; topology: string }>;
|
||||
/** Ensure the standalone extension relay daemon is running (ensure_relay op). */
|
||||
ensureRelay?: () => Promise<BrowserNativeRelayEnsureStatus>;
|
||||
stateDir?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
}): Promise<BrowserNativeBootstrapResponse> {
|
||||
@@ -161,26 +164,35 @@ export async function runBrowserNativeHost(params: {
|
||||
params.write(encodeBrowserNativeResponse(response));
|
||||
return response;
|
||||
}
|
||||
try {
|
||||
const pairing = await params.buildPairing();
|
||||
response =
|
||||
pairing.topology === "direct-remote"
|
||||
? { v: 1, ok: false, code: "manual_required" }
|
||||
: {
|
||||
v: 1,
|
||||
ok: true,
|
||||
nonce: decoded.request.nonce,
|
||||
pairingString: pairing.pairingString,
|
||||
};
|
||||
} catch (error) {
|
||||
response = {
|
||||
v: 1,
|
||||
ok: false,
|
||||
code:
|
||||
error instanceof Error && error.message.includes("--gateway-url")
|
||||
? "manual_required"
|
||||
: "pairing_unavailable",
|
||||
};
|
||||
if (decoded.request.op === "ensure_relay") {
|
||||
try {
|
||||
const relay = params.ensureRelay ? await params.ensureRelay() : "skipped";
|
||||
response = { v: 1, ok: true, nonce: decoded.request.nonce, relay };
|
||||
} catch {
|
||||
response = { v: 1, ok: false, code: "relay_unavailable" };
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const pairing = await params.buildPairing();
|
||||
response =
|
||||
pairing.topology === "direct-remote"
|
||||
? { v: 1, ok: false, code: "manual_required" }
|
||||
: {
|
||||
v: 1,
|
||||
ok: true,
|
||||
nonce: decoded.request.nonce,
|
||||
pairingString: pairing.pairingString,
|
||||
};
|
||||
} catch (error) {
|
||||
response = {
|
||||
v: 1,
|
||||
ok: false,
|
||||
code:
|
||||
error instanceof Error && error.message.includes("--gateway-url")
|
||||
? "manual_required"
|
||||
: "pairing_unavailable",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -12,10 +12,13 @@ type BrowserNativeFailureCode =
|
||||
| "origin_forbidden"
|
||||
| "manifest_invalid"
|
||||
| "manual_required"
|
||||
| "pairing_unavailable";
|
||||
type BrowserNativeBootstrapRequest = { v: 1; op: "bootstrap"; nonce: string };
|
||||
| "pairing_unavailable"
|
||||
| "relay_unavailable";
|
||||
export type BrowserNativeRelayEnsureStatus = "spawned" | "running" | "skipped";
|
||||
type BrowserNativeBootstrapRequest = { v: 1; op: "bootstrap" | "ensure_relay"; nonce: string };
|
||||
export type BrowserNativeBootstrapResponse =
|
||||
| { v: 1; ok: true; nonce: string; pairingString: string }
|
||||
| { v: 1; ok: true; nonce: string; relay: BrowserNativeRelayEnsureStatus }
|
||||
| { v: 1; ok: false; code: BrowserNativeFailureCode };
|
||||
|
||||
function readNativeUint32(buffer: Buffer, offset = 0): number {
|
||||
@@ -150,8 +153,10 @@ function parseBrowserNativeRequest(raw: string): BrowserNativeBootstrapRequest |
|
||||
return null;
|
||||
}
|
||||
const record = asNullableRecord(parsed);
|
||||
return record?.v === 1 && record.op === "bootstrap" && isCanonicalNonce(record.nonce)
|
||||
? { v: 1, op: "bootstrap", nonce: record.nonce }
|
||||
return record?.v === 1 &&
|
||||
(record.op === "bootstrap" || record.op === "ensure_relay") &&
|
||||
isCanonicalNonce(record.nonce)
|
||||
? { v: 1, op: record.op, nonce: record.nonce }
|
||||
: null;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ type BrowserExtensionPairing = {
|
||||
|
||||
type PairingConfig = OpenClawConfig & { browser?: BrowserConfig };
|
||||
|
||||
function firstExtensionRelayPort(cfg: PairingConfig): number {
|
||||
export function firstExtensionRelayPort(cfg: PairingConfig): number {
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
for (const [name, profile] of Object.entries(resolved.profiles)) {
|
||||
if (profile.driver === "extension") {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import net from "node:net";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ensureExtensionRelayDaemonProcess,
|
||||
isRelayPortServed,
|
||||
} from "./extension-relay-daemon-spawn.js";
|
||||
|
||||
const ENTRY = "/opt/openclaw/dist/extensions/browser/relay-daemon-entry.js";
|
||||
|
||||
describe("ensureExtensionRelayDaemonProcess", () => {
|
||||
it("skips when no relay credential exists", async () => {
|
||||
const spawnProcess = vi.fn();
|
||||
const status = await ensureExtensionRelayDaemonProcess({
|
||||
port: 18_799,
|
||||
entryPath: ENTRY,
|
||||
readToken: () => null,
|
||||
probe: async () => false,
|
||||
spawnProcess,
|
||||
});
|
||||
expect(status).toBe("skipped");
|
||||
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({
|
||||
port: 19_123,
|
||||
entryPath: ENTRY,
|
||||
execPath: "/usr/bin/node",
|
||||
readToken: () => "a".repeat(64),
|
||||
probe: async () => false,
|
||||
spawnProcess,
|
||||
});
|
||||
expect(status).toBe("spawned");
|
||||
expect(spawnProcess).toHaveBeenCalledWith("/usr/bin/node", [ENTRY, "--port", "19123"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRelayPortServed", () => {
|
||||
it("detects a listening loopback server and a closed port", 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;
|
||||
try {
|
||||
expect(await isRelayPortServed(port)).toBe(true);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
expect(await isRelayPortServed(port)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import net from "node:net";
|
||||
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> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const socket = net.connect({ host: "127.0.0.1", port });
|
||||
const finish = (served: boolean): void => {
|
||||
socket.destroy();
|
||||
resolve(served);
|
||||
};
|
||||
socket.setTimeout(PORT_PROBE_TIMEOUT_MS, () => finish(false));
|
||||
socket.once("connect", () => finish(true));
|
||||
socket.once("error", () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the standalone relay daemon serves the extension relay port. The
|
||||
* daemon is spawned detached so it outlives this one-shot native host; binding
|
||||
* the port is the single-instance lock, so concurrent spawns are harmless.
|
||||
*/
|
||||
export async function ensureExtensionRelayDaemonProcess(params: {
|
||||
port: number;
|
||||
entryPath: string;
|
||||
execPath?: string;
|
||||
readToken?: () => string | null;
|
||||
probe?: (port: number) => Promise<boolean>;
|
||||
spawnProcess?: (command: string, args: string[]) => void;
|
||||
}): Promise<BrowserNativeRelayEnsureStatus> {
|
||||
const readToken = params.readToken ?? readExtensionRelayToken;
|
||||
if (!readToken()) {
|
||||
return "skipped";
|
||||
}
|
||||
const probe = params.probe ?? isRelayPortServed;
|
||||
if (await probe(params.port)) {
|
||||
return "running";
|
||||
}
|
||||
const spawnProcess =
|
||||
params.spawnProcess ??
|
||||
((command: string, args: string[]): void => {
|
||||
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
||||
child.unref();
|
||||
});
|
||||
spawnProcess(params.execPath ?? process.execPath, [
|
||||
params.entryPath,
|
||||
"--port",
|
||||
String(params.port),
|
||||
]);
|
||||
return "spawned";
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import net from "node:net";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { relayTestKey } from "../../chrome-extension/relay-key.test-support.js";
|
||||
import { runExtensionRelayDaemon } from "./relay-daemon.js";
|
||||
|
||||
const TOKEN = relayTestKey(1);
|
||||
|
||||
describe("runExtensionRelayDaemon", () => {
|
||||
it("refuses to start without a relay credential", async () => {
|
||||
const run = await runExtensionRelayDaemon({ port: 0, readToken: () => null });
|
||||
expect(run.port).toBeNull();
|
||||
await expect(run.done).resolves.toBe("no-credential");
|
||||
});
|
||||
|
||||
it("exits quietly when the relay port is already served", 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;
|
||||
try {
|
||||
const run = await runExtensionRelayDaemon({ port, readToken: () => TOKEN });
|
||||
expect(run.port).toBeNull();
|
||||
await expect(run.done).resolves.toBe("port-in-use");
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("serves the relay until the idle grace elapses with no peers", async () => {
|
||||
const run = await runExtensionRelayDaemon({
|
||||
port: 0,
|
||||
readToken: () => TOKEN,
|
||||
idleExitMs: 60,
|
||||
pollMs: 15,
|
||||
});
|
||||
expect(run.port).toBeGreaterThan(0);
|
||||
// The bound socket answers while the daemon is alive.
|
||||
const served = await new Promise<boolean>((resolve) => {
|
||||
const socket = net.connect({ host: "127.0.0.1", port: run.port ?? 0 });
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
socket.once("error", () => resolve(false));
|
||||
});
|
||||
expect(served).toBe(true);
|
||||
await expect(run.done).resolves.toBe("idle");
|
||||
});
|
||||
|
||||
it("stops on demand", async () => {
|
||||
const run = await runExtensionRelayDaemon({
|
||||
port: 0,
|
||||
readToken: () => TOKEN,
|
||||
idleExitMs: 60_000,
|
||||
pollMs: 60_000,
|
||||
});
|
||||
expect(run.port).toBeGreaterThan(0);
|
||||
run.stop();
|
||||
await expect(run.done).resolves.toBe("stopped");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { readExtensionRelayToken } from "./extension-relay/relay-auth.js";
|
||||
import {
|
||||
type ExtensionRelayHandle,
|
||||
startExtensionRelayServer,
|
||||
} from "./extension-relay/relay-server.js";
|
||||
|
||||
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 IDLE_POLL_MS = 30 * 1000;
|
||||
|
||||
export type RelayDaemonExitReason = "port-in-use" | "no-credential" | "idle" | "stopped";
|
||||
|
||||
export 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. */
|
||||
port: number | null;
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run the standalone extension relay daemon: one loopback relay server with no
|
||||
* Gateway. The daemon stays alive while the paired extension holds its relay
|
||||
* connection or a CDP client is attached, and exits once both sides have been
|
||||
* gone for the idle grace so an idle daemon never outlives Chrome.
|
||||
*/
|
||||
export async function runExtensionRelayDaemon(params: {
|
||||
port: number;
|
||||
readToken?: () => string | null;
|
||||
idleExitMs?: number;
|
||||
pollMs?: number;
|
||||
now?: () => number;
|
||||
}): Promise<RelayDaemonRun> {
|
||||
const readToken = params.readToken ?? readExtensionRelayToken;
|
||||
const now = params.now ?? Date.now;
|
||||
const idleExitMs = params.idleExitMs ?? RELAY_DAEMON_IDLE_EXIT_MS;
|
||||
const pollMs = params.pollMs ?? IDLE_POLL_MS;
|
||||
let resolveDone: (reason: RelayDaemonExitReason) => void = () => {};
|
||||
const done = new Promise<RelayDaemonExitReason>((resolve) => {
|
||||
resolveDone = resolve;
|
||||
});
|
||||
|
||||
const token = readToken();
|
||||
if (!token) {
|
||||
log.warn("relay daemon refused to start: no extension relay credential");
|
||||
resolveDone("no-credential");
|
||||
return { done, port: null, stop: () => {} };
|
||||
}
|
||||
|
||||
let handle: ExtensionRelayHandle;
|
||||
try {
|
||||
handle = await startExtensionRelayServer({ port: params.port, token });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | null)?.code === "EADDRINUSE") {
|
||||
log.info(`relay port ${params.port} is already served; standalone daemon not needed`);
|
||||
resolveDone("port-in-use");
|
||||
return { done, port: null, stop: () => {} };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
log.info(`standalone extension relay listening on 127.0.0.1:${handle.port}`);
|
||||
|
||||
let lastActiveAtMs = now();
|
||||
let stopped = false;
|
||||
const finish = (reason: RelayDaemonExitReason): void => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
clearInterval(idleTimer);
|
||||
void handle.close().finally(() => resolveDone(reason));
|
||||
};
|
||||
const idleTimer = setInterval(() => {
|
||||
if (handle.bridge.extensionConnected || handle.bridge.cdpClientCount > 0) {
|
||||
lastActiveAtMs = now();
|
||||
return;
|
||||
}
|
||||
if (now() - lastActiveAtMs >= idleExitMs) {
|
||||
log.info("relay daemon idle (no extension, no CDP clients); exiting");
|
||||
finish("idle");
|
||||
}
|
||||
}, pollMs);
|
||||
idleTimer.unref?.();
|
||||
|
||||
return {
|
||||
done,
|
||||
port: handle.port,
|
||||
stop: () => finish("stopped"),
|
||||
};
|
||||
}
|
||||
@@ -600,6 +600,7 @@ function buildUnifiedDistEntries(): Record<string, string> {
|
||||
"extensions/memory-core/src/memory/manager-search-knn.child.ts",
|
||||
...listBundledPluginEntrySources(rootBundledPluginBuildEntries),
|
||||
"extensions/browser/native-host-entry": "extensions/browser/native-host-entry.ts",
|
||||
"extensions/browser/relay-daemon-entry": "extensions/browser/relay-daemon-entry.ts",
|
||||
...bundledHookEntries,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user