UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

554 lines (553 loc) 24.3 kB
import { D as resolveExpiresAtMsFromDurationMs, F as resolveTimerTimeoutMs } from "./number-coercion-CLj0HTDM.js"; import { c as isRecord, i as asOptionalObjectRecord } from "./record-coerce-DItp3I4t.js"; import { l as normalizeOptionalString } from "./string-coerce-CIXf7egm.js"; import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.js"; import { i as sanitizeExecApprovalWarningText, n as sanitizeExecApprovalDisplayText, t as exceedsApprovalTextLimit } from "./exec-approval-text-sanitize-C1BzZQH8.js"; import { t as sanitizeApprovalScope } from "./approval-scope-BL54HpUv.js"; import { p as truncatePluginApprovalDetail } from "./plugin-approvals-BUrr4V_c.js"; import { t as resolveExecApprovalCommandDisplay } from "./exec-approval-command-display-03IqfHT_.js"; import { a as forceDenyOperatorApproval, c as insertOperatorApproval, p as resolveOperatorApproval, r as consumeOperatorApprovalAllowOnce } from "./operator-approval-store-BxnBnd-1.js"; import { n as EXEC_APPROVAL_RESOLVED_ENTRY_GRACE_MS, r as ExecApprovalLifecycle } from "./exec-approval-lifecycle-CRyRxBLA.js"; import { randomUUID } from "node:crypto"; //#region src/infra/approval-presentation.ts const PLUGIN_EXTERNAL_RESOLUTION_LABEL_MAX_LENGTH = 80; function normalizeDecisionList(decisions) { const result = []; for (const decision of decisions) if (!result.includes(decision)) result.push(decision); if (!result.includes("deny")) result.push("deny"); return result; } function sanitizeOptionalSingleLine(value) { const normalized = normalizeOptionalString(value); return normalized ? sanitizeExecApprovalDisplayText(normalized) : null; } function normalizePluginExternalResolution(value) { if (!value) return null; const rawLabel = value.label?.trim(); const label = rawLabel ? sanitizeExecApprovalDisplayText(rawLabel) : ""; if (!label || exceedsApprovalTextLimit(label, PLUGIN_EXTERNAL_RESOLUTION_LABEL_MAX_LENGTH)) throw new Error("invalid external approval label"); const decisions = value.decisions ?? ["allow-once"]; if (decisions.length < 1 || decisions.length > 2 || decisions.some((decision) => decision !== "allow-once" && decision !== "allow-always") || new Set(decisions).size !== decisions.length) throw new Error("invalid external approval decisions"); return { label, decisions: [...decisions] }; } function buildExecApprovalPresentation(params) { if (!isRecord(params.request)) return null; const request = params.request; const { commandText, commandPreview } = resolveExecApprovalCommandDisplay(request); if (!commandText.trim()) return null; const warningText = typeof request.warningText === "string" && request.warningText.trim() ? sanitizeExecApprovalWarningText(request.warningText) : null; const scope = request.scope ? sanitizeApprovalScope(request.scope) : null; return { kind: "exec", commandText, commandPreview, warningText, host: sanitizeOptionalSingleLine(request.host), nodeId: sanitizeOptionalSingleLine(request.nodeId), agentId: sanitizeOptionalSingleLine(request.agentId), ...scope ? { scope } : {}, allowedDecisions: normalizeDecisionList(params.allowedDecisions) }; } function buildPluginApprovalPresentation(params) { if (!isRecord(params.request)) return null; const request = params.request; const rawTitle = normalizeOptionalString(request.title); const rawDescription = normalizeOptionalString(request.description); if (!rawTitle || !rawDescription) return null; const title = sanitizeExecApprovalDisplayText(rawTitle); const description = sanitizeExecApprovalWarningText(rawDescription); if (exceedsApprovalTextLimit(title, 80) || exceedsApprovalTextLimit(description, 512)) return null; const severity = request.severity === "info" || request.severity === "warning" || request.severity === "critical" ? request.severity : "warning"; const rawDetail = normalizeOptionalString(request.detail); const detail = rawDetail ? truncatePluginApprovalDetail(sanitizeExecApprovalWarningText(rawDetail)) : null; const scope = request.scope ? sanitizeApprovalScope(request.scope) : null; let externalResolution; try { externalResolution = normalizePluginExternalResolution(request.externalResolution); } catch { return null; } return { kind: "plugin", title, description, ...detail ? { detail } : {}, severity, pluginId: sanitizeOptionalSingleLine(request.pluginId), toolName: sanitizeOptionalSingleLine(request.toolName), agentId: sanitizeOptionalSingleLine(request.agentId), ...scope ? { scope } : {}, allowedDecisions: normalizeDecisionList(params.allowedDecisions), ...externalResolution ? { externalResolution: { label: externalResolution.label, decisions: [...externalResolution.decisions ?? ["allow-once"]] } } : {} }; } function buildSystemAgentApprovalPresentation(params) { if (!isRecord(params.request)) return null; const request = params.request; const title = normalizeOptionalString(request.title); const description = normalizeOptionalString(request.description); if (!title || !description || !/^[a-f0-9]{64}$/.test(request.proposalHash)) return null; return { kind: "system-agent", title: truncateUtf16Safe(sanitizeExecApprovalDisplayText(title), 80), description: truncateUtf16Safe(sanitizeExecApprovalWarningText(description), 512), proposalHash: request.proposalHash, agentId: sanitizeOptionalSingleLine(request.agentId), allowedDecisions: ["allow-once", "deny"] }; } /** Returns the safe cross-surface presentation, or null when no prompt can be rendered. */ function buildApprovalPresentation(params) { if (params.kind === "exec") return buildExecApprovalPresentation(params); return params.kind === "plugin" ? buildPluginApprovalPresentation(params) : buildSystemAgentApprovalPresentation(params); } //#endregion //#region src/gateway/exec-approval-manager.ts const EXPLICIT_APPROVAL_ID_INVALID_CHAR_PATTERN = /[^A-Za-z0-9._:-]/; /** Typed creation failure for an explicit approval id outside the shared safe format. */ var InvalidApprovalIdError = class extends Error { constructor() { super("approval id must be 1-128 characters using only letters, numbers, '.', '_', ':', or '-', and cannot be '.' or '..'"); this.code = "EXEC_APPROVAL_ID_INVALID"; this.reason = "INVALID_APPROVAL_ID"; this.name = "InvalidApprovalIdError"; } }; function readRequestString(request, key) { const value = asOptionalObjectRecord(request)?.[key]; return typeof value === "string" && value.trim() ? value.trim() : null; } /** Approval creation and persistence precede every local wait or delivery handoff. */ var ExecApprovalManager = class extends ExecApprovalLifecycle { constructor(options) { super(); this.options = options; } get approvalKind() { return this.options.approvalKind ?? "exec"; } get runtimeEpoch() { return this.options.persistence.runtimeEpoch; } resolveApprovalSource(request) { return { agentId: readRequestString(request, "agentId"), sessionKey: readRequestString(request, "sessionKey"), sessionId: readRequestString(request, "sessionId"), runId: readRequestString(request, "runId"), toolCallId: readRequestString(request, "toolCallId"), toolName: readRequestString(request, "toolName") }; } allowedDecisionsForRequest(request) { const decisions = this.options.resolveAllowedDecisions?.(request); const normalized = []; for (const decision of decisions ?? [ "allow-once", "allow-always", "deny" ]) if ((decision === "allow-once" || decision === "allow-always" || decision === "deny") && !normalized.includes(decision)) normalized.push(decision); if (!normalized.includes("deny")) normalized.push("deny"); return normalized; } create(request, timeoutMs, id) { this.assertNotRetired(); const now = Date.now(); const resolvedTimeoutMs = resolveTimerTimeoutMs(timeoutMs, 1); const expiresAtMs = resolveExpiresAtMsFromDurationMs(resolvedTimeoutMs, { nowMs: now }); if (expiresAtMs === void 0) throw new Error("approval expiry is unavailable"); const hasExplicitId = id !== null && id !== void 0 && id.length > 0; if (hasExplicitId && (id.length > 128 || id === "." || id === ".." || EXPLICIT_APPROVAL_ID_INVALID_CHAR_PATTERN.test(id))) throw new InvalidApprovalIdError(); return { id: hasExplicitId ? id : randomUUID(), request, createdAtMs: now, expiresAtMs }; } /** Synchronously persists/registers the request before returning its authority promise. */ register(record, _timeoutMs) { this.assertNotRetired(); if (record.agentRuntimeDelegatedAuthority && this.options.validateAgentRuntimeDelegatedAuthority?.(record.agentRuntimeDelegatedAuthority) !== true) throw new Error("agent runtime approval authority is no longer active"); if (record.approvalAuthority && record.approvalAuthority() === false) throw new Error("approval authority is no longer active"); const persistence = this.options.persistence; const presentation = buildApprovalPresentation({ kind: this.approvalKind, request: record.request, allowedDecisions: this.allowedDecisionsForRequest(record.request) }); if (!presentation) throw new Error("approval cannot be persisted without a valid reviewer presentation"); const existing = this.pending.get(record.id); if (existing) { if (existing.record.resolvedAtMs === void 0) return existing.promise; throw new Error(`approval id '${record.id}' already resolved`); } const source = this.resolveApprovalSource(record.request); let audienceSessionKeys = []; if (source.sessionKey) audienceSessionKeys = this.options.resolveAudienceSessionKeys?.(source.sessionKey, source.agentId) ?? [source.sessionKey]; const inserted = insertOperatorApproval({ approval: { id: record.id, kind: this.approvalKind, presentation, requester: { deviceId: record.requestedByDeviceId, clientId: record.requestedByClientId, deviceTokenAuth: record.requestedByDeviceTokenAuth === true }, reviewerDeviceIds: record.approvalReviewerDeviceIds, source, audienceSessionKeys, runtimeEpoch: persistence.runtimeEpoch, createdAtMs: record.createdAtMs, expiresAtMs: record.expiresAtMs, ...record.executionIdentityToken ? { executionIdentityToken: record.executionIdentityToken } : {} }, databaseOptions: persistence.databaseOptions }); if (inserted.outcome === "conflict") throw new Error(`approval id '${record.id}' conflicts with persisted state`); const promise = this.registerEntry(record); for (const signal of record.approvalSignals ?? []) { if (signal.aborted) { this.forceDenyIfRuntimeAuthorityClosed(record.id); continue; } signal.addEventListener("abort", () => { const closed = this.forceDenyIfRuntimeAuthorityClosed(record.id); if (closed?.outcome === "denied" && closed.liveRecord) this.options.onExpired?.(closed.record, closed.liveRecord); }, { once: true }); } if (inserted.outcome === "inserted") this.emitLifecycle({ phase: "pending", record: inserted.record }); return promise; } isRuntimeAuthorityActive(record) { const delegated = record.agentRuntimeDelegatedAuthority; if (delegated && this.options.validateAgentRuntimeDelegatedAuthority?.(delegated) !== true) return false; if (record.approvalSignals?.some((signal) => signal.aborted)) return false; try { return record.approvalAuthority?.() !== false; } catch { return false; } } emitLifecycle(event) { try { this.options.onLifecycle?.(event); } catch {} } /** Persist the first verdict, then release the process-local waiter. */ resolveDetailed(recordId, decision, resolver, localResolvedBy = null, localResolutionSource = "operator", options = {}) { if (this.retired) return { outcome: "not-found" }; if (decision !== "deny") { const closed = this.forceDenyIfRuntimeAuthorityClosed(recordId); if (closed) { if (closed.outcome === "not-found" || closed.outcome === "corrupt") return closed; return { outcome: "already-resolved", retry: "conflict", record: closed.record, ...closed.liveRecord ? { liveRecord: closed.liveRecord } : {} }; } } const persistence = this.options.persistence; const localEntry = this.pending.get(recordId); if (localEntry?.record.terminalReason === "storage-corrupt") { const repaired = this.persistStorageCorruptDeny(recordId); if (repaired.outcome === "expired") return repaired; if (repaired.outcome === "not-found" || repaired.outcome === "corrupt") return repaired; if (repaired.outcome === "denied" && decision === "deny") return { outcome: "resolved", record: repaired.record, ...repaired.liveRecord ? { liveRecord: repaired.liveRecord } : {} }; return { outcome: "already-resolved", retry: repaired.record.decision === decision ? "same" : "conflict", record: repaired.record, ...repaired.liveRecord ? { liveRecord: repaired.liveRecord } : {} }; } if (decision !== "deny" && !localEntry) return { outcome: "not-found" }; let standingGrantSpec = decision === "allow-always" && localEntry ? this.options.resolveStandingGrantMint?.(localEntry.record.request) ?? void 0 : void 0; if (standingGrantSpec?.kind === "mcp-tool" && localEntry?.record.mcpToolApprovalActive?.() !== true) standingGrantSpec = void 0; const standingGrant = standingGrantSpec ? { ...standingGrantSpec, expiresAtMs: options.grantExpiresAtMs !== void 0 ? options.grantExpiresAtMs : this.options.resolveStandingGrantExpiresAtMs?.(Date.now()) ?? null } : void 0; let result; try { result = resolveOperatorApproval({ id: recordId, decision, resolver, expectedKind: this.approvalKind, runtimeEpoch: persistence.runtimeEpoch, databaseOptions: persistence.databaseOptions, ...standingGrant?.kind === "cron" ? { standingGrant } : {}, ...standingGrant?.kind === "mcp-tool" ? { mcpToolGrant: standingGrant } : {} }); } catch (error) { this.settleLocalStorageFailure(recordId); throw error; } if (result.outcome === "resolved" && standingGrant?.kind === "placement") this.options.retainPlacementStandingGrant?.({ ...standingGrant, approvalId: recordId, nowMs: result.record.resolvedAtMs ?? Date.now() }); if (result.outcome === "resolved" || result.outcome === "expired" || result.outcome === "already-resolved") this.settleLocalFromStore(result.record, void 0, localResolvedBy, result.outcome === "resolved" ? localResolutionSource : "operator"); else if (result.outcome === "not-found" || result.outcome === "corrupt") this.settleLocalStorageFailure(recordId); return "record" in result && localEntry ? { ...result, liveRecord: localEntry.record } : result; } /** Persist a fail-closed terminal state, then release the local waiter. */ forceDenyDetailed(recordId, reason, resolver, status = "denied", localDecision, requireDue = false, localResolvedBy = null) { if (this.retired) return { outcome: "not-found" }; const persistence = this.options.persistence; const localRecord = this.pending.get(recordId)?.record; if (localRecord?.terminalReason === "storage-corrupt") return this.persistStorageCorruptDeny(recordId); let result; try { result = forceDenyOperatorApproval({ id: recordId, status, requireDue, reason, resolver, expectedKind: this.approvalKind, runtimeEpoch: persistence.runtimeEpoch, databaseOptions: persistence.databaseOptions }); } catch (error) { this.settleLocalStorageFailure(recordId); throw error; } if (result.outcome === "denied") this.settleLocalFromStore(result.record, localDecision, localResolvedBy); else if (result.outcome === "expired" || result.outcome === "already-terminal") this.settleLocalFromStore(result.record, void 0, localResolvedBy); else if (result.outcome === "not-found" || result.outcome === "corrupt") this.settleLocalStorageFailure(recordId); return "record" in result && localRecord ? { ...result, liveRecord: localRecord } : result; } settleLocalFromStore(record, localDecision, localResolvedBy = null, localResolutionSource = "operator") { const persistence = this.options.persistence; const liveRecord = this.pending.get(record.id)?.record; if (record.kind !== this.approvalKind || record.runtimeEpoch !== persistence.runtimeEpoch || record.status === "pending" || record.resolvedAtMs === null) return false; const decision = localDecision === void 0 ? record.status === "allowed" || record.status === "denied" ? record.decision : null : localDecision; const settled = this.settleLocalEntry({ recordId: record.id, decision, resolvedAtMs: record.resolvedAtMs, resolvedBy: localResolvedBy, resolverKind: record.resolver?.kind ?? null, status: record.status, terminalReason: record.terminalReason, consumedAtMs: record.consumedAtMs, consumedBy: record.consumedBy, resolutionSource: localResolutionSource }); if (settled) { this.emitLifecycle({ phase: "terminal", record }); if (record.status === "expired" && liveRecord) try { this.options.onExpired?.(record, liveRecord); } catch (error) { this.reportError(error, { approvalId: record.id, operation: "expire" }); } } return settled; } /** Settle one durable terminal transition and report whether this manager published it. */ reconcileDurableTerminal(record) { return this.settleLocalFromStore(record); } /** Reconciles durable truth with an existing waiter without rehydrating its request. */ reconcileDurableLookup(lookup, localResolvedBy = null) { if (this.retired) return null; const recordId = lookup.outcome === "found" ? lookup.record.id : lookup.id; const entry = this.pending.get(recordId); if (lookup.outcome !== "found") { if (entry) this.settleLocalStorageFailure(recordId); return null; } const persistence = this.options.persistence; if (!entry || lookup.record.kind !== this.approvalKind || lookup.record.runtimeEpoch !== persistence.runtimeEpoch) return lookup.record; if (lookup.record.status === "pending" && entry.record.terminalReason === "storage-corrupt") { const repaired = this.persistStorageCorruptDeny(recordId); return "record" in repaired ? repaired.record : null; } if (lookup.record.status !== "pending") this.settleLocalFromStore(lookup.record, void 0, localResolvedBy); return lookup.record; } settleLocalStorageFailure(recordId) { this.settleLocalEntry({ recordId, decision: "deny", resolvedAtMs: Date.now(), resolvedBy: "storage-error", resolverKind: "system", status: "denied", terminalReason: "storage-corrupt", retainForManagerLifetime: true }); } persistStorageCorruptDeny(recordId) { const localEntry = this.pending.get(recordId); const persistence = this.options.persistence; if (!localEntry) return { outcome: "not-found" }; const result = forceDenyOperatorApproval({ id: recordId, status: "denied", reason: "storage-corrupt", resolver: { kind: "system", id: "storage-error" }, expectedKind: this.approvalKind, runtimeEpoch: persistence.runtimeEpoch, databaseOptions: persistence.databaseOptions }); if (result.outcome === "denied" || result.outcome === "expired") this.emitLifecycle({ phase: "terminal", record: result.record }); return "record" in result ? { ...result, liveRecord: localEntry.record } : result; } reportError(error, context) { const onError = this.options.onError; if (!onError) return; try { onError(error instanceof Error ? error : new Error(String(error)), { ...context, approvalKind: this.approvalKind }); } catch {} } expireDue(recordId) { if (this.retired) return false; const entry = this.pending.get(recordId); if (!entry || entry.record.resolvedAtMs !== void 0) return false; const result = this.forceDenyDetailed(recordId, "timeout", { kind: "system", id: null }, "expired", void 0, true); if (result.outcome === "not-due") { this.scheduleExpiryTimer(entry); return false; } return result.outcome === "denied" || result.outcome === "expired"; } resolve(recordId, decision, resolvedBy, options = {}) { return this.resolveDetailed(recordId, decision, { kind: "runtime", id: resolvedBy ?? null }, resolvedBy ?? null, "operator", options).outcome === "resolved"; } /** * Trusted auto-review resolution (identity-matched approval runtime). * Always allow-once; system.run replay validation treats the resulting * record more strictly than an operator decision (see #103515). */ resolveAutoReview(recordId, resolvedBy) { return this.resolveDetailed(recordId, "allow-once", { kind: "runtime", id: resolvedBy ?? null }, resolvedBy ?? null, "auto-review").outcome === "resolved"; } /** * One-shot ask-fallback re-admission for a timed-out approval. This is * pre-gate policy on the process-local record only: the durable row stays * `expired` and no execution authority is minted here. The shipped askFallback * policy (docs/tools/exec-approvals.md) still applies; system.run replay * uses this flag to keep re-admission single-use. */ consumeAskFallback(recordId) { const entry = this.pending.get(recordId); if (!entry) return false; const record = entry.record; if (record.resolvedAtMs === void 0 || record.decision !== void 0 || record.consumedDecision !== void 0 || record.askFallbackConsumed === true || record.status !== "expired" && record.terminalReason !== "no-route") return false; record.askFallbackConsumed = true; return true; } expire(recordId, resolvedBy) { const noRoute = resolvedBy === "no-approval-route"; return this.forceDenyDetailed(recordId, noRoute ? "no-route" : "timeout", { kind: "system", id: resolvedBy ?? null }, noRoute ? "denied" : "expired", noRoute ? null : void 0, false, resolvedBy ?? null).outcome === "denied"; } consumeAllowOnce(recordId, consumerId = recordId) { if (!this.canUseRetainedBinding() || this.forceDenyIfRuntimeAuthorityClosed(recordId)) return false; const entry = this.pending.get(recordId); if (!entry) return false; const nowMs = Date.now(); const resolvedAtMs = entry.record.resolvedAtMs; const graceAnchorMs = this.resolvedGraceAnchorMs(entry, nowMs); if (resolvedAtMs === void 0 || graceAnchorMs === null || nowMs - graceAnchorMs >= 15e3 || entry.record.decision !== "allow-once" || entry.record.consumedDecision) return false; const persistence = this.options.persistence; const result = consumeOperatorApprovalAllowOnce({ id: recordId, consumerId, expectedKind: this.approvalKind, runtimeEpoch: persistence.runtimeEpoch, redemptionWindowMs: EXEC_APPROVAL_RESOLVED_ENTRY_GRACE_MS + Math.max(0, graceAnchorMs - resolvedAtMs), databaseOptions: persistence.databaseOptions }); if (result.outcome !== "consumed") return false; entry.record.consumedDecision = "allow-once"; entry.record.consumedAtMs = result.record.consumedAtMs; entry.record.consumedBy = result.record.consumedBy; return true; } /** Observes a registered decision; Gateway closure rejects the wait, not the approval. */ awaitDecision(recordId) { this.assertNotRetired(); this.forceDenyIfRuntimeAuthorityClosed(recordId); if (!this.getSnapshot(recordId)) return null; const entry = this.pending.get(recordId); return entry ? this.observeEntry(entry, entry.promise) : null; } /** Projects an allowed decision only while its exact runtime authority is live. */ projectDecisionIfActive(recordId, decision) { if (decision !== "allow-once" && decision !== "allow-always") return decision; if (!this.canUseRetainedBinding()) return null; const record = this.pending.get(recordId)?.record; if (!record) return null; if (this.isRuntimeAuthorityActive(record)) return decision; this.forceDenyIfRuntimeAuthorityClosed(recordId); return null; } /** Atomically closes a live approval whose exact runtime owner is gone. */ forceDenyIfRuntimeAuthorityClosed(recordId) { const record = this.pending.get(recordId)?.record; if (!record || this.isRuntimeAuthorityActive(record)) return null; return this.forceDenyDetailed(recordId, "run-aborted", { kind: "system", id: null }, "cancelled"); } }; //#endregion export { InvalidApprovalIdError as n, ExecApprovalManager as t };