openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
470 lines (469 loc) • 16.1 kB
JavaScript
import { C as resolveExpiresAtMsFromDurationMs, a as addTimerTimeoutGraceMs, j as resolveTimerTimeoutMs, m as isFutureDateTimestampMs } from "./number-coercion-CJQ8TR--.js";
import { n as logRejectedLargePayload } from "./diagnostic-payload-DELJ7QXt.js";
import { i as MAX_BUFFERED_BYTES } from "./server-constants-BGwLM6XN.js";
import { randomUUID } from "node:crypto";
//#region src/gateway/node-registry.ts
const SERIALIZED_EVENT_PAYLOAD = Symbol("openclaw.serializedEventPayload");
const AUTHORIZED_SYSTEM_RUN_EVENT_GRACE_MS = 300 * 1e3;
const WEBSOCKET_OPEN_READY_STATE = 1;
const SLOW_CONSUMER_CLOSE_CODE = 1008;
/** Serialize an event payload once so fanout can reuse the same JSON string. */
function serializeEventPayload(payload) {
if (payload === void 0) return null;
const json = JSON.stringify(payload);
return typeof json === "string" ? {
json,
[SERIALIZED_EVENT_PAYLOAD]: true
} : null;
}
/** Narrow values created by serializeEventPayload. */
function isSerializedEventPayload(value) {
return typeof value === "object" && value !== null && value[SERIALIZED_EVENT_PAYLOAD] === true && typeof value.json === "string";
}
/** Normalize optional string-ish websocket fields. */
function normalizeString(value) {
return typeof value === "string" ? value.trim() : "";
}
/** Normalize system.run timeout values, preserving null for no expiry. */
function normalizeSystemRunTimeoutMs(value) {
if (value === void 0) return;
if (typeof value !== "number" || !Number.isFinite(value)) return;
const timeoutMs = Math.trunc(value);
return timeoutMs > 0 ? resolveTimerTimeoutMs(timeoutMs, 1) : null;
}
/** Extract system.run event auth metadata from invoke params. */
function resolvePendingSystemRunEvent(params) {
if (params.command !== "system.run" || !params.params || typeof params.params !== "object") return;
const obj = params.params;
const runId = normalizeString(obj.runId);
if (!runId) return;
const timeoutMs = normalizeSystemRunTimeoutMs(obj.timeoutMs);
const sessionKey = normalizeString(obj.sessionKey);
return {
runId,
...sessionKey ? { sessionKey } : {},
...timeoutMs !== void 0 ? { timeoutMs } : {}
};
}
/** Ensure system.run requests have a runId before they are sent to a node. */
function withSystemRunEventRunId(params) {
if (params.command !== "system.run" || !params.params || typeof params.params !== "object" || Array.isArray(params.params)) return params.params;
const obj = params.params;
if (normalizeString(obj.runId)) return params.params;
return {
...obj,
runId: randomUUID()
};
}
/** Registry of currently connected Gateway nodes. */
var NodeRegistry = class {
constructor() {
this.nodesById = /* @__PURE__ */ new Map();
this.nodesByConn = /* @__PURE__ */ new Map();
this.pendingInvokes = /* @__PURE__ */ new Map();
this.authorizedSystemRunEvents = /* @__PURE__ */ new Map();
}
/** Register a websocket client as the current connection for its node id. */
register(client, opts) {
const connect = client.connect;
const nodeId = connect.device?.id ?? connect.client.id;
const caps = Array.isArray(connect.caps) ? connect.caps : [];
const declaredCaps = Array.isArray(connect.declaredCaps) ? connect.declaredCaps ?? [] : caps;
const commands = Array.isArray(connect.commands) ? connect.commands ?? [] : [];
const declaredCommands = Array.isArray(connect.declaredCommands) ? connect.declaredCommands ?? [] : commands;
const permissions = typeof connect.permissions === "object" ? connect.permissions ?? void 0 : void 0;
const declaredPermissions = typeof connect.declaredPermissions === "object" ? connect.declaredPermissions ?? void 0 : permissions;
const pathEnv = typeof connect.pathEnv === "string" ? connect.pathEnv : void 0;
const session = {
nodeId,
connId: client.connId,
client,
clientId: connect.client.id,
clientMode: connect.client.mode,
displayName: connect.client.displayName,
platform: connect.client.platform,
version: connect.client.version,
coreVersion: connect.coreVersion,
uiVersion: connect.uiVersion,
deviceFamily: connect.client.deviceFamily,
modelIdentifier: connect.client.modelIdentifier,
remoteIp: opts.remoteIp,
declaredCaps,
caps,
declaredCommands,
commands,
declaredPermissions,
permissions,
pathEnv,
connectedAtMs: Date.now()
};
this.nodesById.set(nodeId, session);
this.nodesByConn.set(client.connId, nodeId);
return session;
}
/** Unregister one connection and reject invokes tied to that connection. */
unregister(connId) {
const nodeId = this.nodesByConn.get(connId);
if (!nodeId) return null;
this.nodesByConn.delete(connId);
const unregistersCurrentNode = this.nodesById.get(nodeId)?.connId === connId;
if (unregistersCurrentNode) this.nodesById.delete(nodeId);
for (const [id, pending] of this.pendingInvokes.entries()) {
if (pending.connId !== connId) continue;
clearTimeout(pending.timer);
pending.reject(/* @__PURE__ */ new Error(`node disconnected (${pending.command})`));
this.pendingInvokes.delete(id);
}
for (const [key, event] of this.authorizedSystemRunEvents) if (event.connId === connId) this.authorizedSystemRunEvents.delete(key);
return unregistersCurrentNode ? nodeId : null;
}
/** List connected node sessions. */
listConnected() {
return [...this.nodesById.values()];
}
/** Return a connected node session by node id. */
get(nodeId) {
return this.nodesById.get(nodeId);
}
/** Probe websocket liveness with ping/pong when the socket supports it. */
async checkConnectivity(nodeId, timeoutMs = 2e3) {
const node = this.nodesById.get(nodeId);
if (!node) return {
ok: false,
error: {
code: "NOT_CONNECTED",
message: "node not connected"
}
};
const socket = node.client.socket;
if (socket.readyState !== WEBSOCKET_OPEN_READY_STATE) return {
ok: false,
error: {
code: "NOT_CONNECTED",
message: "node socket not open"
}
};
if (typeof socket.ping !== "function" || typeof socket.once !== "function") return { ok: true };
const timeout = Math.max(1, Math.trunc(timeoutMs));
return await new Promise((resolve) => {
let settled = false;
const cleanup = () => {
socket.off?.("pong", onPong);
socket.off?.("close", onClose);
socket.off?.("error", onError);
socket.removeListener?.("pong", onPong);
socket.removeListener?.("close", onClose);
socket.removeListener?.("error", onError);
};
const finish = (result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
cleanup();
resolve(result);
};
const onPong = () => finish({ ok: true });
const onClose = () => finish({
ok: false,
error: {
code: "NOT_CONNECTED",
message: "node socket closed during connectivity probe"
}
});
const onError = (err) => finish({
ok: false,
error: {
code: "UNAVAILABLE",
message: err instanceof Error ? err.message : "node socket error during connectivity probe"
}
});
const timer = setTimeout(() => finish({
ok: false,
error: {
code: "TIMEOUT",
message: "node connectivity probe timed out"
}
}), timeout);
socket.once?.("pong", onPong);
socket.once?.("close", onClose);
socket.once?.("error", onError);
try {
socket.ping?.(void 0, false, (err) => {
if (err) finish({
ok: false,
error: {
code: "UNAVAILABLE",
message: err.message
}
});
});
} catch (err) {
finish({
ok: false,
error: {
code: "UNAVAILABLE",
message: err instanceof Error ? err.message : "node ping failed"
}
});
}
});
}
/** Update command list while keeping it within the node's declared command surface. */
updateCommands(nodeId, commands) {
return this.updateSurface(nodeId, { commands });
}
updateSurface(nodeId, surface) {
const node = this.nodesById.get(nodeId);
if (!node) return null;
const declaredCommands = new Set(node.declaredCommands);
const nextCommands = surface.commands.filter((command) => declaredCommands.has(command));
node.commands = nextCommands;
node.client.connect.commands = nextCommands;
if ("caps" in surface) {
const declaredCaps = new Set(node.declaredCaps);
const nextCaps = (surface.caps ?? []).filter((capability) => declaredCaps.has(capability));
node.caps = nextCaps;
node.client.connect.caps = nextCaps;
}
if ("permissions" in surface) {
if (surface.permissions === void 0) {
node.permissions = void 0;
node.client.connect.permissions = void 0;
return node;
}
const declared = node.declaredPermissions ?? {};
const nextEntries = [];
for (const [key, declaredValue] of Object.entries(declared)) {
if (!declaredValue) {
nextEntries.push([key, false]);
continue;
}
const approvedValue = surface.permissions?.[key];
if (approvedValue) {
nextEntries.push([key, true]);
continue;
}
if (approvedValue !== void 0) nextEntries.push([key, false]);
}
const nextPermissions = nextEntries.length > 0 ? Object.fromEntries(nextEntries) : void 0;
node.permissions = nextPermissions;
node.client.connect.permissions = nextPermissions;
}
return node;
}
async invoke(params) {
const node = this.nodesById.get(params.nodeId);
if (!node) return {
ok: false,
error: {
code: "NOT_CONNECTED",
message: "node not connected"
}
};
const requestId = randomUUID();
const invokeParams = withSystemRunEventRunId({
command: params.command,
params: params.params
});
const payload = {
id: requestId,
nodeId: params.nodeId,
command: params.command,
paramsJSON: "params" in params && invokeParams !== void 0 ? JSON.stringify(invokeParams) : null,
timeoutMs: params.timeoutMs,
idempotencyKey: params.idempotencyKey
};
if (!this.sendEventToSession(node, "node.invoke.request", payload)) return {
ok: false,
error: {
code: "UNAVAILABLE",
message: "failed to send invoke to node"
}
};
const systemRunEvent = resolvePendingSystemRunEvent({
command: params.command,
params: invokeParams
});
if (systemRunEvent) this.rememberAuthorizedSystemRunEvent({
nodeId: params.nodeId,
connId: node.connId,
...systemRunEvent
});
const timeoutMs = resolveTimerTimeoutMs(params.timeoutMs, 3e4, 0);
return await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingInvokes.delete(requestId);
resolve({
ok: false,
error: {
code: "TIMEOUT",
message: "node invoke timed out"
}
});
}, timeoutMs);
this.pendingInvokes.set(requestId, {
nodeId: params.nodeId,
connId: node.connId,
command: params.command,
systemRunEvent,
resolve,
reject,
timer
});
});
}
/** Authorize an inbound system.run event against a recently issued node invoke. */
authorizeSystemRunEvent(params) {
if (!params.connId || !params.sessionKey) return false;
const connId = params.connId;
this.pruneAuthorizedSystemRunEvents();
let match;
if (params.runId) {
match = this.matchAuthorizedSystemRunEvent({
nodeId: params.nodeId,
connId,
runId: params.runId,
sessionKey: params.sessionKey
});
if (!match && this.allowsLegacyMacRunIdFallback({
nodeId: params.nodeId,
connId
})) match = this.matchSingleAuthorizedSystemRunEvent({
nodeId: params.nodeId,
connId,
sessionKey: params.sessionKey
});
} else {
if (!this.allowsLegacyMacRunIdFallback({
nodeId: params.nodeId,
connId
})) return false;
match = this.matchSingleAuthorizedSystemRunEvent({
nodeId: params.nodeId,
connId,
sessionKey: params.sessionKey
});
}
if (!match) return false;
if (params.terminal) this.authorizedSystemRunEvents.delete(match.key);
return true;
}
rememberAuthorizedSystemRunEvent(event) {
this.pruneAuthorizedSystemRunEvents();
const authorized = {
...event,
expiresAtMs: this.authorizedSystemRunEventExpiresAt(event.timeoutMs)
};
this.authorizedSystemRunEvents.set(this.authorizedSystemRunEventKey(authorized), authorized);
}
forgetAuthorizedSystemRunEvent(event) {
this.authorizedSystemRunEvents.delete(this.authorizedSystemRunEventKey(event));
}
authorizedSystemRunEventExpiresAt(timeoutMs) {
if (typeof timeoutMs !== "number") return null;
return resolveExpiresAtMsFromDurationMs(addTimerTimeoutGraceMs(timeoutMs, AUTHORIZED_SYSTEM_RUN_EVENT_GRACE_MS)) ?? 0;
}
matchAuthorizedSystemRunEvent(params) {
for (const [key, event] of this.authorizedSystemRunEvents) if (event.nodeId === params.nodeId && event.connId === params.connId && event.runId === params.runId && this.authorizedSystemRunSessionMatches(event, params.sessionKey)) return {
key,
event
};
return null;
}
matchSingleAuthorizedSystemRunEvent(params) {
let match = null;
for (const [key, event] of this.authorizedSystemRunEvents) {
if (event.nodeId !== params.nodeId || event.connId !== params.connId || !this.authorizedSystemRunSessionMatches(event, params.sessionKey)) continue;
if (match) return null;
match = {
key,
event
};
}
return match;
}
authorizedSystemRunSessionMatches(event, sessionKey) {
return !event.sessionKey || event.sessionKey === sessionKey;
}
allowsLegacyMacRunIdFallback(params) {
const node = this.nodesById.get(params.nodeId);
return node?.connId === params.connId && node.clientId === "openclaw-macos" && node.platform === "darwin";
}
pruneAuthorizedSystemRunEvents(now = Date.now()) {
for (const [key, event] of this.authorizedSystemRunEvents) if (event.expiresAtMs !== null && !isFutureDateTimestampMs(event.expiresAtMs, { nowMs: now })) this.authorizedSystemRunEvents.delete(key);
}
authorizedSystemRunEventKey(params) {
return `${params.nodeId}\0${params.connId}\0${params.sessionKey ?? ""}\0${params.runId}`;
}
handleInvokeResult(params) {
const pending = this.pendingInvokes.get(params.id);
if (!pending) return false;
if (pending.nodeId !== params.nodeId || pending.connId !== params.connId) return false;
clearTimeout(pending.timer);
this.pendingInvokes.delete(params.id);
if (!params.ok && pending.systemRunEvent) this.forgetAuthorizedSystemRunEvent({
nodeId: pending.nodeId,
connId: pending.connId,
...pending.systemRunEvent
});
pending.resolve({
ok: params.ok,
payload: params.payload,
payloadJSON: params.payloadJSON ?? null,
error: params.error ?? null
});
return true;
}
sendEvent(nodeId, event, payload) {
const node = this.nodesById.get(nodeId);
if (!node) return false;
return this.sendEventToSession(node, event, payload);
}
sendEventRaw(nodeId, event, payloadJSON) {
const node = this.nodesById.get(nodeId);
if (!node) return false;
return this.sendEventRawInternal(node, event, payloadJSON);
}
sendEventInternal(node, event, payload) {
if (this.rejectSlowNodeSocket(node)) return false;
try {
node.client.socket.send(JSON.stringify({
type: "event",
event,
payload
}));
return true;
} catch {
return false;
}
}
sendEventRawInternal(node, event, payloadJSON) {
if (payloadJSON !== null && payloadJSON !== void 0 && !isSerializedEventPayload(payloadJSON)) return false;
if (this.rejectSlowNodeSocket(node)) return false;
try {
const payloadFragment = payloadJSON ? `,"payload":${payloadJSON.json}` : "";
node.client.socket.send(`{"type":"event","event":${JSON.stringify(event)}${payloadFragment}}`);
return true;
} catch {
return false;
}
}
sendEventToSession(node, event, payload) {
return this.sendEventInternal(node, event, payload);
}
rejectSlowNodeSocket(node) {
if (!(node.client.socket.bufferedAmount > 52428800)) return false;
logRejectedLargePayload({
surface: "gateway.ws.outbound_buffer",
bytes: node.client.socket.bufferedAmount,
limitBytes: MAX_BUFFERED_BYTES,
reason: "ws_send_buffer_close"
});
try {
node.client.socket.close(SLOW_CONSUMER_CLOSE_CODE, "slow consumer");
} catch {}
return true;
}
};
//#endregion
export { serializeEventPayload as n, NodeRegistry as t };