openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
1,500 lines • 64 kB
JavaScript
import { r as asNullableRecord } from "./record-coerce-DItp3I4t.js";
import { l as normalizeOptionalString, o as normalizeLowercaseStringOrEmpty, s as normalizeNullableString } from "./string-coerce-CIXf7egm.js";
import { _ as normalizeUniqueTrimmedStringList } from "./string-normalization-DsCfAx8q.js";
import { t as pruneMapToMaxSize } from "./map-size-CNcWiFKu.js";
import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js";
import { r as PAIRING_SCOPE, t as ADMIN_SCOPE } from "./operator-scopes-Dw7Gu2cA.js";
import { i as GATEWAY_CLIENT_NAMES, r as GATEWAY_CLIENT_MODES } from "./client-info-B1bPgeKr.js";
import { t as ErrorCodes } from "./gateway-error-details-w0nAGBBp.js";
import { A as isAdminOnlyNodeInvokeCommand, M as isPrivateNodeInvokeCommand, j as isBrowserProxyNodeInvokeCommand } from "./node-commands-BC8PhxqU.js";
import { Dn as validateNodeRenameParams, Sn as validateNodePendingAckParams, _n as validateNodeListParams, bn as validateNodePairRejectParams, fn as validateNodeEventParams, gn as validateNodeInvokeResultParams, hn as validateNodeInvokeProgressParams, mn as validateNodeInvokeParams, vn as validateNodePairApproveParams, xn as validateNodePairRemoveParams, yn as validateNodePairListParams } from "./src-BiL5aQto.js";
import { c as isForbiddenBrowserProxyMutation } from "./method-scopes-K6J_UQGL.js";
import { d as errorShape, f as missingScopeErrorShape } from "./error-codes-Bo8q2D1o.js";
import { a as isForegroundRestrictedPluginNodeCommand, l as resolveNodeCommandAllowlist, o as isNodeCommandAllowed, t as DEFAULT_DANGEROUS_NODE_COMMANDS } from "./node-command-policy-H4FpS0aA.js";
import { a as missingSystemRunApprovalBinding, f as toSystemRunApprovalMismatchError, i as matchSystemRunApprovalBinding, n as buildSystemRunApprovalBinding, v as resolveSystemRunCommandRequest } from "./system-run-approval-binding-D8O27dhf.js";
import { m as removePairedDeviceRole, n as getPairedDevice, s as listApprovedPairedDeviceRoles } from "./device-pairing-DFIQp3ZY.js";
import { a as listNodePairing, f as renamePairedNode, i as getPendingNodePairing, l as recordPairedNodeHostStats, t as approveNodePairing, u as rejectNodePairing } from "./device-pairing-node-DiTk_P80.js";
import { n as awaitWithinDeadline, t as ABSOLUTE_DEADLINE_EXPIRED } from "./absolute-deadline-D0jNXqHr.js";
import { t as parseGatewayPayload } from "./server-json-CUC0gj3q.js";
import { n as respondUnavailableOnNodeInvokeErrorWithProvenance, r as respondUnavailableOnThrow } from "./nodes.helpers-7uACMTmw.js";
import { r as resolveSystemRunApprovalRuntimeContext } from "./system-run-approval-context-8ovkunfB.js";
import { t as assertValidParams } from "./validation-pzrlzFvo.js";
import "./exec-approval-manager-CvF_La8z.js";
import { n as EXEC_APPROVAL_RESOLVED_ENTRY_GRACE_MS } from "./exec-approval-lifecycle-CRyRxBLA.js";
import { a as removePendingNodeAction, i as listPendingNodeActions, n as clearRemovedNodeRuntimeState, o as replacePendingNodeActionsForGeneration, r as enqueuePendingNodeAction, s as reconcileRevokedDeviceWorker, t as acknowledgePendingNodeActions } from "./node-runtime-state-Cp7qgqDe.js";
import { a as invalidateNodeWakeState, n as NODE_WAKE_RECONNECT_WAIT_MS, r as captureNodeWakeLifecycle, s as releaseNodeWakeLifecycle, t as NODE_WAKE_RECONNECT_RETRY_WAIT_MS } from "./node-wake-state-CWVR-GCk.js";
import { i as pairedDeviceHasNonOperatorRole, n as deniesCrossDeviceManagement, o as resolveDeviceManagementAuthz, s as resolveDeviceSessionAuthz, t as emitDeviceManagementSecurityEvent } from "./device-management-security-BqfIxdra.js";
import { i as isNodePairingGenerationCurrent, n as captureNodePairingGeneration, r as captureNodePairingState } from "./device-pairing-node-state-DxNt2jjv.js";
import { a as resolveDispatchableNodeSession, i as isNodePairingWorkCurrent, n as maybeWakeNodeWithApns, o as respondPairingChanged, r as waitForNodeReconnect, t as maybeSendNodeWakeNudge } from "./nodes.wake-DsmpSHp_.js";
import { t as nodeInvokePolicy } from "./nodes-policy-DGZgpCCU.js";
import { n as invokeNodeWithReadinessRetry, t as applyPluginNodeInvokePolicy } from "./node-invoke-plugin-policy-C4Da33zu.js";
import { n as refreshConnectedNodeSurfaceCaches, t as nodeReadHandlers } from "./nodes.read-CKOVJ-gR.js";
import { randomUUID } from "node:crypto";
//#region src/gateway/server-methods/nodes.event.ts
const nodeEventHandlers = { "node.event": async ({ params, respond, context, client }) => {
if (!assertValidParams(params, validateNodeEventParams, "node.event", respond)) return;
const p = params;
const payloadJSON = typeof p.payloadJSON === "string" ? p.payloadJSON : p.payload !== void 0 ? JSON.stringify(p.payload) : null;
await respondUnavailableOnThrow(respond, async () => {
const nodeId = client?.connect?.device?.id ?? client?.connect?.client?.id ?? "node";
const nodeSession = context.nodeRegistry.get(nodeId);
const eventConnId = client?.connId;
const eventPairingGeneration = nodeSession?.pairingGeneration;
const isEventConnectionCurrent = async () => {
if (!eventConnId || !eventPairingGeneration) return false;
const before = resolveDispatchableNodeSession(context.nodeRegistry.getForPairingGeneration(nodeId, eventPairingGeneration));
if (!before || before.connId !== eventConnId) return false;
if (!await context.nodeRegistry.isConnectionCurrentPairingState(eventConnId)) return false;
return resolveDispatchableNodeSession(context.nodeRegistry.getForPairingGeneration(nodeId, eventPairingGeneration))?.connId === eventConnId;
};
const { handleNodeEvent } = await import("./server-node-events-DiJZ7yam.js");
const apnsGeneration = p.event === "push.apns.register" ? await captureNodePairingGeneration(nodeId) : null;
const presenceAllowed = nodeSession !== void 0 && nodeSession.connId === client?.connId && nodeSession.permissions?.accessibility === true;
const result = await handleNodeEvent({
deps: context.deps,
broadcast: context.broadcast,
nodeSendToSession: context.nodeSendToSession,
nodeSubscribe: async (subscriptionNodeId, sessionKey, subscriptionConnId) => {
if (subscriptionNodeId !== nodeId || !subscriptionConnId || subscriptionConnId !== client?.connId || !await isEventConnectionCurrent()) return;
context.nodeSubscribe(subscriptionNodeId, sessionKey, subscriptionConnId);
},
nodeUnsubscribe: async (subscriptionNodeId, sessionKey, subscriptionConnId) => {
if (subscriptionNodeId !== nodeId || !subscriptionConnId || subscriptionConnId !== client?.connId || !await isEventConnectionCurrent()) return;
context.nodeUnsubscribe(subscriptionNodeId, sessionKey, subscriptionConnId);
},
broadcastVoiceWakeChanged: context.broadcastVoiceWakeChanged,
addChatRun: context.addChatRun,
removeChatRun: context.removeChatRun,
chatAbortControllers: context.chatAbortControllers,
dedupe: context.dedupe,
agentRunSeq: context.agentRunSeq,
getHealthCache: context.getHealthCache,
refreshHealthSnapshot: context.refreshHealthSnapshot,
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
loadGatewayModelCatalogSnapshot: context.loadGatewayModelCatalogSnapshot,
authorizeNodeSystemRunEvent: (eventParams) => context.nodeRegistry.authorizeSystemRunEvent({
nodeId: eventParams.nodeId,
connId: eventParams.connId,
runId: eventParams.runId,
sessionKey: eventParams.sessionKey,
terminal: eventParams.terminal
}),
updateNodePresenceActivity: (activity) => {
const updated = context.nodeRegistry.updatePresenceActivity(activity);
return updated?.lastActiveAtMs !== void 0 && updated.presenceUpdatedAtMs !== void 0 ? {
lastActiveAtMs: updated.lastActiveAtMs,
presenceUpdatedAtMs: updated.presenceUpdatedAtMs
} : null;
},
clearNodePresenceActivity: (activity) => context.nodeRegistry.clearPresenceActivity(activity),
updateNodeHostStats: (stats) => {
const hostStats = context.nodeRegistry.updateHostStats(stats);
if (hostStats && eventPairingGeneration) recordPairedNodeHostStats({
nodeId,
hostStats,
expectedPairingGeneration: {
nodeId,
key: eventPairingGeneration
}
}).catch((error) => context.logGateway.warn(`failed to persist node host stats for ${nodeId}: ${formatErrorMessage(error)}`));
return hostStats;
},
logGateway: { warn: context.logGateway.warn }
}, nodeId, {
event: p.event,
payloadJSON
}, {
connId: client?.connId,
deviceId: client?.connect?.device?.id,
pairingGeneration: eventPairingGeneration ? {
nodeId,
key: eventPairingGeneration
} : void 0,
presenceAllowed,
isConnectionCurrent: isEventConnectionCurrent,
resolveApnsRegistrationGeneration: async () => {
if (!apnsGeneration || !client?.connId) return null;
const before = resolveDispatchableNodeSession(context.nodeRegistry.getForPairingGeneration(nodeId, apnsGeneration.key));
if (!before || before.connId !== client.connId) return null;
if (!await isNodePairingGenerationCurrent(apnsGeneration)) return null;
return resolveDispatchableNodeSession(context.nodeRegistry.getForPairingGeneration(nodeId, apnsGeneration.key))?.connId === client.connId ? apnsGeneration.key : null;
}
});
if (result?.reason === "pairing_changed") {
respondPairingChanged(respond);
return;
}
respond(true, result ?? { ok: true }, void 0);
});
} };
//#endregion
//#region src/gateway/node-invoke-system-run-approval-errors.ts
/** Builds a failed system.run approval guard result with a structured code. */
function systemRunApprovalGuardError(params) {
const details = params.details ? { ...params.details } : {};
return {
ok: false,
message: params.message,
details: {
code: params.code,
...details
}
};
}
/** Builds the standard response for system.run calls that still need approval. */
function systemRunApprovalRequired(runId) {
return systemRunApprovalGuardError({
code: "APPROVAL_REQUIRED",
message: "approval required",
details: { runId }
});
}
//#endregion
//#region src/gateway/node-invoke-system-run-approval-match.ts
function requestMismatch() {
return {
ok: false,
code: "APPROVAL_REQUEST_MISMATCH",
message: "approval id does not match request"
};
}
/** Evaluates whether a node system.run request matches the stored approval binding. */
function evaluateSystemRunApprovalMatch(params) {
if (params.request.host !== "node") return requestMismatch();
const actualBinding = buildSystemRunApprovalBinding({
argv: params.argv,
cwd: params.binding.cwd,
agentId: params.binding.agentId,
sessionKey: params.binding.sessionKey,
env: params.binding.env
});
const expectedBinding = params.request.systemRunBinding;
if (!expectedBinding) return missingSystemRunApprovalBinding({ actualEnvKeys: actualBinding.envKeys });
return matchSystemRunApprovalBinding({
expected: expectedBinding,
actual: actualBinding.binding,
actualEnvKeys: actualBinding.envKeys
});
}
//#endregion
//#region src/gateway/node-invoke-system-run-approval.ts
const BACKEND_BRIDGEABLE_NO_DEVICE_REQUEST_CLIENT_IDS = /* @__PURE__ */ new Set([
GATEWAY_CLIENT_NAMES.CONTROL_UI,
GATEWAY_CLIENT_NAMES.WEBCHAT_UI,
GATEWAY_CLIENT_NAMES.WEBCHAT,
GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT
]);
function normalizeApprovalDecision(value) {
const s = normalizeNullableString(value);
return s === "allow-once" || s === "allow-always" ? s : null;
}
function clientHasApprovals(client) {
const scopes = Array.isArray(client?.connect?.scopes) ? client?.connect?.scopes : [];
return scopes.includes("operator.admin") || scopes.includes("operator.approvals");
}
function isTrustedBackendApprovalClient(client) {
return clientHasApprovals(client) && client?.connect?.client?.id === GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT && client.connect.client.mode === GATEWAY_CLIENT_MODES.BACKEND && client.isDeviceTokenAuth !== true;
}
function canBridgeNoDeviceApprovalFromBackend(params) {
const requestedByClientId = normalizeNullableString(params.snapshot.requestedByClientId);
const request = params.snapshot.request;
return params.snapshot.requestedByDeviceId == null && params.snapshot.requestedByDeviceTokenAuth !== true && !hasChatApprovalReplayBinding(request) && requestedByClientId !== null && BACKEND_BRIDGEABLE_NO_DEVICE_REQUEST_CLIENT_IDS.has(requestedByClientId) && isTrustedBackendApprovalClient(params.client);
}
function hasChatApprovalReplayBinding(request) {
return normalizeComparableString(request.turnSourceChannel, { lowercase: true }) !== null || normalizeComparableString(request.turnSourceTo) !== null || normalizeComparableString(request.turnSourceAccountId) !== null || normalizeComparableString(request.turnSourceThreadId) !== null;
}
function normalizeComparableString(value, opts = {}) {
const normalized = typeof value === "number" && Number.isFinite(value) ? String(value) : normalizeNullableString(value);
if (!normalized) return null;
return opts.lowercase ? normalized.toLowerCase() : normalized;
}
function matchesRequiredString(params) {
const expected = normalizeComparableString(params.expected, { lowercase: params.lowercase });
if (!expected) return false;
return expected === normalizeComparableString(params.actual, { lowercase: params.lowercase });
}
function matchesOptionalString(params) {
const expected = normalizeComparableString(params.expected, { lowercase: params.lowercase });
if (!expected) return true;
return expected === normalizeComparableString(params.actual, { lowercase: params.lowercase });
}
function canBridgeNoDeviceChatApprovalFromBackend(params) {
if (params.snapshot.requestedByDeviceId != null || params.snapshot.requestedByDeviceTokenAuth === true || !isTrustedBackendApprovalClient(params.client)) return false;
const request = params.snapshot.request;
const plan = request.systemRunPlan ?? null;
return matchesRequiredString({
expected: request.turnSourceChannel,
actual: params.rawParams.turnSourceChannel,
lowercase: true
}) && matchesOptionalString({
expected: request.turnSourceTo,
actual: params.rawParams.turnSourceTo
}) && matchesRequiredString({
expected: plan?.sessionKey ?? request.sessionKey,
actual: params.rawParams.sessionKey
}) && matchesOptionalString({
expected: plan?.agentId ?? request.agentId,
actual: params.rawParams.agentId
}) && matchesOptionalString({
expected: request.turnSourceAccountId,
actual: params.rawParams.turnSourceAccountId
}) && matchesOptionalString({
expected: request.turnSourceThreadId,
actual: params.rawParams.turnSourceThreadId
});
}
function pickSystemRunParams(raw) {
const next = {};
for (const key of [
"command",
"rawCommand",
"systemRunPlan",
"cwd",
"env",
"timeoutMs",
"needsScreenRecording",
"agentId",
"sessionKey",
"runId",
"suppressNotifyOnExit"
]) if (key in raw) next[key] = raw[key];
return next;
}
function resolveForwardedRawCommand(plan) {
const preview = normalizeNullableString(plan.commandPreview);
if (!preview) return plan.commandText;
const resolved = resolveSystemRunCommandRequest({
command: plan.argv,
rawCommand: preview
});
return resolved.ok && resolved.previewText === preview ? preview : plan.commandText;
}
/**
* Gate `system.run` approval flags (`approved`, `approvalDecision`) behind a real
* `exec.approval.*` record. This prevents users with only `operator.write` from
* bypassing node-host approvals by injecting control fields into `node.invoke`.
*/
function sanitizeSystemRunParamsForForwarding(opts) {
const obj = asNullableRecord(opts.rawParams);
if (!obj) return {
ok: true,
params: opts.rawParams
};
const p = obj;
const approved = p.approved === true;
const requestedDecision = normalizeApprovalDecision(p.approvalDecision);
if (p.approvalSource != null && p.approvalSource !== "ask-fallback") return systemRunApprovalGuardError({
code: "INVALID_APPROVAL_SOURCE",
message: "approval source invalid"
});
const approvalSource = p.approvalSource === "ask-fallback" ? "ask-fallback" : null;
if (approvalSource !== null && (p.approved !== void 0 || p.approvalDecision !== void 0)) return systemRunApprovalGuardError({
code: "APPROVAL_SOURCE_MISMATCH",
message: "approval source cannot be combined with explicit approval"
});
const wantsApprovalOverride = approved || requestedDecision !== null || approvalSource !== null;
const next = pickSystemRunParams(obj);
if (!wantsApprovalOverride) {
const cmdTextResolution = resolveSystemRunCommandRequest({
command: p.command,
rawCommand: p.rawCommand
});
if (!cmdTextResolution.ok) return {
ok: false,
message: cmdTextResolution.message,
details: cmdTextResolution.details
};
return {
ok: true,
params: next
};
}
const runId = normalizeNullableString(p.runId);
if (!runId) return systemRunApprovalGuardError({
code: "MISSING_RUN_ID",
message: "approval override requires params.runId"
});
const manager = opts.execApprovalManager;
if (!manager) return systemRunApprovalGuardError({
code: "APPROVALS_UNAVAILABLE",
message: "exec approvals unavailable"
});
const snapshot = manager.getSnapshot(runId);
if (!snapshot) return systemRunApprovalGuardError({
code: "UNKNOWN_APPROVAL_ID",
message: "unknown or expired approval id",
details: { runId }
});
const recordedResolutionSource = snapshot.resolutionSource ?? "operator";
if (recordedResolutionSource !== "operator" && recordedResolutionSource !== "auto-review") return systemRunApprovalGuardError({
code: "INVALID_APPROVAL_SOURCE",
message: "approval record source invalid",
details: { runId }
});
if (recordedResolutionSource === "auto-review" && snapshot.decision !== "allow-once") {
if (snapshot.consumedDecision === "allow-once") return systemRunApprovalRequired(runId);
return systemRunApprovalGuardError({
code: "APPROVAL_SOURCE_MISMATCH",
message: "auto-review source does not match approval decision",
details: { runId }
});
}
const timedOut = snapshot.resolvedAtMs !== void 0 && snapshot.decision === void 0 && snapshot.consumedDecision === void 0 && snapshot.askFallbackConsumed !== true;
const nowMs = typeof opts.nowMs === "number" ? opts.nowMs : Date.now();
const timeoutReplayExpiresAtMs = snapshot.resolvedAtMs === void 0 ? snapshot.expiresAtMs : snapshot.resolvedAtMs + EXEC_APPROVAL_RESOLVED_ENTRY_GRACE_MS;
if (timedOut ? nowMs > timeoutReplayExpiresAtMs : nowMs > snapshot.expiresAtMs) return systemRunApprovalGuardError({
code: "APPROVAL_EXPIRED",
message: "approval expired",
details: { runId }
});
const targetNodeId = normalizeNullableString(opts.nodeId);
if (!targetNodeId) return systemRunApprovalGuardError({
code: "MISSING_NODE_ID",
message: "node.invoke requires nodeId",
details: { runId }
});
const approvalNodeId = normalizeNullableString(snapshot.request.nodeId);
if (!approvalNodeId) return systemRunApprovalGuardError({
code: "APPROVAL_NODE_BINDING_MISSING",
message: "approval id missing node binding",
details: { runId }
});
if (approvalNodeId !== targetNodeId) return systemRunApprovalGuardError({
code: "APPROVAL_NODE_MISMATCH",
message: "approval id not valid for this node",
details: { runId }
});
const snapshotDeviceId = snapshot.requestedByDeviceId ?? null;
const clientDeviceId = opts.client?.connect?.device?.id ?? null;
if (snapshotDeviceId) {
if (snapshotDeviceId !== clientDeviceId) return systemRunApprovalGuardError({
code: "APPROVAL_DEVICE_MISMATCH",
message: "approval id not valid for this device",
details: { runId }
});
} else if (snapshot.requestedByConnId && snapshot.requestedByConnId !== (opts.client?.connId ?? null) && !canBridgeNoDeviceApprovalFromBackend({
snapshot,
client: opts.client
}) && !canBridgeNoDeviceChatApprovalFromBackend({
snapshot,
rawParams: p,
client: opts.client
})) return systemRunApprovalGuardError({
code: "APPROVAL_CLIENT_MISMATCH",
message: "approval id not valid for this client",
details: { runId }
});
const runtimeContext = resolveSystemRunApprovalRuntimeContext({
plan: snapshot.request.systemRunPlan ?? null,
command: p.command,
rawCommand: p.rawCommand,
cwd: p.cwd,
agentId: p.agentId,
sessionKey: p.sessionKey
});
if (!runtimeContext.ok) return {
ok: false,
message: runtimeContext.message,
details: runtimeContext.details
};
if (runtimeContext.plan) {
next.command = [...runtimeContext.plan.argv];
next.systemRunPlan = runtimeContext.plan;
next.rawCommand = resolveForwardedRawCommand(runtimeContext.plan);
if (runtimeContext.cwd) next.cwd = runtimeContext.cwd;
else delete next.cwd;
if (runtimeContext.agentId) next.agentId = runtimeContext.agentId;
else delete next.agentId;
if (runtimeContext.sessionKey) next.sessionKey = runtimeContext.sessionKey;
else delete next.sessionKey;
}
const approvalMatch = evaluateSystemRunApprovalMatch({
argv: runtimeContext.argv,
request: snapshot.request,
binding: {
cwd: runtimeContext.cwd,
agentId: runtimeContext.agentId,
sessionKey: runtimeContext.sessionKey,
env: p.env
}
});
if (!approvalMatch.ok) return toSystemRunApprovalMismatchError({
runId,
match: approvalMatch
});
const decision = manager.projectDecisionIfActive ? manager.projectDecisionIfActive(runId, snapshot.decision ?? null) : snapshot.decision ?? null;
if ((snapshot.decision === "allow-once" || snapshot.decision === "allow-always") && decision !== snapshot.decision) return systemRunApprovalRequired(runId);
if (snapshot.decision === "allow-once") {
if (approvalSource !== null) return systemRunApprovalGuardError({
code: "APPROVAL_SOURCE_MISMATCH",
message: "approval source does not match approval record",
details: { runId }
});
if (recordedResolutionSource === "auto-review") {
if (!runtimeContext.plan) return systemRunApprovalGuardError({
code: "APPROVAL_PLAN_REQUIRED",
message: "auto-review approval requires an approved execution plan",
details: { runId }
});
}
if (typeof manager.consumeAllowOnce !== "function" || !manager.consumeAllowOnce(runId)) return systemRunApprovalRequired(runId);
if (recordedResolutionSource === "auto-review") {
next.approvalSource = "auto-review";
return {
ok: true,
params: next,
approvalAuthority: {
recordId: runId,
decision: "allow-once"
}
};
}
next.approved = true;
next.approvalDecision = "allow-once";
return {
ok: true,
params: next,
approvalAuthority: {
recordId: runId,
decision: "allow-once"
}
};
}
if (snapshot.decision === "allow-always") {
if (approvalSource !== null) return systemRunApprovalGuardError({
code: "APPROVAL_SOURCE_MISMATCH",
message: "approval source does not match approval record",
details: { runId }
});
next.approved = true;
next.approvalDecision = "allow-always";
return {
ok: true,
params: next,
approvalAuthority: {
recordId: runId,
decision: "allow-always"
}
};
}
if (timedOut && approvalSource === "ask-fallback" && !approved && requestedDecision === null && clientHasApprovals(opts.client)) {
if (!runtimeContext.plan) return systemRunApprovalGuardError({
code: "APPROVAL_PLAN_REQUIRED",
message: "ask fallback requires an approved execution plan",
details: { runId }
});
if (typeof manager.consumeAskFallback !== "function" || !manager.consumeAskFallback(runId)) return systemRunApprovalRequired(runId);
next.approvalSource = "ask-fallback";
return {
ok: true,
params: next,
approvalAuthority: {
recordId: runId,
decision: "allow-once"
}
};
}
return systemRunApprovalRequired(runId);
}
//#endregion
//#region src/gateway/node-invoke-sanitize.ts
/** Sanitizes node.invoke params before forwarding them to a connected node. */
function sanitizeNodeInvokeParamsForForwarding(opts) {
if (opts.command === "system.run") return sanitizeSystemRunParamsForForwarding({
nodeId: opts.nodeId,
rawParams: opts.rawParams,
client: opts.client,
execApprovalManager: opts.execApprovalManager
});
return {
ok: true,
params: opts.rawParams
};
}
//#endregion
//#region src/gateway/server-methods/node-command-rejection-hint.ts
function buildNodeCommandRejectionHint(reason, command, node, cfg) {
const platform = node?.platform ?? "unknown";
if (reason === "command not declared by node") return `node command not allowed: the node (platform: ${platform}) does not support "${command}"`;
if (reason === "command not allowlisted") {
if (command.startsWith("talk.")) return `node command not allowed: "${command}" requires a trusted Talk-capable node`;
if ((cfg.gateway?.nodes?.commands?.deny ?? []).some((entry) => entry.trim() === command)) return `node command not allowed: "${command}" is blocked by gateway.nodes.commands.deny`;
if (DEFAULT_DANGEROUS_NODE_COMMANDS.includes(command)) return `node command not allowed: "${command}" requires explicit gateway.nodes.commands.allow opt-in`;
return `node command not allowed: "${command}" is not in the allowlist for platform "${platform}"`;
}
if (reason === "node did not declare commands") {
if (node?.declaredCommands?.includes(command)) return "node command not allowed: the node's declared command surface is pending approval; run `openclaw nodes pending`, then `openclaw nodes approve <requestId>`";
return `node command not allowed: the node did not declare any supported commands`;
}
return `node command not allowed: ${reason}`;
}
//#endregion
//#region src/gateway/server-methods/nodes.handlers.invoke-progress.ts
const MAX_PROGRESS_CHUNK_BYTES = 16384;
/** Accept one bounded stdout chunk for an active node invocation. */
const handleNodeInvokeProgress = async ({ params, respond, context, client }) => {
if (!assertValidParams(params, validateNodeInvokeProgressParams, "node.invoke.progress", respond)) return;
const progress = params;
const callerNodeId = client?.connect?.device?.id ?? client?.connect?.client?.id;
if (callerNodeId && callerNodeId !== progress.nodeId) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId mismatch"));
return;
}
if (Buffer.byteLength(progress.chunk, "utf8") > MAX_PROGRESS_CHUNK_BYTES) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "progress chunk too large"));
return;
}
respond(true, {
ok: true,
ignored: !context.nodeRegistry.handleInvokeProgress({
...progress,
connId: client?.connId
})
}, void 0);
};
//#endregion
//#region src/gateway/server-methods/nodes.handlers.invoke-result.ts
function normalizeNodeInvokeResultParams(params) {
if (!params || typeof params !== "object") return params;
const normalized = { ...params };
if (normalized.payloadJSON === null) delete normalized.payloadJSON;
else if (normalized.payloadJSON !== void 0 && typeof normalized.payloadJSON !== "string") {
if (normalized.payload === void 0) normalized.payload = normalized.payloadJSON;
delete normalized.payloadJSON;
}
if (normalized.error === null) delete normalized.error;
return normalized;
}
/** Handle a node's response to an earlier gateway `node.invoke` request. */
const handleNodeInvokeResult = async ({ params, respond, context, client }) => {
const normalizedParams = normalizeNodeInvokeResultParams(params);
if (!assertValidParams(normalizedParams, validateNodeInvokeResultParams, "node.invoke.result", respond)) return;
const p = normalizedParams;
const callerNodeId = client?.connect?.device?.id ?? client?.connect?.client?.id;
if (callerNodeId && callerNodeId !== p.nodeId) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId mismatch"));
return;
}
if (!context.nodeRegistry.handleInvokeResult({
id: p.id,
nodeId: p.nodeId,
connId: client?.connId,
ok: p.ok,
payload: p.payload,
payloadJSON: p.payloadJSON ?? null,
error: p.error ?? null
})) {
context.logGateway.debug(`late invoke result ignored: id=${p.id} node=${p.nodeId}`);
respond(true, {
ok: true,
ignored: true
}, void 0);
return;
}
respond(true, { ok: true }, void 0);
};
//#endregion
//#region src/gateway/server-methods/nodes.invoke-authority.ts
function isForwardedNodeInvokeApprovalAuthorityActive(params) {
const authority = params.authority;
return !authority || params.manager?.projectDecisionIfActive(authority.recordId, authority.decision) === authority.decision;
}
function resolveNodeInvokeRuntimeAuthorityError(params) {
const callerIdentity = params.client?.internal?.agentRuntimeIdentity;
if (callerIdentity && params.context.validateAgentRuntimeApprovalAuthority?.(callerIdentity) !== true) return "agent runtime approval authority closed before node dispatch";
if (!isForwardedNodeInvokeApprovalAuthorityActive({
manager: params.context.execApprovalManager,
authority: params.approvalAuthority
})) return "approved runtime authority closed before node dispatch";
}
//#endregion
//#region src/gateway/server-methods/nodes.invoke-deadline.ts
const NODE_INVOKE_DEADLINE_EXPIRED = ABSOLUTE_DEADLINE_EXPIRED;
/** Bounds node pairing, wake, policy, and transport preparation by one absolute deadline. */
async function awaitNodeInvokeWithinDeadline(operation, deadlineAtMs) {
return await awaitWithinDeadline(operation, deadlineAtMs);
}
//#endregion
//#region src/gateway/server-methods/nodes.invoke-foreground.ts
/** Queues only commands that iOS explicitly rejected as requiring the foreground. */
function shouldQueueAsPendingForegroundAction(params) {
const platform = normalizeLowercaseStringOrEmpty(params.platform);
if (!platform.startsWith("ios") && !platform.startsWith("ipados")) return false;
if (!isForegroundRestrictedPluginNodeCommand(params.command) && !params.command.startsWith("camera.") && !params.command.startsWith("screen.") && !params.command.startsWith("talk.")) return false;
const error = params.error && typeof params.error === "object" ? params.error : null;
const code = normalizeOptionalString(error?.code)?.toUpperCase() ?? "";
const message = normalizeOptionalString(error?.message)?.toUpperCase() ?? "";
return code === "NODE_BACKGROUND_UNAVAILABLE" || message.includes("BACKGROUND_UNAVAILABLE");
}
//#endregion
//#region src/gateway/server-methods/nodes.invoke-talk-events.ts
const TALK_PTT_COMMANDS = /* @__PURE__ */ new Set([
"talk.ptt.start",
"talk.ptt.stop",
"talk.ptt.cancel",
"talk.ptt.once"
]);
const talkPttEventSeqBySessionId = /* @__PURE__ */ new Map();
function emitTalkPttNodeEvent(params) {
if (!TALK_PTT_COMMANDS.has(params.command)) return;
const payloadObj = typeof params.payload === "object" && params.payload !== null ? params.payload : {};
const captureId = normalizeOptionalString(payloadObj.captureId) ?? randomUUID();
const sessionId = `node:${params.nodeId}:talk:${captureId}`;
const seq = (talkPttEventSeqBySessionId.get(sessionId) ?? 0) + 1;
talkPttEventSeqBySessionId.set(sessionId, seq);
pruneMapToMaxSize(talkPttEventSeqBySessionId, 2048);
const type = params.command === "talk.ptt.start" ? "capture.started" : params.command === "talk.ptt.cancel" ? "capture.cancelled" : params.command === "talk.ptt.once" ? "capture.once" : "capture.stopped";
const final = params.command !== "talk.ptt.start";
const talkEvent = {
id: `${sessionId}:${seq}`,
type,
sessionId,
captureId,
seq,
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
mode: "stt-tts",
transport: "managed-room",
brain: "agent-consult",
final,
payload: {
nodeId: params.nodeId,
command: params.command,
status: normalizeOptionalString(payloadObj.status) ?? void 0,
transcript: normalizeOptionalString(payloadObj.transcript) ?? void 0
}
};
params.context.broadcast("talk.event", {
nodeId: params.nodeId,
command: params.command,
talkEvent
}, { dropIfSlow: true });
}
//#endregion
//#region src/gateway/server-methods/nodes.pending.ts
function resolveAllowedPendingNodeActions(params) {
const pending = listPendingNodeActions({
nodeId: params.nodeId,
pairingGeneration: params.pairingGeneration,
ttlMs: nodeInvokePolicy.pendingActionTtlMs
});
if (pending.length === 0) return pending;
const connect = params.client?.connect;
const declaredCommands = Array.isArray(connect?.commands) ? connect.commands : [];
const allowlist = resolveNodeCommandAllowlist(params.cfg, {
platform: connect?.client?.platform,
deviceFamily: connect?.client?.deviceFamily,
caps: connect?.caps,
commands: declaredCommands
});
const allowed = pending.filter((entry) => {
return isNodeCommandAllowed({
command: entry.command,
declaredCommands,
allowlist
}).ok;
});
if (allowed.length !== pending.length) replacePendingNodeActionsForGeneration({
nodeId: params.nodeId,
pairingGeneration: params.pairingGeneration,
replacement: allowed,
ttlMs: nodeInvokePolicy.pendingActionTtlMs
});
return allowed;
}
function ackPendingNodeActions(nodeId, ids, pairingGeneration) {
if (ids.length === 0) return listPendingNodeActions({
nodeId,
pairingGeneration,
ttlMs: nodeInvokePolicy.pendingActionTtlMs
});
return acknowledgePendingNodeActions({
nodeId,
pairingGeneration,
ids,
ttlMs: nodeInvokePolicy.pendingActionTtlMs
});
}
function toPendingParamsJSON(params) {
if (params === void 0) return;
try {
return JSON.stringify(params);
} catch {
return;
}
}
const nodePendingActionHandlers = {
"node.pending.pull": async ({ params, respond, client, context }) => {
if (!assertValidParams(params, validateNodeListParams, "node.pending.pull", respond)) return;
const nodeId = client?.connect?.device?.id ?? client?.connect?.client?.id;
const trimmedNodeId = normalizeOptionalString(nodeId) ?? "";
if (!trimmedNodeId) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
return;
}
await respondUnavailableOnThrow(respond, async () => {
const generation = await captureNodePairingGeneration(trimmedNodeId);
if (!generation) {
respondPairingChanged(respond);
return;
}
const session = context.nodeRegistry.getForPairingGeneration(trimmedNodeId, generation.key);
if (!session || session.connId !== client?.connId) {
respondPairingChanged(respond);
return;
}
const pending = resolveAllowedPendingNodeActions({
nodeId: trimmedNodeId,
pairingGeneration: generation.key,
client,
cfg: context.getRuntimeConfig()
});
if (!await isNodePairingGenerationCurrent(generation)) {
respondPairingChanged(respond);
return;
}
respond(true, {
nodeId: trimmedNodeId,
actions: pending.map((entry) => ({
id: entry.id,
command: entry.command,
paramsJSON: entry.paramsJSON ?? null,
enqueuedAtMs: entry.enqueuedAtMs
}))
}, void 0);
});
},
"node.pending.ack": async ({ params, respond, client, context }) => {
if (!assertValidParams(params, validateNodePendingAckParams, "node.pending.ack", respond)) return;
const nodeId = client?.connect?.device?.id ?? client?.connect?.client?.id;
const trimmedNodeId = normalizeOptionalString(nodeId) ?? "";
if (!trimmedNodeId) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
return;
}
await respondUnavailableOnThrow(respond, async () => {
const generation = await captureNodePairingGeneration(trimmedNodeId);
if (!generation) {
respondPairingChanged(respond);
return;
}
const session = context.nodeRegistry.getForPairingGeneration(trimmedNodeId, generation.key);
if (!session || session.connId !== client?.connId) {
respondPairingChanged(respond);
return;
}
const ackIds = normalizeUniqueTrimmedStringList(params.ids);
const remaining = ackPendingNodeActions(trimmedNodeId, ackIds, generation.key);
if (!await isNodePairingGenerationCurrent(generation)) {
respondPairingChanged(respond);
return;
}
respond(true, {
nodeId: trimmedNodeId,
ackedIds: ackIds,
remainingCount: remaining.length
}, void 0);
});
}
};
//#endregion
//#region src/gateway/server-methods/nodes.invoke.ts
const nodeInvokeHandlers = {
"node.invoke": async ({ params, respond, context, client, req, signal }) => {
if (!assertValidParams(params, validateNodeInvokeParams, "node.invoke", respond)) return;
const p = params;
const nodeId = normalizeOptionalString(p.nodeId) ?? "";
const command = normalizeOptionalString(p.command) ?? "";
const sessionKey = normalizeOptionalString(p.sessionKey);
const nodeInvokeStream = client?.internal?.syntheticClient === true && client.internal.pluginRuntimeOwnerId ? client.internal.nodeInvokeStream : void 0;
if (!nodeId || !command) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId and command required"));
return;
}
if (isPrivateNodeInvokeCommand(command)) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "node.invoke does not allow private node controls", { details: { command } }));
return;
}
if (command === "system.execApprovals.get" || command === "system.execApprovals.set") {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "node.invoke does not allow system.execApprovals.*; use exec.approvals.node.*", { details: { command } }));
return;
}
if (nodeInvokePolicy.rejectClaudeAgentRun(command, respond)) return;
if (isBrowserProxyNodeInvokeCommand(command) && isForbiddenBrowserProxyMutation(p.params)) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `node.invoke cannot mutate persistent browser profiles via ${command}`, { details: { command } }));
return;
}
if (isAdminOnlyNodeInvokeCommand(command) && !nodeInvokePolicy.clientHasOperatorAdminScope(client)) {
respond(false, void 0, missingScopeErrorShape({
missingScope: ADMIN_SCOPE,
requiredScopes: [ADMIN_SCOPE]
}));
return;
}
const invokeDeadlineAtMs = typeof p.timeoutMs === "number" && p.timeoutMs > 0 ? Date.now() + p.timeoutMs : void 0;
let nodeCommandDispatched = false;
const resolveRemainingInvokeTimeoutMs = () => invokeDeadlineAtMs === void 0 ? p.timeoutMs : Math.max(0, invokeDeadlineAtMs - Date.now());
const respondIfInvokeExpired = () => {
if (invokeDeadlineAtMs === void 0 || resolveRemainingInvokeTimeoutMs() !== 0) return false;
respondUnavailableOnNodeInvokeErrorWithProvenance(respond, {
ok: false,
error: {
code: "TIMEOUT",
message: "node invoke timed out"
}
}, { nodeCommandDispatched });
return true;
};
await respondUnavailableOnThrow(respond, async () => {
const generation = await awaitNodeInvokeWithinDeadline(() => captureNodePairingGeneration(nodeId), invokeDeadlineAtMs);
if (generation === NODE_INVOKE_DEADLINE_EXPIRED) {
respondIfInvokeExpired();
return;
}
if (!generation) {
respondPairingChanged(respond);
return;
}
const wakeLifecycle = captureNodeWakeLifecycle(nodeId, generation.key);
const invocationLifecycle = signal ? AbortSignal.any([wakeLifecycle, signal]) : wakeLifecycle;
let releaseApprovalHandoff;
try {
const continuePairingWork = async () => {
const pairingCurrent = await awaitNodeInvokeWithinDeadline(() => isNodePairingWorkCurrent({
nodeId,
generation,
lifecycle: wakeLifecycle
}), invokeDeadlineAtMs);
if (pairingCurrent === NODE_INVOKE_DEADLINE_EXPIRED) {
respondIfInvokeExpired();
return false;
}
if (pairingCurrent) return true;
respondPairingChanged(respond);
return false;
};
if (respondIfInvokeExpired()) return;
const cfg = context.getRuntimeConfig();
let nodeSession = resolveDispatchableNodeSession(context.nodeRegistry.getForPairingGeneration(nodeId, generation.key));
if (!nodeSession) {
const wakeReqId = req.id;
const wakeFlowStartedAtMs = Date.now();
context.logGateway.info(`node wake start node=${nodeId} req=${wakeReqId} command=${command}`);
const wake = await awaitNodeInvokeWithinDeadline(() => maybeWakeNodeWithApns(nodeId, {
cfg,
lifecycle: wakeLifecycle,
generation
}), invokeDeadlineAtMs);
if (wake === NODE_INVOKE_DEADLINE_EXPIRED) {
respondIfInvokeExpired();
return;
}
context.logGateway.info(`node wake stage=wake1 node=${nodeId} req=${wakeReqId} available=${wake.available} throttled=${wake.throttled} path=${wake.path} durationMs=${wake.durationMs} apnsStatus=${wake.apnsStatus ?? -1} apnsReason=${wake.apnsReason ?? "-"}`);
if (respondIfInvokeExpired()) return;
if (wake.available) {
const waitStartedAtMs = Date.now();
const remainingTimeoutMs = resolveRemainingInvokeTimeoutMs();
const waitTimeoutMs = invokeDeadlineAtMs === void 0 ? NODE_WAKE_RECONNECT_WAIT_MS : Math.min(NODE_WAKE_RECONNECT_WAIT_MS, remainingTimeoutMs ?? 0);
const reconnected = await waitForNodeReconnect({
nodeId,
context,
timeoutMs: waitTimeoutMs,
lifecycle: wakeLifecycle,
pairingGeneration: generation.key
});
const waitDurationMs = Math.max(0, Date.now() - waitStartedAtMs);
context.logGateway.info(`node wake stage=wait1 node=${nodeId} req=${wakeReqId} reconnected=${reconnected} timeoutMs=${waitTimeoutMs} durationMs=${waitDurationMs}`);
}
if (!await continuePairingWork() || respondIfInvokeExpired()) return;
nodeSession = resolveDispatchableNodeSession(context.nodeRegistry.getForPairingGeneration(nodeId, generation.key));
if (!nodeSession && wake.available) {
const retryWake = await awaitNodeInvokeWithinDeadline(() => maybeWakeNodeWithApns(nodeId, {
force: true,
cfg,
lifecycle: wakeLifecycle,
generation
}), invokeDeadlineAtMs);
if (retryWake === NODE_INVOKE_DEADLINE_EXPIRED) {
respondIfInvokeExpired();
return;
}
context.logGateway.info(`node wake stage=wake2 node=${nodeId} req=${wakeReqId} force=true available=${retryWake.available} throttled=${retryWake.throttled} path=${retryWake.path} durationMs=${retryWake.durationMs} apnsStatus=${retryWake.apnsStatus ?? -1} apnsReason=${retryWake.apnsReason ?? "-"}`);
if (respondIfInvokeExpired()) return;
if (retryWake.available) {
const waitStartedAtMs = Date.now();
const remainingTimeoutMs = resolveRemainingInvokeTimeoutMs();
const waitTimeoutMs = invokeDeadlineAtMs === void 0 ? NODE_WAKE_RECONNECT_RETRY_WAIT_MS : Math.min(NODE_WAKE_RECONNECT_RETRY_WAIT_MS, remainingTimeoutMs ?? 0);
const reconnected = await waitForNodeReconnect({
nodeId,
context,
timeoutMs: waitTimeoutMs,
lifecycle: wakeLifecycle,
pairingGeneration: generation.key
});
const waitDurationMs = Math.max(0, Date.now() - waitStartedAtMs);
context.logGateway.info(`node wake stage=wait2 node=${nodeId} req=${wakeReqId} reconnected=${reconnected} timeoutMs=${waitTimeoutMs} durationMs=${waitDurationMs}`);
}
if (!await continuePairingWork() || respondIfInvokeExpired()) return;
nodeSession = resolveDispatchableNodeSession(context.nodeRegistry.getForPairingGeneration(nodeId, generation.key));
}
if (!nodeSession) {
if (respondIfInvokeExpired()) return;
const totalDurationMs = Math.max(0, Date.now() - wakeFlowStartedAtMs);
const nudge = await awaitNodeInvokeWithinDeadline(() => maybeSendNodeWakeNudge(nodeId, {
cfg,
lifecycle: wakeLifecycle,
generation
}), invokeDeadlineAtMs);
if (nudge === NODE_INVOKE_DEADLINE_EXPIRED) {
respondIfInvokeExpired();
return;
}
if (!await continuePairingWork()) return;
context.logGateway.info(`node wake nudge node=${nodeId} req=${wakeReqId} sent=${nudge.sent} throttled=${nudge.throttled} reason=${nudge.reason} durationMs=${nudge.durationMs} apnsStatus=${nudge.apnsStatus ?? -1} apnsReason=${nudge.apnsReason ?? "-"}`);
context.logGateway.warn(`node wake done node=${nodeId} req=${wakeReqId} connected=false reason=not_connected totalMs=${totalDurationMs}`);
respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, "node not connected", { details: {
code: "NOT_CONNECTED",
nodeError: {
code: "NOT_CONNECTED",
message: "node not connected"
},
nodeCommandDispatched: false
} }));
return;
}
const totalDurationMs = Math.max(0, Date.now() - wakeFlowStartedAtMs);
context.logGateway.info(`node wake done node=${nodeId} req=${wakeReqId} connected=true totalMs=${totalDurationMs}`);
}
for (const authorizationCfg of [cfg, context.getRuntimeConfig()]) {
const allowlist = resolveNodeCommandAllowlist(authorizationCfg, {
...nodeSession,
approvedCommands: nodeSession.commands
});
const allowed = isNodeCommandAllowed({
command,
declaredCommands: nodeSession.commands,
allowlist
});
if (!allowed.ok) {
const hint = buildNodeCommandRejectionHint(allowed.reason, command, nodeSession, authorizationCfg);
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, hint, { details: {
reason: allowed.reason,
command
} }));
return;
}
}
const forwardedParams = sanitizeNodeInvokeParamsForForwarding({
nodeId,
command,
rawParams: p.params,
client,
execApprovalManager: context.execApprovalManager
});
if (!forwardedParams.ok) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, forwardedParams.message, { details: forwardedParams.details ?? null }));
return;
}
if (respondIfInvokeExpired()) return;
if (forwardedParams.approvalAuthority) {
const authority = forwardedParams.approvalAuthority;
releaseApprovalHandoff = context.execApprovalManager?.retainForHandoff(authority.recordId) ?? void 0;
if (!releaseApprovalHandoff) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "approved runtime authority closed before node dispatch", { details: { code: "APPROVAL_AUTHORITY_CLOSED" } }));
return;
}
}
const isForwardedApprovalAuthorityActive = () => isForwardedNodeInvokeApprovalAuthorityActive({
manager: context.execApprovalManager,
authority: forwardedParams.approvalAuthority
});
const policyResult = await awaitNodeInvokeWithinDeadline(() => applyPluginNodeInvokePolicy({
context,
client,
nodeSession,
command,
params: forwardedParams.params,
...sessionKey ? { sessionKey } : {},
turnSource: {
channel: p.turnSourceChannel,
to: p.turnSourceTo,
accountId: p.turnSourceAccountId,
threadId: p.turnSourceThreadId
},
timeoutMs: p.timeoutMs,
signal: invocationLifecycle,
resolveRemainingTimeoutMs: resolveRemainingInvokeTimeoutMs,
onNodeCommandDispatched: () => {
nodeCommandDispatched = true;
},
idempotencyKey: p.idempotencyKey,
isInvocationCurrent: () => isNodePairingWorkCurrent({
nodeId,
generation,
lifecycle: wakeLifecycle
}),
isApprovalAuthorityActive: isForwardedApprovalAuthorityActive,
...nodeInvokeStream ? { nodeInvokeStream } : {}
}), invokeDeadlineAtMs);
if (policyResult === NODE_INVOKE_DEADLINE_EXPIRED) {
respondIfInvokeExpired();
return;
}
if (!await continuePairingWork()) return;
if (policyResult) {
if (!policyResult.ok) {
const errorCode = policyResult.unavailable ? ErrorCodes.UNAVAILABLE : ErrorCodes.INVALID_REQUEST;
respond(false, void 0, errorShape(errorCode, policyResult.message, { details: {
...policyResult.details,
...policyResult.code ? { code: policyResult.code } : {}
} }));
return;
}
const payload = policyResult.payloadJSON ? parseGatewayPayload(policyResult.payloadJSON) : policyResult.payload;
emitTalkPttNodeEvent({
context,
nodeId,
command,
payload
});
respond(true, {
ok: true,
nodeId,
command,
payload: policyResult.payload,
payloadJSON: policyResult.payloadJSON ?? null
}, void 0);
return;
}
const dispatchSession = resolveDispatchableNodeSession(context.nodeRegistry.getForPairingGeneration(nodeId, generation.key));
if (!dispatchSession || dispatchSession.connId !== nodeSession.connId) {
respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, "node connection changed before dispatch", {
retryable: true,
details: { code: "ROUTE_CHANGED" }
}));
return;
}
const resolveDispatchAuthorization = (dispatchCfg) => isNodeCommandAllowed({
command,
declaredCommands: dispatchSession.commands,
allowlist: resolveNodeCommandAllowlist(dispatchCfg, {
...dispatchSession,
approvedCommands: dispatchSession.commands
})
});
const dispatchCfg = context.getRuntimeConfig();
const dispatchAllowed = resolveDispatchAuthorization(dispatchCfg);
if (!dispatchAllowed.ok) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, buildNodeCommandRejectionHint(dispatchAllowed.reason, command, dispatchSession, dispatchCfg), { details: {
reason: dispatchAllowed.reason,
command
} }));
return;
}
const dispatchTimeoutMs = resolveRemainingInvokeTimeoutMs();
if (invokeDeadlineAtMs !== void 0 && dispatchTimeoutMs === 0) {
respondIfInvokeExpired();
return;
}
const authorityError = resolveNodeInvokeRuntimeAuthorityError({
context,
client,
approvalAuthority: forwardedParams.approvalAuthority
});
if (authorityError) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, authorityError, { details: { code: "APPROVAL_AUTHORITY_CLOSED" } }));
return;
}
const res = await invokeNodeWithReadinessRetry(context.nodeRegistry, {
nodeId,
expectedConnId: nodeSession.connId,
expectedPairingGeneration: generation.key,
command,
params: forwardedParams.params,
timeoutMs: dispatchTimeoutMs,
signal: invocationLifecycle,
idempotencyKey: p.idempotencyKey,
...sessionKey ? { sessionKey } : {},
...nodeInvokeStream && {
onProgress: nodeInvokeStream.onProgress,
idleTimeoutMs: nodeInvokeStream.idleTimeoutMs
},
isDispatchAuthorized: () => (nodeInvokeStream?.isRuntimeCurrent() ?? true) && resolveNodeInvokeRuntimeAuthorityError({
context,
client,
approvalAuthority: forwardedParams.approvalAuthority
}) === void 0,
onDispatchReady: (invokeId) => {
nodeCommandDispatched = true;
nodeInvokeStream?.onDispatchReady(invokeId);
}
});
if (!await continuePairingWork()) return;
if (!res.ok) {
if (shouldQueueAsPendingForegroundAction({
platform: nodeSession.platform,
command,
error: res.error
}) && !client?.internal?.agentRuntimeIdentity && !forwardedParams.approvalAuthority) {
const paramsJSON = toPendingParamsJSON(forwardedParams.params);
const queued = enqueuePendingNodeAction({
nodeId,
pairingGeneration: generation.key,
command,
paramsJSON,
idempotencyKey: p.idempotencyKey,
ttlMs: nodeInvokePolicy.pendingActionTtlMs,
maxPerNode: nodeInvokePolicy.pendingActionMaxPerNode
});
const wake = await maybeWakeNodeWithApns(nodeId, {
cfg,
lifecycle: wakeLifecycle,
generation
});
if (!await continuePairingWork()) {
if (queued.created) removePendingNodeAction({
nodeId,
pairingGeneration: generation.key,
actionId: queued.action.id,
ttlMs: nodeInvokePolicy.pendingActionTtlMs
});
return;
}
context.logGateway.info(`node pending queued node=${nodeId} req=${req.id} command=${command} queuedId=${queued.action.id} wakePath=${wake.path} wakeAvailable=${wake.available}`);
respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, "node command queued until iOS returns to foreground", {
retryable: true,
details: {
code: "QUEUED_UNTIL_FOREGROUND",
queuedActionId: queued.action.id,
nodeId,
command,
wake: {
path: wake.path,
available: wake.available,
throttled: wake.throttled,
apnsStatus: wake.apnsStatus,
apnsReason: wake.apnsReason
},
nodeError: res.error ?? null
}
}));
return;
}
if (!respondUnavailableOnNodeInvokeErrorWithProvenance(respond, res, { nodeCommandDispatched })) return;
return;
}
const payload = res.payloadJSON ? parseGatewayPayload(res.payloadJSON) : res.payload;
emitTalkPttNodeEvent({
context,
nodeId,
command,
payload
});
respond(true, {
ok: true,
nodeId,
command,
payload,
payloadJSON: res.payloadJSON ?? null
}, void 0);
} finally {
releaseApprovalHandoff?.();
releaseNodeWakeLifecycle(nodeId, wakeLifecycle);
}
});
},
"node.invoke.progress": handleNodeInvokeProgress,
"node.invoke.result": handleNodeInvokeResult
};
//#endregion
//#region src/gateway/server-methods/nodes.pairing.ts
function broadcastRemovedNodePairing(params) {
params.context.broadcast("node.pair.resolved", {
requestId: "",
nodeId: params.nodeId,
decision: "removed",
ts: Date.now()
}, { dropIfSlow: true });
}
function emitNodePairingDeniedSecurityEvent(params) {
emitDeviceManagementSecurityEvent({
action: "device.pairing.denied",
outcome: "denied",
severity: "medium",
authz: params.authz,
targetDeviceId: params.nodeId,
policyId: "gateway.device-pairing",
decision: "deny",
controlId: params.controlId,
reason: params.reason,
attributes: { role: "node" }
});
}
async function enforcePendingNodePairingOwnership(params) {
const action = params.mutation === "approve" ? "approval" : "rejection";
const controlId = params.mutation === "approve" ? "node.pair.approve" : "node.pair.reject";
const deniedMessage = `node pairing ${action} denied`;
const pending = await getPendingNodePairing(params.requestId);
const sessionAuthz = resolveDeviceSessionAuthz(params.client);
if (!pending) {
if (sessionAuthz.callerDeviceId && !sessionAuthz.isAdminCaller) {
params.respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, deniedMessage));
return false;
}
return true;
}
const authz = resolveDeviceManagementAuthz(params.client, pending.nodeId);
if (!deniesCrossDeviceManagement(authz)) return true;
params.context.logGateway.warn(`${deniedMessage} node=${pending.nodeId} reason=device-ownership-mismatch`);
emitNodePairingDeniedSecurityEvent({
authz,
nodeId: pending.nodeId,
controlId,
reason: "device-ownership-mismatch"
});
params.respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, deniedMessage));
return false;
}
function emitNodeRoleRemovalSecurityEvent(params) {
const denied = params.reason !== void 0;
emitDeviceManagementSecurityEvent({
action: denied ? "device.role.removal_denied" : "device.role.removed",
outcome: denied ? "denied" : "success",
severity: "medium",
authz: params.authz,
targetDeviceId: params.deviceId,
policyId: "gateway.device-pairing",
decision: denied ? "deny" : "allow",
controlId: "node.pair.remove",
...params.reason ? { reason: params.reason } : {},
attributes: {
role: "node",
...params.removedDevice !== void 0 ? { removed_device: params.removedDevice } : {}
}
});
}
async function removePairedDeviceBackedNode(params) {
const nodeId = params.nodeId.trim();
if (!nodeId) return { status: "unknown" };
const paired = await getPairedDevice(nodeId);
if (!paired || !listApprovedPairedDeviceRoles(paired).includes("node")) return { status: "unknown" };
const authz = resolveDeviceManagementAuthz(params.client, nodeId);
if (deniesCrossDeviceManagement(authz)) {
params.context.logGateway.warn(`node pairing removal denied node=${nodeId} reason=device-ownership-mismatch`);
emitNodeRoleRemovalSecurityEvent({
authz,
deviceId: nodeId,
reason: "device-ownership-mismatch"
});
return {
status: "denied",
message: "node pairing removal denied"
};
}
if (authz.callerDeviceId && !authz.isAdminCaller && pairedDeviceHasNonOperatorRole(paired)) {
params.context.logGateway.warn(`node pairing removal denied node=${nodeId} reason=role-management-requires-admin`);
emitNodeRoleRemovalSecurityEvent({
authz,
deviceId: nodeId,
reason: "role-management-requires-admin"
});
return {
status: "denied",
message: "node pairing removal denied"
};
}
const removed = await removePairedDeviceRole({
deviceId: nodeId,
role: "node"
});
if (!removed) return { status: "unknown" };
params.context.logGateway.info(`node pairing removed device-backed node=${removed.deviceId}`);
emitNodeRoleRemovalSecurityEvent({
authz,
deviceId: removed.deviceId,
removedDevice: removed.removedDevice
});
params.context.invalidateClientsForDevice?.(removed.deviceId, {
role: "node",
reason: "device-pair-removed"
});
await reconcileRevokedDeviceWorker(params.context, removed.deviceId);
return {
status: "removed",
nodeId: removed.deviceId,
disconnectDeviceId: removed.deviceId
};
}
//#endregion
//#region src/gateway/server-methods/nodes.ts
const nodeHandlers = {
"node.pair.list": async ({ params, respond, client }) => {
if (!assertValidParams(params, validateNodePairListParams, "node.pair.list", respond)) return;
await respondUnavailableOnThrow(respond, async () => {
const list = await listNodePairing();
const authz = resolveDeviceSessionAuthz(client);
respond(true, authz.callerDeviceId && !authz.isAdminCaller ? {
pending: list.pending.filter((request) => request.nodeId.trim() === authz.callerDeviceId),
paired: list.paired.filter((node) => node.nodeId.trim() === authz.callerDeviceId)
} : list, void 0);
});
},
"node.pair.approve": async ({ params, respond, context, client }) => {
if (!assertValidParams(params, validateNodePairApproveParams, "node.pair.approve", respond)) return;
const { requestId } = params;
const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
await respondUnavailableOnThrow(respond, async () => {
if (!await enforcePendingNodePairingOwnership({
requestId,
mutation: "approve",
client,
context,
respond
})) return;
const pendingApproval = await getPendingNodePairing(requestId);
const pairingStateBeforeApproval = pendingApproval ? await captureNodePairingState(pendingApproval.nodeId) : null;
const sessionBeforeApproval = pendingApproval ? context.nodeRegistry.get(pendingApproval.nodeId) : void 0;
const approved = await approveNodePairing(requestId, { callerScopes });
if (!approved) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
return;
}
if ("status" in approved && approved.status === "forbidden") {
respond(false, void 0, missingScopeErrorShape({
missingScope: approved.missingScope,
requiredScopes: approved.missingScope === "operator.pairing" ? [PAIRING_SCOPE] : [PAIRING_SCOPE, approved.missingScope]
}));
return;
}
if (!("node" in approved)) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
return;
}
const approvedNode = approved.node;
invalidateNodeWakeState(approvedNode.nodeId);
const persistedApprovedState = await captureNodePairingState(approvedNode.nodeId);
const previousGenerationKey = pairingStateBeforeApproval?.generation?.key;
const updatedNode = Boolean(sessionBeforeApproval && pairingStateBeforeApproval?.identity.key === approved.pairingIdentity && sessionBeforeApproval.pairingIdentity === approved.pairingIdentity && approved.previousPairingGeneration === previousGenerationKey && sessionBeforeApproval.pairingGeneration === previousGenerationKey) && sessionBeforeApproval && persistedApprovedState?.identity.key === approved.pairingIdentity && persistedApprovedState.generation?.key === approved.nextPairingGeneration ? context.nodeRegistry.updateSurface(approvedNode.nodeId, {
caps: approvedNode.caps ?? [],
commands: approvedNode.commands ?? [],
permissions: approvedNode.permissions
}, {
expectedConnId: sessionBeforeApproval.connId,
expectedPairingIdentity: approved.pairingIdentity,
...previousGenerationKey ? { expectedPairingGeneration: previousGenerationKey } : {},
nextPairingGeneration: approved.nextPairingGeneration
}) : null;
if (updatedNode) refreshConnectedNodeSurfaceCaches({
context,
nodeSession: updatedNode
});
context.broadcast("node.pair.resolved", {
requestId,
nodeId: approvedNode.nodeId,
decision: "approved",
ts: Date.now()
}, { dropIfSlow: true });
respond(true, {
requestId: approved.requestId,
node: approvedNode
}, void 0);
});
},
"node.pair.reject": async ({ params, respond, context, client }) => {
if (!assertValidParams(params, validateNodePairRejectParams, "node.pair.reject", respond)) return;
const { requestId } = params;
await respondUnavailableOnThrow(respond, async () => {
if (!await enforcePendingNodePairingOwnership({
requestId,
mutation: "reject",
client,
context,
respond
})) return;
const rejected = await rejectNodePairing(requestId);
if (!rejected) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
return;
}
context.broadcast("node.pair.resolved", {
requestId,
nodeId: rejected.nodeId,
decision: "rejected",
ts: Date.now()
}, { dropIfSlow: true });
respond(true, rejected, void 0);
});
},
"node.pair.remove": async ({ params, respond, context, client }) => {
if (!assertValidParams(params, validateNodePairRemoveParams, "node.pair.remove", respond)) return;
const { nodeId } = params;
await respondUnavailableOnThrow(respond, async () => {
const deviceBacked = await removePairedDeviceBackedNode({
nodeId,
client,
context
});
if (deviceBacked.status === "denied") {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, deviceBacked.message));
return;
}
if (deviceBacked.status !== "removed") {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"));
return;
}
try {
clearRemovedNodeRuntimeState({
nodeId: deviceBacked.nodeId,
context
});
broadcastRemovedNodePairing({
nodeId: deviceBacked.nodeId,
context
});
respond(true, { nodeId: deviceBacked.nodeId }, void 0);
} finally {
queueMicrotask(() => {
context.disconnectClientsForDevice?.(deviceBacked.disconnectDeviceId, { role: "node" });
});
}
});
},
"node.rename": async ({ params, respond, context, client }) => {
if (!assertValidParams(params, validateNodeRenameParams, "node.rename", respond)) return;
const { nodeId, displayName } = params;
await respondUnavailableOnThrow(respond, async () => {
const authz = resolveDeviceManagementAuthz(client, nodeId);
if (deniesCrossDeviceManagement(authz)) {
context.logGateway.warn(`node rename denied node=${authz.normalizedTargetDeviceId} reason=device-ownership-mismatch`);
emitNodePairingDeniedSecurityEvent({
authz,
nodeId: authz.normalizedTargetDeviceId,
controlId: "node.rename",
reason: "device-ownership-mismatch"
});
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "node rename denied"));
return;
}
const trimmed = displayName.trim();
if (!trimmed) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "displayName required"));
return;
}
const updated = await renamePairedNode(nodeId, trimmed);
if (!updated) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"));
return;
}
respond(true, {
nodeId: updated.nodeId,
displayName: updated.displayName
}, void 0);
});
},
...nodeReadHandlers,
...nodePendingActionHandlers,
...nodeInvokeHandlers,
...nodeEventHandlers
};
//#endregion
export { nodeHandlers };