diff --git a/CHANGELOG.md b/CHANGELOG.md index c3ca753e0817..9dda839462b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/config/knip.config.ts b/config/knip.config.ts index b79aec83ad44..9c8b788e65b8 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -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!", diff --git a/extensions/browser/chrome-extension/background.js b/extensions/browser/chrome-extension/background.js index 100abfe80610..0133c0f0fddb 100644 --- a/extensions/browser/chrome-extension/background.js +++ b/extensions/browser/chrome-extension/background.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; diff --git a/extensions/browser/chrome-extension/modules/native-bootstrap.d.ts b/extensions/browser/chrome-extension/modules/native-bootstrap.d.ts index 3052777dfc5f..b66032b36e6e 100644 --- a/extensions/browser/chrome-extension/modules/native-bootstrap.d.ts +++ b/extensions/browser/chrome-extension/modules/native-bootstrap.d.ts @@ -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 }>; diff --git a/extensions/browser/chrome-extension/modules/native-bootstrap.js b/extensions/browser/chrome-extension/modules/native-bootstrap.js index 44c2e6aaee59..e9a623542fb7 100644 --- a/extensions/browser/chrome-extension/modules/native-bootstrap.js +++ b/extensions/browser/chrome-extension/modules/native-bootstrap.js @@ -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; diff --git a/extensions/browser/chrome-extension/modules/native-bootstrap.test.ts b/extensions/browser/chrome-extension/modules/native-bootstrap.test.ts index 6bc3720795ec..34b8bc5ae80c 100644 --- a/extensions/browser/chrome-extension/modules/native-bootstrap.test.ts +++ b/extensions/browser/chrome-extension/modules/native-bootstrap.test.ts @@ -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", + }); + }); +}); diff --git a/extensions/browser/chrome-extension/modules/relay-core.d.ts b/extensions/browser/chrome-extension/modules/relay-core.d.ts index a58d09031364..bae8adca99a0 100644 --- a/extensions/browser/chrome-extension/modules/relay-core.d.ts +++ b/extensions/browser/chrome-extension/modules/relay-core.d.ts @@ -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; diff --git a/extensions/browser/chrome-extension/modules/relay-core.js b/extensions/browser/chrome-extension/modules/relay-core.js index 768d7ad4cc18..1c282ea5ba0f 100644 --- a/extensions/browser/chrome-extension/modules/relay-core.js +++ b/extensions/browser/chrome-extension/modules/relay-core.js @@ -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; diff --git a/extensions/browser/chrome-extension/modules/relay-core.test.ts b/extensions/browser/chrome-extension/modules/relay-core.test.ts index 5664737bd2d7..7a57ce2d823c 100644 --- a/extensions/browser/chrome-extension/modules/relay-core.test.ts +++ b/extensions/browser/chrome-extension/modules/relay-core.test.ts @@ -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); + }); +}); diff --git a/extensions/browser/native-host-entry.ts b/extensions/browser/native-host-entry.ts index 47b863948b07..70855109671b 100644 --- a/extensions/browser/native-host-entry.ts +++ b/extensions/browser/native-host-entry.ts @@ -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 { 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) { diff --git a/extensions/browser/relay-daemon-entry.ts b/extensions/browser/relay-daemon-entry.ts new file mode 100644 index 000000000000..632f9aff18b1 --- /dev/null +++ b/extensions/browser/relay-daemon-entry.ts @@ -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 { + 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; +}); diff --git a/extensions/browser/src/browser/extension-native-host.test.ts b/extensions/browser/src/browser/extension-native-host.test.ts index 008475184acb..fe368ff67ed1 100644 --- a/extensions/browser/src/browser/extension-native-host.test.ts +++ b/extensions/browser/src/browser/extension-native-host.test.ts @@ -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(); + }); +}); diff --git a/extensions/browser/src/browser/extension-native-host.ts b/extensions/browser/src/browser/extension-native-host.ts index 1dcc34fca353..f96d7b8496fc 100644 --- a/extensions/browser/src/browser/extension-native-host.ts +++ b/extensions/browser/src/browser/extension-native-host.ts @@ -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; write: (frame: Buffer) => void; buildPairing: () => Promise<{ pairingString: string; topology: string }>; + /** Ensure the standalone extension relay daemon is running (ensure_relay op). */ + ensureRelay?: () => Promise; stateDir?: string; platform?: NodeJS.Platform; }): Promise { @@ -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 { diff --git a/extensions/browser/src/browser/extension-native-protocol.ts b/extensions/browser/src/browser/extension-native-protocol.ts index f5dc5e258ed6..1961d28e46b6 100644 --- a/extensions/browser/src/browser/extension-native-protocol.ts +++ b/extensions/browser/src/browser/extension-native-protocol.ts @@ -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; } diff --git a/extensions/browser/src/browser/extension-pairing.ts b/extensions/browser/src/browser/extension-pairing.ts index be0bb7f4bf0e..d84906603eee 100644 --- a/extensions/browser/src/browser/extension-pairing.ts +++ b/extensions/browser/src/browser/extension-pairing.ts @@ -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") { diff --git a/extensions/browser/src/browser/extension-relay-daemon-spawn.test.ts b/extensions/browser/src/browser/extension-relay-daemon-spawn.test.ts new file mode 100644 index 000000000000..b93a11d3c360 --- /dev/null +++ b/extensions/browser/src/browser/extension-relay-daemon-spawn.test.ts @@ -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((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((resolve) => { + server.close(() => resolve()); + }); + } + expect(await isRelayPortServed(port)).toBe(false); + }); +}); diff --git a/extensions/browser/src/browser/extension-relay-daemon-spawn.ts b/extensions/browser/src/browser/extension-relay-daemon-spawn.ts new file mode 100644 index 000000000000..8f05c35901ae --- /dev/null +++ b/extensions/browser/src/browser/extension-relay-daemon-spawn.ts @@ -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 { + return await new Promise((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; + spawnProcess?: (command: string, args: string[]) => void; +}): Promise { + 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"; +} diff --git a/extensions/browser/src/browser/relay-daemon.test.ts b/extensions/browser/src/browser/relay-daemon.test.ts new file mode 100644 index 000000000000..a85f4a353206 --- /dev/null +++ b/extensions/browser/src/browser/relay-daemon.test.ts @@ -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((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((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((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"); + }); +}); diff --git a/extensions/browser/src/browser/relay-daemon.ts b/extensions/browser/src/browser/relay-daemon.ts new file mode 100644 index 000000000000..4b67bef1281f --- /dev/null +++ b/extensions/browser/src/browser/relay-daemon.ts @@ -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; + /** 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 { + 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((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"), + }; +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 85d5204196a1..76c6bd05f131 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -600,6 +600,7 @@ function buildUnifiedDistEntries(): Record { "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, }; }