UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

524 lines (523 loc) 20.9 kB
import { i as extractErrorCode } from "./error-coercion-D_-xJ90S.js"; import { Rn as string, wn as number } from "./schemas-zxit8y5H.js"; import { t as createSubsystemLogger } from "./subsystem-Dy2tqXOS.js"; import { n as rawDataToString } from "./ws-C3ckvj65.js"; import "./webhook-ingress-CpRSOilv.js"; import { a as resolveProfile } from "./config-BosO4Zbt.js"; import { a as getOrCreateProfileRuntime, h as withProfileOperationLease, m as waitForProfileOperation, o as getProfileLifecycle, s as isBrowserRuntimeRunning } from "./server-context.lifecycle-Tmt_A8dN.js"; import "./subsystem-DbD06g1M.js"; import "./errors-CesLEJB8.js"; import { _ as randomRelayNonce, f as parseStrictJsonObject, h as isCanonicalBase64UrlBytes, i as BROWSER_RELAY_EXTENSION_SUBPROTOCOL, m as createRelayProof, r as BROWSER_RELAY_CHALLENGE_TTL_MS, v as relayKeyIdFromHex, y as verifyRelayProof } from "./auth-v2-B-IXoFi_.js"; import { c as relayOwnerFrame, d as relayOwnerRetired, f as relayOwnerStatus, l as relayOwnerReply, p as relayOwnerStreamClosed, r as startExtensionRelayServer, u as relayOwnerResource } from "./relay-server-9erhkSvY.js"; import { n as registerBorrowedRelayCdpAccess } from "./relay-access-B8A24s4S.js"; import { once } from "node:events"; import { WebSocket } from "ws"; //#region extensions/browser/src/browser/extension-relay/owner-auth-client.ts /** Never send a key or client proof until the configured listener proves its resource. */ async function authenticateRelayOwner(params) { const resource = relayOwnerResource(params.port, params.profile); const disconnected = new AbortController(); const signal = AbortSignal.any([ disconnected.signal, params.signal, AbortSignal.timeout(BROWSER_RELAY_CHALLENGE_TTL_MS) ]); signal.throwIfAborted(); const ws = new WebSocket(`ws://127.0.0.1:${params.port}${resource}`, BROWSER_RELAY_EXTENSION_SUBPROTOCOL, { maxPayload: 67108864 }); ws.once("close", () => disconnected.abort(/* @__PURE__ */ new Error("Relay owner authentication connection closed"))); const abort = () => ws.terminate(); signal.addEventListener("abort", abort, { once: true }); ws.on("error", () => {}); const read = async () => { const [raw] = await once(ws, "message", { signal }); const message = parseStrictJsonObject(rawDataToString(raw)); if (!message) throw new Error("Invalid relay owner authentication frame"); return message; }; try { await once(ws, "open", { signal }); const clientNonce = randomRelayNonce(); const keyId = relayKeyIdFromHex(params.token); const challengeRead = read(); ws.send(JSON.stringify({ type: "auth.hello", v: 2, keyId, clientNonce })); const challenge = await challengeRead; const owner = typeof challenge.resource === "string" ? challenge.resource.slice(`${resource}&owner=`.length) : ""; const now = Date.now(); if (Object.keys(challenge).length !== 15 || challenge.type !== "auth.challenge" || challenge.v !== 2 || challenge.keyId !== keyId || challenge.clientNonce !== clientNonce || challenge.role !== "cdp" || challenge.transport !== "websocket" || challenge.method !== "GET" || challenge.flow !== "owner" || challenge.resource !== `${resource}&owner=${owner}` || !isCanonicalBase64UrlBytes(owner, 16) || !isCanonicalBase64UrlBytes(challenge.instanceId, 16) || !isCanonicalBase64UrlBytes(challenge.sessionId, 16) || !isCanonicalBase64UrlBytes(challenge.serverNonce, 32) || typeof challenge.issuedAtMs !== "number" || !Number.isSafeInteger(challenge.issuedAtMs) || typeof challenge.expiresAtMs !== "number" || !Number.isSafeInteger(challenge.expiresAtMs) || challenge.expiresAtMs <= now || challenge.issuedAtMs > now + 3e4 || challenge.expiresAtMs - challenge.issuedAtMs !== 1e4) throw new Error("Relay owner authentication binding mismatch"); const fields = { keyId, clientNonce, instanceId: challenge.instanceId, sessionId: challenge.sessionId, serverNonce: challenge.serverNonce, issuedAtMs: challenge.issuedAtMs, expiresAtMs: challenge.expiresAtMs, role: "cdp", transport: "websocket", method: "GET", flow: "owner", resource: `${resource}&owner=${owner}` }; if (!verifyRelayProof(params.token, "server", fields, challenge.serverProof)) throw new Error("Relay owner did not prove the configured key"); const clientProof = createRelayProof(params.token, "client", fields); const acceptedRead = read(); ws.send(JSON.stringify({ type: "auth.response", v: 2, sessionId: fields.sessionId, clientProof })); const accepted = await acceptedRead; if (Object.keys(accepted).length !== 4 || accepted.type !== "auth.ok" || accepted.v !== 2 || accepted.sessionId !== fields.sessionId || !verifyRelayProof(params.token, "accept", fields, accepted.acceptProof, clientProof)) throw new Error("Relay owner acceptance proof failed"); signal.throwIfAborted(); return { ws, owner }; } catch (error) { ws.terminate(); throw error; } finally { signal.removeEventListener("abort", abort); } } //#endregion //#region extensions/browser/src/browser/extension-relay/owner-client.ts const nullableIdResult = string().nullable(); const streamIdResult = number().int().positive(); /** Client resources are leases on one socket and one proved listener lifetime. */ var RelayOwnerClient = class RelayOwnerClient { constructor(ws) { this.ws = ws; this.pending = /* @__PURE__ */ new Map(); this.streams = /* @__PURE__ */ new Map(); this.profileAdmission = () => {}; this.sequence = 0; this.closed = false; this.retirementAcknowledged = false; ws.on("message", (raw) => { const parsed = parseStrictJsonObject(rawDataToString(raw)); if (relayOwnerRetired.safeParse(parsed).success) { this.retirementAcknowledged = true; this.invalidate("Relay owner retired"); return; } const reply = relayOwnerReply.safeParse(parsed); if (reply.success) { const pending = this.pending.get(reply.data.id); this.pending.delete(reply.data.id); if (reply.data.error) pending?.reject(new Error(reply.data.error)); else pending?.resolve(reply.data.result); return; } const frame = relayOwnerFrame.safeParse(parsed); if (frame.success) { this.streams.get(frame.data.stream)?.onFrame?.(frame.data.frame); return; } const closed = relayOwnerStreamClosed.safeParse(parsed); if (closed.success) { this.streams.get(closed.data.stream)?.onClose?.(); this.streams.delete(closed.data.stream); return; } ws.close(4003, "invalid relay owner response"); }); ws.once("close", () => this.invalidate("Relay owner connection lost")); } invalidate(message) { this.closed = true; for (const pending of this.pending.values()) pending.reject(new Error(message)); this.pending.clear(); for (const stream of this.streams.values()) stream.onClose?.(); this.streams.clear(); } static async connect(params) { const { ws } = await authenticateRelayOwner(params); return new RelayOwnerClient(ws); } adoptProfileLease(assertCurrent) { this.profileAdmission = assertCurrent; } get connected() { return !this.closed && !this.closing && this.ws.readyState === 1; } assertCurrent() { this.profileAdmission(); if (this.closed || this.closing || this.ws.readyState !== 1) throw new Error("Relay owner lease is no longer current"); } async request(op, fields = {}) { if (this.closed || this.ws.readyState !== 1 || this.pending.size >= 64) throw new Error("Relay owner connection unavailable"); const id = ++this.sequence; let timer; try { return await new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); timer = setTimeout(() => { this.pending.delete(id); reject(/* @__PURE__ */ new Error("Relay owner response timed out")); this.ws.terminate(); }, 15e3); timer.unref?.(); this.ws.send(JSON.stringify({ id, op, ...fields })); }); } finally { if (timer) clearTimeout(timer); } } async status(timeoutMs = 0) { this.assertCurrent(); const status = relayOwnerStatus.parse(await this.request("ready", { timeoutMs })); this.assertCurrent(); return status; } async capture(targetId, assertOperationCurrent = () => {}) { this.assertCurrent(); assertOperationCurrent(); const ref = nullableIdResult.parse(await this.request("capture", { targetId })); try { this.assertCurrent(); assertOperationCurrent(); } catch (error) { if (ref) await this.request("release", { ref }); throw error; } let released = false; const assertReference = () => { this.assertCurrent(); assertOperationCurrent(); if (released || !ref) throw new Error("Relay operation target is unavailable"); }; return { resolve: async () => { assertReference(); const target = nullableIdResult.parse(await this.request("resolve", { ref })); assertReference(); return target ?? void 0; }, openTransport: async () => { assertReference(); const transport = await this.openTransport(ref ?? void 0); try { assertReference(); const send = transport.send.bind(transport); transport.send = (message) => { assertReference(); send(message); }; return transport; } catch (error) { transport.close(); throw error; } }, release: async () => { if (released) return; released = true; if (ref) await this.request("release", { ref }); } }; } async openStream(op, ref) { this.assertCurrent(); const id = streamIdResult.parse(await this.request(op, ref ? { ref } : {})); this.assertCurrent(); let closed = false; let closing; const stream = { send: (frame) => { this.assertCurrent(); if (closed) throw new Error("Relay stream closed"); this.ws.send(JSON.stringify({ stream: id, frame })); }, close: () => { closed = true; return closing ??= this.request("stream.close", { stream: id }).then(() => { this.streams.delete(id); stream.onClose?.(); }); } }; this.streams.set(id, stream); return stream; } async openTransport(ref) { const stream = await this.openStream("cdp.open", ref); const transport = { send: (message) => stream.send(JSON.stringify(message)), close: () => { stream.close().catch(() => this.ws.terminate()); } }; stream.onFrame = (raw) => { const frame = parseStrictJsonObject(raw); if (!frame) { this.ws.terminate(); return; } transport.onmessage?.(frame); }; stream.onClose = () => transport.onclose?.("Relay CDP stream closed"); return transport; } async prepareIngress(ws) { const stream = await this.openStream("ingress.open"); const close = () => { stream.close().catch(() => this.ws.terminate()); }; ws.once("close", close); if (ws.readyState !== 1) { close(); throw new Error("Gateway extension connection lost"); } return () => { this.assertCurrent(); stream.onFrame = (raw) => { if (ws.readyState === 1) ws.send(raw); }; stream.onClose = () => ws.close(1011, "Relay ingress lease closed"); ws.on("message", (raw) => { try { stream.send(rawDataToString(raw)); } catch { ws.close(1011, "Relay ingress lease closed"); } }); }; } close() { if (this.retirementAcknowledged) return Promise.resolve(); return this.closing ??= this.request("close").then(() => { this.closed = true; this.ws.close(); }); } }; //#endregion //#region extensions/browser/src/browser/extension-relay/relay-lifecycle.ts /** * Extension relay lifecycle: one owned listener or authenticated borrowed lease * per extension-driver profile in the browser control runtime. */ const log = createSubsystemLogger("browser").child("extension-relay"); const pendingRelayEnsures = /* @__PURE__ */ new WeakMap(); /** Human guidance for a relay without a paired/connected extension. */ const EXTENSION_PAIRING_HINT = "Run `openclaw browser extension install`, load the printed unpacked directory once, and wait for automatic setup."; function relays(state) { if (!state.extensionRelays) state.extensionRelays = /* @__PURE__ */ new Map(); return state.extensionRelays; } function applyInternalRelayToken(state, profileName, internalToken) { const tokens = { ...state.resolved.extensionRelayInternalTokens }; if (internalToken) tokens[profileName] = internalToken; else delete tokens[profileName]; state.resolved = { ...state.resolved, extensionRelayInternalTokens: tokens }; const resolved = resolveProfile(state.resolved, profileName); const runtime = state.profiles.get(profileName); if (resolved?.driver === "extension" && runtime?.profile.driver === "extension") Object.assign(runtime.profile, resolved); return resolved; } /** * Start the relay server for one extension-driver profile, reconciling any * existing one. Idempotency is keyed on profile name, but the desired (port, * token) can drift when the host-local relay secret is rotated or the profile's * cdpPort changes — a stale relay would then authenticate the extension against * the old token or listen on the wrong port. When the desired config differs, * the old relay is closed and a fresh one bound. */ async function ensureExtensionRelayForProfile(state, profile, signal) { for (;;) { signal?.throwIfAborted(); if (!isBrowserRuntimeRunning(state)) throw new Error("Browser runtime is stopping"); const { ensureExtensionRelayToken } = await import("./relay-auth-Dm66rLr-.js"); const token = await ensureExtensionRelayToken(); if (state.resolved.extensionRelayToken !== token) state.resolved = { ...state.resolved, extensionRelayToken: token }; const desiredProfile = resolveProfile(state.resolved, profile.name); if (profile.driver !== "extension" || desiredProfile?.driver !== "extension" || desiredProfile.cdpPort !== profile.cdpPort) throw new Error(`Extension relay profile "${profile.name}" changed during startup.`); Object.assign(profile, desiredProfile); const runtime = getOrCreateProfileRuntime(state, desiredProfile); if (runtime.profile !== profile && runtime.profile.driver === "extension" && runtime.profile.cdpPort === desiredProfile.cdpPort) Object.assign(runtime.profile, desiredProfile); const pending = pendingRelayEnsures.get(runtime); if (pending) { if (pending.port === desiredProfile.cdpPort && pending.token === token && pending.allowLegacyAuth === state.resolved.extensionRelay.allowLegacyAuth) { const handle = await waitForProfileOperation(pending.promise, signal); const current = resolveProfile(state.resolved, profile.name); if (current) Object.assign(profile, current); return handle; } try { await waitForProfileOperation(pending.promise, signal); } catch (err) { signal?.throwIfAborted(); if (getProfileLifecycle(runtime).blockedReason) throw err; } continue; } const promise = ensureDesiredRelay({ state, runtime, profile: desiredProfile, token }); const owned = { port: desiredProfile.cdpPort, token, allowLegacyAuth: state.resolved.extensionRelay.allowLegacyAuth, promise }; pendingRelayEnsures.set(runtime, owned); const settlePending = () => { if (pendingRelayEnsures.get(runtime) === owned) pendingRelayEnsures.delete(runtime); }; promise.then(settlePending, settlePending); const handle = await waitForProfileOperation(promise, signal); const current = resolveProfile(state.resolved, profile.name); if (current) Object.assign(profile, current); return handle; } } async function ensureDesiredRelay(params) { const { state, runtime, profile, token } = params; return await withProfileOperationLease({ state, runtime, configRevision: getProfileLifecycle(runtime).configRevision, ownership: "lifecycle", run: async (signal) => { const map = relays(state); const actor = getProfileLifecycle(runtime); const existing = map.get(profile.name); if (existing) { const matches = existing.port === profile.cdpPort && existing.token === token && existing.allowLegacyAuth === state.resolved.extensionRelay.allowLegacyAuth; if (matches && existing.ownership === "borrowed" && existing.client.connected) try { await existing.client.status(); } catch (error) { if (existing.client.connected) throw error; } if (matches && (existing.ownership !== "borrowed" || existing.client.connected)) { const current = applyInternalRelayToken(state, profile.name, existing.ownership === "borrowed" ? null : existing.internalToken); if (current) Object.assign(profile, current); return existing; } actor.cleanupRelays.add(existing); await existing.close(); actor.cleanupRelays.delete(existing); if (map.get(profile.name) === existing) map.delete(profile.name); applyInternalRelayToken(state, profile.name, null); } let handle; try { try { handle = await startExtensionRelayServer({ port: profile.cdpPort, profileName: profile.name, token, allowLegacyAuth: state.resolved.extensionRelay.allowLegacyAuth }); } catch (error) { if (extractErrorCode(error) !== "EADDRINUSE") throw error; } if (!handle) { const client = await RelayOwnerClient.connect({ port: profile.cdpPort, profile: profile.name, token, signal }); let unregister = () => {}; handle = { ownership: "borrowed", port: profile.cdpPort, token, allowLegacyAuth: state.resolved.extensionRelay.allowLegacyAuth, client, close: async () => { await client.close(); unregister(); } }; actor.cleanupRelays.add(handle); const status = await client.status(); if (!handle.allowLegacyAuth && status.allowLegacyAuth) throw new Error("Existing relay permits legacy authentication; its owner must retire it before this stricter profile can use it."); const borrowed = handle; const assertCurrent = () => { if (map.get(profile.name) !== borrowed || state.profiles.get(profile.name) !== runtime || actor.transitionReason || actor.terminal || actor.cleanupRelays.has(borrowed) || state.resolved.extensionRelayToken !== token || resolveProfile(state.resolved, profile.name)?.cdpPort !== borrowed.port) throw new Error("Borrowed relay profile lease was superseded"); }; client.adoptProfileLease(assertCurrent); unregister = registerBorrowedRelayCdpAccess(`http://127.0.0.1:${profile.cdpPort}`, borrowed); } actor.cleanupRelays.add(handle); signal.throwIfAborted(); const currentProfile = resolveProfile(state.resolved, profile.name); if (state.profiles.get(profile.name) !== runtime || currentProfile?.driver !== "extension" || currentProfile.cdpPort !== profile.cdpPort || state.resolved.extensionRelayToken !== token) throw new Error(`Extension relay profile "${profile.name}" changed during startup.`); map.set(profile.name, handle); const currentWithInternalAuth = applyInternalRelayToken(state, profile.name, handle.ownership === "borrowed" ? null : handle.internalToken); if (!currentWithInternalAuth) throw new Error(`Extension relay profile "${profile.name}" disappeared during startup.`); Object.assign(profile, currentWithInternalAuth); actor.cleanupRelays.delete(handle); log.info(`extension relay for profile "${profile.name}" listening on 127.0.0.1:${handle.port}`); return handle; } catch (err) { if (handle) try { await handle.close(); actor.cleanupRelays.delete(handle); } catch (closeError) { actor.blockedReason = "extension relay cleanup failed"; throw closeError; } throw err; } } }); } /** Start relays for every extension-driver profile (control service startup). */ async function startConfiguredExtensionRelays(state, resolveProfileByName, onWarn) { for (const [name, profile] of Object.entries(state.resolved.profiles)) { if (profile.driver !== "extension") continue; const resolved = resolveProfileByName(name); if (!resolved) continue; try { await ensureExtensionRelayForProfile(state, resolved); } catch (err) { onWarn(`extension relay for profile "${name}" failed to start: ${String(err)}`); } } } /** Stop every running relay (runtime shutdown). */ async function stopExtensionRelays(state) { const map = state.extensionRelays; if (!map) return; let firstError; for (const [name, handle] of map) try { await handle.close(); if (map.get(name) === handle) map.delete(name); applyInternalRelayToken(state, name, null); } catch (err) { log.warn(`extension relay for profile "${name}" failed to stop: ${String(err)}`); firstError ??= err instanceof Error ? err : new Error("Extension relay cleanup failed.", { cause: err }); } if (firstError) throw firstError; } //#endregion export { stopExtensionRelays as i, ensureExtensionRelayForProfile as n, startConfiguredExtensionRelays as r, EXTENSION_PAIRING_HINT as t };