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:
Peter Steinberger
2026-08-23 14:38:59 -07:00
parent 8dac217ce9
commit 2b24baa24d
20 changed files with 561 additions and 26 deletions
+1
View File
@@ -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.
+1
View File
@@ -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);
});
});
+13 -1
View File
@@ -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) {
+36
View File
@@ -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"),
};
}
+1
View File
@@ -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,
};
}