openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
524 lines (523 loc) • 20.9 kB
JavaScript
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 };