diff --git a/src/lib/actions/sandbox/mcp-bridge-add-restart.ts b/src/lib/actions/sandbox/mcp-bridge-add-restart.ts index b5e189826a8..f12d6b83101 100644 --- a/src/lib/actions/sandbox/mcp-bridge-add-restart.ts +++ b/src/lib/actions/sandbox/mcp-bridge-add-restart.ts @@ -40,10 +40,12 @@ import { detachProvider, ensureMcpBridgeProviderProfile, inspectMcpProvider, + isProviderPolicyReceiptBoundaryRefusalError, type McpCredentialRevisionObservation, observeMcpCredentialRevision, providerMatchesCredential, providerShapeDetail, + reconcileIncompleteAddProviderAttachment, refreshMcpProviderEnvironment, upsertMcpProvider, waitForAttachedMcpCredential, @@ -314,7 +316,11 @@ async function addMcpBridgeUnlocked( // persisting ownership or mutating a provider, policy, or adapter. assertMcpCredentialBoundaryRuntimeVersion(); await ensureSandboxGatewaySelected(sandboxName); + const addPolicyOperation = `add MCP server '${options.server}'`; + let addPolicyAuthority: policies.PolicyMutationAuthority | undefined; if (!existingEntry) { + addPolicyAuthority = policies.inspectPolicyMutationAuthority(sandboxName, addPolicyOperation); + policies.assertNemoClawManagedPolicy(addPolicyAuthority, addPolicyOperation); await withMcpCredentialOwnershipLock(() => { // Publish the durable MCP reservation under the same cross-command lock // used by credentials add. Neither command can pass its collision check @@ -373,6 +379,13 @@ async function addMcpBridgeUnlocked( // may therefore reuse only missing or exact resources, never drift. writeBridgeEntry(sandboxName, entry); } + if (resumingPreflightedAdd) { + reconcileIncompleteAddProviderAttachment(sandboxName, entry); + } + if (!addPolicyAuthority) { + addPolicyAuthority = policies.inspectPolicyMutationAuthority(sandboxName, addPolicyOperation); + policies.assertNemoClawManagedPolicy(addPolicyAuthority, addPolicyOperation); + } const adapterInspection = inspectAgentAdapterRegistration(sandboxName, adapter, entry); if ( adapterInspection.state !== "absent" && @@ -495,9 +508,11 @@ async function addMcpBridgeUnlocked( credentialRevision, }); if (adapter === "hermes-config") assertHermesMcpRuntimeIntent(sandboxName); + policies.recheckPolicyMutationAuthority(sandboxName, addPolicyOperation, addPolicyAuthority); const { addState: _completedAddState, ...committedEntry } = entry; writeBridgeEntry(sandboxName, committedEntry); } catch (error) { + const preserveProviderAttachment = isProviderPolicyReceiptBoundaryRefusalError(error); const rollbackProviderInspection = (providerAttachAttempted || providerCreated) && entry.providerId ? inspectMcpProvider(providerName) @@ -515,11 +530,14 @@ async function addMcpBridgeUnlocked( if (policyApplied) { removeGeneratedPolicy(sandboxName, entry, { bestEffort: true }); } - const detachOutcome = providerAttachAttempted - ? detachProvider(sandboxName, entry, { bestEffort: true }) - : "absent"; - let reservationCleanupProved = !providerAttachAttempted; - if (providerAttachAttempted && detachOutcome !== "unknown") { + const detachOutcome = + providerAttachAttempted && !preserveProviderAttachment + ? detachProvider(sandboxName, entry, { bestEffort: true }) + : preserveProviderAttachment + ? "unknown" + : "absent"; + let reservationCleanupProved = !providerAttachAttempted && !preserveProviderAttachment; + if (providerAttachAttempted && !preserveProviderAttachment && detachOutcome !== "unknown") { try { waitForDetachedMcpCredential(sandboxName, entry); reservationCleanupProved = true; diff --git a/src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts b/src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts index 67bd2660d55..ad67e308968 100644 --- a/src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts @@ -9,6 +9,7 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { buildManagedMcpPolicyReceiptFixture } from "../../../../test/helpers/mcp-policy-receipt-process-fixture"; import { isTrustedPrivateEndpointCapability } from "../../security/trusted-private-endpoint"; import { addMcpBridge, normalizeMcpServerUrl } from "./mcp-bridge"; import { @@ -166,7 +167,7 @@ const replace = (module, name, value) => Object.defineProperty(module, name, { configurable: true, enumerable: true, value, writable: true, }); const registry = require("./src/lib/state/registry.js"); -const policies = require("./src/lib/policy/index.js"); +${buildManagedMcpPolicyReceiptFixture()} const adapters = require("./src/lib/actions/sandbox/mcp-bridge-adapters.js"); const policy = require("./src/lib/actions/sandbox/mcp-bridge-policy.js"); const provider = require("./src/lib/actions/sandbox/mcp-bridge-provider.js"); diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-attachments.ts b/src/lib/actions/sandbox/mcp-bridge-provider-attachments.ts index eefa0691d02..0ec395dad91 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-attachments.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-attachments.ts @@ -7,8 +7,11 @@ * this compensation until attachment mutations expose an immutable-ID CAS API. */ +import { isDeepStrictEqual } from "node:util"; import { stripAnsi } from "../../adapters/openshell/client"; +import { captureSandboxBasePolicy } from "../../adapters/openshell/policy-authority"; import { runOpenshellProviderCommand } from "../../adapters/openshell/provider-command"; +import * as policies from "../../policy"; import type { McpBridgeEntry } from "../../state/registry"; import { McpBridgeError } from "./mcp-bridge-contracts"; import { commandOutput, type OpenShellCommandResult } from "./mcp-bridge-output"; @@ -52,8 +55,51 @@ function attachmentMatchesCurrentProviderSnapshot( ); } -export function attachProvider(sandboxName: string, entry: McpBridgeEntry): void { - if (!entry.providerName) return; +type ProviderPolicyMutationAction = "attach" | "detach"; + +interface ProviderPolicyReceiptMutation { + authority: policies.PolicyMutationAuthority; + basePolicy: string; +} + +class ProviderPolicyReceiptBoundaryRefusalError extends McpBridgeError { + readonly preserveProviderAttachment = true as const; + + constructor(message: string) { + super(message); + this.name = "ProviderPolicyReceiptBoundaryRefusalError"; + } +} + +export function isProviderPolicyReceiptBoundaryRefusalError( + error: unknown, +): error is ProviderPolicyReceiptBoundaryRefusalError { + return error instanceof ProviderPolicyReceiptBoundaryRefusalError; +} + +function authorityFromCompensationBoundary( + boundary: policies.ManagedPolicyCompensationBoundary, +): policies.PolicyMutationAuthority { + return { + authority: "nemoclaw-managed", + authorityRecordedNow: false, + gatewayName: boundary.gatewayName, + inspection: { ...boundary.inspection, authority: "nemoclaw-managed" }, + policyCreationReceipt: boundary.policyCreationReceipt, + }; +} + +function rollbackFailure(operation: string, primaryError: unknown, rollbackError: unknown): Error { + const primary = primaryError instanceof Error ? primaryError.message : String(primaryError); + const rollback = rollbackError instanceof Error ? rollbackError.message : String(rollbackError); + return new McpBridgeError( + `${operation} changed the sandbox before its policy receipt could be completed (${primary}). ` + + `The exact provider-attachment compensation also failed (${rollback}). The managed MCP transaction remains incomplete.`, + ); +} + +function attachProviderExact(sandboxName: string, entry: McpBridgeEntry): boolean { + if (!entry.providerName) return false; assertAuthenticatedBridgeEntry(entry); if (!entry.providerId) { throw new McpBridgeError( @@ -74,6 +120,19 @@ export function attachProvider(sandboxName: string, entry: McpBridgeEntry): void if (!inspection.id || !inspection.resourceVersion) { throw new McpBridgeError(`OpenShell provider '${entry.providerName}' has incomplete metadata.`); } + const before = exactAttachment(sandboxName, entry); + if (!before.inspection.attachments) { + throw new McpBridgeError( + before.inspection.error ?? `Could not inspect provider attachment '${entry.providerName}'.`, + ); + } + const attachmentAlreadyExact = attachmentMatchesCurrentProviderSnapshot(before.attachment, entry); + if (before.attachment && !attachmentAlreadyExact) { + throw new McpBridgeError( + `Provider attachment '${entry.providerName}' does not match MCP server '${entry.server}'. Expected stable provider ID '${entry.providerId}', found '${before.attachment.providerId ?? "missing"}', with credential keys '${before.attachment.credentialKeys.join(", ") || "none"}'.`, + ); + } + if (attachmentAlreadyExact) return false; const result = runOpenshellProviderCommand( ["sandbox", "provider", "attach", sandboxName, entry.providerName], { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, @@ -81,7 +140,9 @@ export function attachProvider(sandboxName: string, entry: McpBridgeEntry): void if (result.status !== 0) { const output = commandOutput(result); const afterError = exactAttachment(sandboxName, entry); - if (attachmentMatchesCurrentProviderSnapshot(afterError.attachment, entry)) return; + if (attachmentMatchesCurrentProviderSnapshot(afterError.attachment, entry)) { + return true; + } throw new McpBridgeError( output || afterError.inspection.error || @@ -95,6 +156,16 @@ export function attachProvider(sandboxName: string, entry: McpBridgeEntry): void `OpenShell did not persist the expected provider identity and credential shape for '${entry.providerName}' after attach.`, ); } + return true; +} + +export function attachProvider(sandboxName: string, entry: McpBridgeEntry): void { + if (!entry.providerName) return; + const operation = `attach MCP provider '${entry.providerName}'`; + const mutation = prepareProviderPolicyReceiptMutation(sandboxName, operation); + const attachmentChanged = attachProviderExact(sandboxName, entry); + if (!attachmentChanged) return; + finishProviderPolicyReceiptMutation(sandboxName, entry, "attach", operation, mutation, true); } export function providerDetachChangedState(status: number | null, output: string): boolean { @@ -117,7 +188,7 @@ function isRetryableSandboxMutationConflict(status: number | null, output: strin ); } -export function detachProvider( +function detachProviderExact( sandboxName: string, entry: McpBridgeEntry, options: { allowLegacyGeneric?: boolean; bestEffort?: boolean } = {}, @@ -167,7 +238,7 @@ export function detachProvider( const output = commandOutput(result); const after = exactAttachment(sandboxName, entry); if (after.inspection.attachments && !after.attachment) { - return providerDetachChangedState(result.status, output) ? "detached" : "absent"; + return "detached"; } if ( attempt + 1 < MCP_PROVIDER_DETACH_ATTEMPTS && @@ -187,6 +258,177 @@ export function detachProvider( return "unknown"; } +function compensateExactAttachedProvider( + sandboxName: string, + entry: McpBridgeEntry, + operation: string, + primaryError: unknown, + boundary: policies.ManagedPolicyCompensationBoundary, + expectedReceipt: policies.PolicyMutationAuthority["policyCreationReceipt"], +): policies.PolicyMutationAuthority { + if ( + expectedReceipt != null && + !isDeepStrictEqual(boundary.policyCreationReceipt, expectedReceipt) + ) { + throw new ProviderPolicyReceiptBoundaryRefusalError( + `${operation} changed the provider attachment, but the durable policy receipt changed before exact compensation. The managed MCP transaction remains incomplete.`, + ); + } + const observed = exactAttachment(sandboxName, entry); + if (!observed.inspection.attachments) { + throw new McpBridgeError( + observed.inspection.error ?? + `Could not inspect provider attachment '${entry.providerName}' for exact compensation.`, + ); + } + const exact = attachmentMatchesCurrentProviderSnapshot(observed.attachment, entry); + if (observed.attachment && !exact) { + throw new McpBridgeError( + `Provider attachment '${entry.providerName}' changed before exact compensation.`, + ); + } + if (!exact) throw primaryError; + const outcome = detachProviderExact(sandboxName, entry); + if (outcome !== "detached" && outcome !== "absent") { + throw new McpBridgeError( + `Could not confirm exact compensation for provider attachment '${entry.providerName}'.`, + ); + } + return policies.recheckPolicyMutationAuthority( + sandboxName, + operation, + authorityFromCompensationBoundary(boundary), + ); +} + +function prepareProviderPolicyReceiptMutation( + sandboxName: string, + operation: string, +): ProviderPolicyReceiptMutation { + const authority = policies.inspectPolicyMutationAuthority(sandboxName, operation); + policies.assertNemoClawManagedPolicy(authority, operation); + const basePolicy = captureSandboxBasePolicy(sandboxName, authority.gatewayName); + policies.recheckPolicyMutationAuthority(sandboxName, operation, authority); + return { authority, basePolicy }; +} + +function providerMutationMatches( + sandboxName: string, + entry: McpBridgeEntry, + action: ProviderPolicyMutationAction, +): boolean { + const observed = exactAttachment(sandboxName, entry); + if (!observed.inspection.attachments) return false; + const exact = attachmentMatchesCurrentProviderSnapshot(observed.attachment, entry); + if (observed.attachment && !exact) return false; + return action === "attach" ? exact : !observed.attachment; +} + +function finishProviderPolicyReceiptMutation( + sandboxName: string, + entry: McpBridgeEntry, + action: ProviderPolicyMutationAction, + operation: string, + mutation: ProviderPolicyReceiptMutation, + compensatePreCasAttach: boolean, +): void { + try { + policies.finalizePolicyMutationReceipt(sandboxName, mutation.basePolicy, mutation.authority); + } catch (error) { + if (policies.isPolicyMutationReceiptFinalVerificationError(error)) { + try { + policies.recheckPolicyMutationAuthority(sandboxName, operation, mutation.authority); + if (providerMutationMatches(sandboxName, entry, action)) return; + } catch { + // The rotated receipt is not coherent. Preserve the incomplete state. + } + throw error; + } + if (!compensatePreCasAttach) throw error; + try { + const boundary = policies.inspectManagedPolicyCompensationBoundary( + sandboxName, + operation, + mutation.authority.gatewayName, + ); + compensateExactAttachedProvider( + sandboxName, + entry, + operation, + error, + boundary, + mutation.authority.policyCreationReceipt, + ); + } catch (rollbackError) { + if (isProviderPolicyReceiptBoundaryRefusalError(rollbackError)) throw rollbackError; + throw rollbackFailure(operation, error, rollbackError); + } + throw error; + } +} + +/** + * Undo an exact attachment left by an interrupted incomplete add before the + * normal add path reads or mutates receipt-bound policy state. + */ +export function reconcileIncompleteAddProviderAttachment( + sandboxName: string, + entry: McpBridgeEntry, +): void { + if (entry.addState !== "preflighted" || !entry.providerName || !entry.providerId) return; + const operation = `resume MCP provider '${entry.providerName}' attachment`; + try { + const authority = policies.inspectPolicyMutationAuthority(sandboxName, operation); + policies.assertNemoClawManagedPolicy(authority, operation); + } catch (error) { + const boundary = policies.inspectManagedPolicyCompensationBoundary(sandboxName, operation); + if ( + boundary.policyCreationReceipt.policyHash === boundary.inspection.policyIdentity.hash && + boundary.policyCreationReceipt.policyVersion === + boundary.inspection.policyIdentity.activeVersion + ) { + throw error; + } + try { + compensateExactAttachedProvider( + sandboxName, + entry, + operation, + error, + boundary, + boundary.policyCreationReceipt, + ); + } catch (rollbackError) { + throw rollbackFailure(operation, error, rollbackError); + } + } +} + +export function detachProvider( + sandboxName: string, + entry: McpBridgeEntry, + options: { allowLegacyGeneric?: boolean; bestEffort?: boolean } = {}, +): ProviderDetachOutcome { + if (!entry.providerName) return "absent"; + const operation = `detach MCP provider '${entry.providerName}'`; + let mutation: ProviderPolicyReceiptMutation; + try { + mutation = prepareProviderPolicyReceiptMutation(sandboxName, operation); + } catch (error) { + if (options.bestEffort) return "unknown"; + throw error; + } + const outcome = detachProviderExact(sandboxName, entry, options); + if (outcome !== "detached") return outcome; + try { + finishProviderPolicyReceiptMutation(sandboxName, entry, "detach", operation, mutation, false); + return outcome; + } catch (error) { + if (options.bestEffort) return "unknown"; + throw error; + } +} + /** * Remove a dangling provider name from the sandbox spec after the provider * object itself has been independently proven absent. OpenShell main cannot @@ -199,6 +441,7 @@ export function detachMissingProviderReference( ): ProviderDetachOutcome { if (!entry.providerName) return "absent"; assertPersistedAuthenticatedBridgeEntry(entry); + const operation = `detach missing MCP provider reference '${entry.providerName}'`; const before = inspectMcpProvider(entry.providerName); if (before.exists !== false) { const detail = @@ -209,6 +452,7 @@ export function detachMissingProviderReference( `OpenShell provider '${entry.providerName}' is not provably absent before dangling-reference cleanup: ${detail}.`, ); } + const mutation = prepareProviderPolicyReceiptMutation(sandboxName, operation); const result = runOpenshellProviderCommand( ["sandbox", "provider", "detach", sandboxName, entry.providerName], { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, @@ -232,5 +476,7 @@ export function detachMissingProviderReference( `OpenShell returned an unrecognized result while removing dangling provider reference '${entry.providerName}'.`, ); } - return providerDetachChangedState(result.status, output) ? "detached" : "absent"; + const outcome = providerDetachChangedState(result.status, output) ? "detached" : "absent"; + finishProviderPolicyReceiptMutation(sandboxName, entry, "detach", operation, mutation, false); + return outcome; } diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts b/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts index 575223e5943..1dff6a1dbca 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts @@ -45,7 +45,9 @@ export { attachProvider, detachMissingProviderReference, detachProvider, + isProviderPolicyReceiptBoundaryRefusalError, providerDetachChangedState, + reconcileIncompleteAddProviderAttachment, } from "./mcp-bridge-provider-attachments"; /** diff --git a/src/lib/actions/sandbox/mcp-bridge-provider.ts b/src/lib/actions/sandbox/mcp-bridge-provider.ts index 42b991c6b10..8edc5021c30 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider.ts @@ -30,8 +30,10 @@ export { detachMissingProviderReference, detachProvider, ensureMcpBridgeProviderProfile, + isProviderPolicyReceiptBoundaryRefusalError, refreshMcpProviderEnvironment, providerDetachChangedState, + reconcileIncompleteAddProviderAttachment, upsertMcpProvider, } from "./mcp-bridge-provider-mutation"; export type { McpCredentialRevisionObservation } from "./mcp-bridge-provider-readiness"; diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index fb7447514d9..91ed3498740 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -670,6 +670,34 @@ export interface PolicyMutationAuthority { readonly policyCreationReceipt?: NemoClawPolicyCreationReceipt | null; } +export interface ManagedPolicyCompensationBoundary { + readonly gatewayName: string; + readonly inspection: SandboxPolicyAuthorityInspection; + readonly policyCreationReceipt: NemoClawPolicyCreationReceipt; +} + +/** A receipt CAS succeeded, but its final coherent readback did not. */ +export class PolicyMutationReceiptFinalVerificationError extends PolicyAuthorityRefusalError { + readonly receiptRotation = "committed" as const; + + constructor(message: string, options?: ErrorOptions) { + super(message, "owner-unknown", options); + this.name = "PolicyMutationReceiptFinalVerificationError"; + } +} + +export function isPolicyMutationReceiptFinalVerificationError( + error: unknown, +): error is PolicyMutationReceiptFinalVerificationError { + return ( + error instanceof PolicyMutationReceiptFinalVerificationError || + (typeof error === "object" && + error !== null && + "receiptRotation" in error && + error.receiptRotation === "committed") + ); +} + export const isPolicyAuthorityRefusalError = isAuthorityRefusalError; export const isExternalPolicyAuthorityRefusalError = isExternalAuthorityRefusalError; @@ -917,6 +945,30 @@ export function inspectPolicyMutationAuthority( ); } +/** + * Inspect only the immutable managed sandbox boundary for exact compensation. + * The returned receipt is not asserted to match the live policy hash. + */ +export function inspectManagedPolicyCompensationBoundary( + sandboxName: string, + operation: string, + requestedGatewayName?: string, +): ManagedPolicyCompensationBoundary { + const live = inspectLivePolicyBoundary(sandboxName, operation, requestedGatewayName); + if (live.inspection.authority !== "owner-unknown") { + throw new PolicyAuthorityRefusalError( + `Refusing to ${operation}: OpenShell no longer reports a sandbox-scoped policy.`, + live.inspection.authority, + ); + } + const boundary = managedReceiptSandboxBoundary(live, sandboxName, operation); + return { + gatewayName: live.gatewayName, + inspection: live.inspection, + policyCreationReceipt: boundary.receipt, + }; +} + /** Require the durable policy receipt immediately before a local mutation. */ function preparePolicyMutationAuthority( sandboxName: string, @@ -1189,16 +1241,18 @@ export function finalizePolicyMutationReceipt( ); } - const completed = inspectPolicyMutationAuthority( - sandboxName, - operation, - previous.gatewayName, - true, - ); + let completed: PolicyMutationAuthority; + try { + completed = inspectPolicyMutationAuthority(sandboxName, operation, previous.gatewayName, true); + } catch (error) { + throw new PolicyMutationReceiptFinalVerificationError( + `NemoClaw recorded the updated policy identity for '${sandboxName}', but could not verify it. The policy update remains incomplete.`, + { cause: error }, + ); + } if (!isDeepStrictEqual(completed.policyCreationReceipt, nextReceipt)) { - throw new PolicyAuthorityRefusalError( - `NemoClaw applied the sandbox policy for '${sandboxName}', but could not verify the recorded policy identity. The policy update is incomplete.`, - "owner-unknown", + throw new PolicyMutationReceiptFinalVerificationError( + `NemoClaw recorded the updated policy identity for '${sandboxName}', but the final receipt readback did not match. The policy update remains incomplete.`, ); } } @@ -2112,9 +2166,9 @@ function removePreset( const teamsActive = presetName === "teams" ? false - : getCredentialBoundMessagingChannelsFromEntry( - registry.getSandbox(sandboxName), - ).includes("teams"); + : getCredentialBoundMessagingChannelsFromEntry(registry.getSandbox(sandboxName)).includes( + "teams", + ); updated = reconcileTeamsOutlookLoginCredentialBinding(updated, sandboxName, teamsActive); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/lib/policy/policy-mutation-authority.test.ts b/src/lib/policy/policy-mutation-authority.test.ts index 873953b7381..5ea6f6254ee 100644 --- a/src/lib/policy/policy-mutation-authority.test.ts +++ b/src/lib/policy/policy-mutation-authority.test.ts @@ -54,8 +54,11 @@ import { applyPermissivePolicy, applyPresetContent, excludeBaselineEntry, + finalizePolicyMutationReceipt, + inspectManagedPolicyCompensationBoundary, inspectPolicyMutationAuthority, inspectPolicyRecoveryAuthority, + isPolicyMutationReceiptFinalVerificationError, recheckPolicyMutationAuthority, removePreset, restoreBaselineEntry, @@ -285,6 +288,52 @@ describe("PolicyMutationAuthority", () => { expect(reportedErrors()).toContain("creation receipt does not match the live sandbox policy"); }); + it("exposes only the immutable managed boundary for exact compensation (#9833)", () => { + livePolicyHash = "policy-attachment-drift"; + + expect(() => inspectPolicyMutationAuthority(SANDBOX, "continue MCP add")).toThrow( + /creation receipt does not match the live sandbox policy/u, + ); + expect(inspectManagedPolicyCompensationBoundary(SANDBOX, "compensate MCP attach")).toEqual( + expect.objectContaining({ + gatewayName: "nemoclaw", + policyCreationReceipt: expect.objectContaining({ policyHash: INITIAL_POLICY_HASH }), + }), + ); + }); + + it("types a final receipt readback failure after the CAS succeeds (#9833)", () => { + const previous = inspectPolicyMutationAuthority(SANDBOX, "update MCP attachment"); + livePolicyHash = UPDATED_POLICY_HASH; + mocks.inspectSandboxPolicyAuthority + .mockImplementationOnce(() => ({ + authority: "owner-unknown", + effectivePolicy: {}, + policyIdentity: { hash: livePolicyHash, activeVersion: 1 }, + })) + .mockImplementationOnce(() => ({ + authority: "owner-unknown", + effectivePolicy: {}, + policyIdentity: { hash: livePolicyHash, activeVersion: 1 }, + })) + .mockImplementationOnce(() => { + throw new Error("simulated final receipt readback failure"); + }); + + let observed: unknown; + try { + finalizePolicyMutationReceipt(SANDBOX, BASE_POLICY, previous); + } catch (error) { + observed = error; + } + + expect(isPolicyMutationReceiptFinalVerificationError(observed)).toBe(true); + expect(mocks.compareAndSetSandboxPolicyCreationReceipt).toHaveBeenCalledOnce(); + expect(sandbox.policyCreationReceipt).toEqual( + expect.objectContaining({ policyHash: UPDATED_POLICY_HASH }), + ); + }); + it("refuses live policy drift between inspection and mutation (#9833)", () => { const recorded = inspectPolicyMutationAuthority(SANDBOX, "apply a policy preset"); livePolicyHash = "policy-concurrent-change"; diff --git a/test/helpers/mcp-destroy-lifecycle-fixture.ts b/test/helpers/mcp-destroy-lifecycle-fixture.ts new file mode 100644 index 00000000000..7d2b775599f --- /dev/null +++ b/test/helpers/mcp-destroy-lifecycle-fixture.ts @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { McpBridgeEntry } from "../../src/lib/state/registry"; + +export const mcpDestroyBridgeEntries: Record<"github" | "slack", McpBridgeEntry> = { + github: { + server: "github", + agent: "openclaw", + adapter: "mcporter", + url: "https://8.8.8.8/github", + env: ["GITHUB_TOKEN"], + providerName: "alpha-mcp-github", + providerId: "11111111-2222-4333-8444-555555555555", + policyName: "mcp-bridge-github", + addedAt: "2026-06-27T00:00:00.000Z", + }, + slack: { + server: "slack", + agent: "openclaw", + adapter: "mcporter", + url: "https://8.8.8.8/slack", + env: ["SLACK_TOKEN"], + providerName: "alpha-mcp-slack", + providerId: "66666666-7777-4888-8999-000000000000", + policyName: "mcp-bridge-slack", + addedAt: "2026-06-27T00:00:00.000Z", + }, +}; diff --git a/test/helpers/mcp-policy-receipt-process-fixture.ts b/test/helpers/mcp-policy-receipt-process-fixture.ts new file mode 100644 index 00000000000..b5a4a90a74f --- /dev/null +++ b/test/helpers/mcp-policy-receipt-process-fixture.ts @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +export const MCP_POLICY_BASE = "version: 1\nnetwork_policies:\n baseline: {}\n"; + +export const MCP_POLICY_RECEIPT_AUTHORITY = { + authority: "nemoclaw-managed" as const, + authorityRecordedNow: false, + gatewayName: "nemoclaw", + inspection: { + authority: "nemoclaw-managed" as const, + effectivePolicy: {}, + policyIdentity: { hash: "receipt-bound-policy", activeVersion: 1 }, + }, + policyCreationReceipt: { policyHash: "receipt-bound-policy", policyVersion: 1 }, +}; + +export function readMcpSandboxRegistry(home: string): { + sandboxes: Record< + string, + { mcp?: { bridges: Record }; customPolicies?: unknown[] } + >; +} { + return JSON.parse(fs.readFileSync(path.join(home, ".nemoclaw", "sandboxes.json"), "utf8")) as { + sandboxes: Record< + string, + { mcp?: { bridges: Record }; customPolicies?: unknown[] } + >; + }; +} + +export function buildManagedMcpPolicyReceiptFixture(): string { + return String.raw` +const policyAuthority = require("./src/lib/adapters/openshell/policy-authority.js"); +const policies = require("./src/lib/policy/index.js"); +const receiptAuthority = ${JSON.stringify(MCP_POLICY_RECEIPT_AUTHORITY)}; +policyAuthority.captureSandboxBasePolicy = () => ${JSON.stringify(MCP_POLICY_BASE)}; +policies.inspectPolicyMutationAuthority = () => receiptAuthority; +policies.assertNemoClawManagedPolicy = () => {}; +policies.recheckPolicyMutationAuthority = () => receiptAuthority; +`; +} + +export function buildMcpAddPolicyReceiptFixture(): string { + return String.raw` +const policyAuthority = require("./src/lib/adapters/openshell/policy-authority.js"); +const policies = require("./src/lib/policy/index.js"); +const receiptMutationScenario = + crashAfter === "policy-receipt-after-attach" || + crashAfter === "policy-receipt-finalize-pre-cas-failure" || + crashAfter === "policy-receipt-changed-before-compensation" || + crashAfter === "policy-receipt-finalize-post-cas-failure" || + crashAfter === "policy-receipt-after-attach-kill" || + crashAfter === "final-add-verification-refusal"; +const receiptAuthority = ${JSON.stringify(MCP_POLICY_RECEIPT_AUTHORITY)}; +const rotatedReceiptAuthority = { + ...receiptAuthority, + inspection: { + ...receiptAuthority.inspection, + policyIdentity: { hash: "concurrent-policy", activeVersion: 2 }, + }, + policyCreationReceipt: { policyHash: "concurrent-policy", policyVersion: 2 }, +}; +const inspectReceiptCurrent = () => { + if (crashAfter === "initial-policy-receipt-mismatch" || marked("policy-receipt-mismatch")) { + if (crashAfter === "policy-receipt-changed-before-compensation") { + return rotatedReceiptAuthority; + } + throw new Error("Refusing to mutate managed MCP state: the NemoClaw policy creation receipt does not match the live sandbox policy."); + } + if ( + crashAfter === "incomplete-add-transient-authority-failure" && + !marked("transient-authority-refusal") + ) { + mark("transient-authority-refusal"); + throw new Error("simulated transient policy inspection failure"); + } + return receiptAuthority; +}; +policyAuthority.captureSandboxBasePolicy = () => ${JSON.stringify(MCP_POLICY_BASE)}; +policies.inspectPolicyMutationAuthority = inspectReceiptCurrent; +policies.inspectManagedPolicyCompensationBoundary = () => ({ + gatewayName: crashAfter === "policy-receipt-changed-before-compensation" + ? rotatedReceiptAuthority.gatewayName + : receiptAuthority.gatewayName, + inspection: { + ...(crashAfter === "policy-receipt-changed-before-compensation" + ? rotatedReceiptAuthority.inspection + : receiptAuthority.inspection), + policyIdentity: marked("policy-receipt-mismatch") + ? crashAfter === "policy-receipt-changed-before-compensation" + ? rotatedReceiptAuthority.inspection.policyIdentity + : { hash: "receipt-with-attachment", activeVersion: 2 } + : receiptAuthority.inspection.policyIdentity, + }, + policyCreationReceipt: crashAfter === "policy-receipt-changed-before-compensation" + ? rotatedReceiptAuthority.policyCreationReceipt + : receiptAuthority.policyCreationReceipt, +}); +policies.assertNemoClawManagedPolicy = () => {}; +policies.recheckPolicyMutationAuthority = (_sandboxName, operation) => { + const authority = inspectReceiptCurrent(); + if ( + operation === "add MCP server 'fake'" && + marked("reject-final-add-verification") + ) { + throw new Error("simulated final add policy authority refusal"); + } + return authority; +}; +policies.finalizePolicyMutationReceipt = () => { + if (marked("reject-attachment-finalize-pre-cas")) { + fs.rmSync(marker("reject-attachment-finalize-pre-cas"), { force: true }); + throw new Error("simulated pre-CAS receipt finalization failure"); + } + if (crashAfter === "policy-receipt-after-attach-kill") process.exit(88); + fs.rmSync(marker("policy-receipt-mismatch"), { force: true }); + mark("policy-receipt-finalized"); + if (marked("reject-attachment-finalize-post-cas")) { + fs.rmSync(marker("reject-attachment-finalize-post-cas"), { force: true }); + throw new policies.PolicyMutationReceiptFinalVerificationError( + "simulated post-CAS receipt finalization failure", + ); + } +}; +if (crashAfter === "policy-receipt-finalize-pre-cas-failure") { + mark("reject-attachment-finalize-pre-cas"); +} +if (crashAfter === "policy-receipt-changed-before-compensation") { + mark("reject-attachment-finalize-pre-cas"); +} +if (crashAfter === "policy-receipt-finalize-post-cas-failure") { + mark("reject-attachment-finalize-post-cas"); +} +if (crashAfter === "final-add-verification-refusal") { + mark("reject-final-add-verification"); +} +`; +} + +export function buildMcpRemovePolicyReceiptFixture(): string { + return String.raw`${buildManagedMcpPolicyReceiptFixture()} +policies.finalizePolicyMutationReceipt = () => {}; +`; +} diff --git a/test/helpers/rebuild-recreate-journal-fixture.ts b/test/helpers/rebuild-recreate-journal-fixture.ts new file mode 100644 index 00000000000..7942647be7f --- /dev/null +++ b/test/helpers/rebuild-recreate-journal-fixture.ts @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { vi } from "vitest"; + +import type { RebuildRecreateJournal } from "../../src/lib/actions/sandbox/rebuild-recreate-journal"; + +export function stubRecreateJournal(): RebuildRecreateJournal { + return { + id: "journal-1", + acceptedTarget: false, + sourceConfirmedAbsent: false, + gatewayAuthority: { + gatewayName: "nemoclaw", + gatewayPort: 8080, + mode: "nemoclaw-managed", + source: "standalone", + endpoint: null, + stateDir: null, + supervisor: null, + requiredCapabilities: [], + }, + targetGeneration: "generation-1", + targetIntentFingerprint: "intent-1", + markDeleting: vi.fn(), + observeSourceForDelete: vi.fn(() => "source" as const), + confirmDeleted: vi.fn(), + completeAcceptedTarget: vi.fn(), + }; +} diff --git a/test/mcp/mcp-add-crash-consistency.test.ts b/test/mcp/mcp-add-crash-consistency.test.ts index ba990bb7549..6ea85b76ec8 100644 --- a/test/mcp/mcp-add-crash-consistency.test.ts +++ b/test/mcp/mcp-add-crash-consistency.test.ts @@ -8,6 +8,12 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { + buildMcpAddPolicyReceiptFixture, + buildMcpRemovePolicyReceiptFixture, + readMcpSandboxRegistry, +} from "../helpers/mcp-policy-receipt-process-fixture"; + const MATCHING_OPENSHELL = path.resolve("test/fixtures/openshell-v0.0.106"); type CrashBoundary = @@ -20,6 +26,14 @@ type CrashBoundary = | "credential-projection-coalesced" | "credential-projection-unstable" | "credential-projection-delayed-hostless" + | "policy-receipt-after-attach" + | "policy-receipt-finalize-pre-cas-failure" + | "policy-receipt-changed-before-compensation" + | "policy-receipt-finalize-post-cas-failure" + | "policy-receipt-after-attach-kill" + | "incomplete-add-transient-authority-failure" + | "initial-policy-receipt-mismatch" + | "final-add-verification-refusal" | "registered-credential-collision" | "registered-late-collision" | "adapter" @@ -88,9 +102,9 @@ const registry = require("./src/lib/state/registry.js"); const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); const { mockManagedEndpointlessProviderProfileRun } = require("./test/helpers/onboard-script-mocks.cjs"); const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); -const policies = require("./src/lib/policy/index.js"); const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); const ownershipLocks = require("./src/lib/state/mcp-lifecycle-lock/credential-ownership.js"); +${buildMcpAddPolicyReceiptFixture()} if (crashAfter === "credential-command-race") { const withMcpCredentialOwnershipLock = ownershipLocks.withMcpCredentialOwnershipLock; @@ -221,10 +235,16 @@ providerCommands.runOpenshellProviderCommand = (args) => { observedProviderName = args[4]; attachmentAttemptedThisProcess = true; mark("attached"); + if (receiptMutationScenario) mark("policy-receipt-mismatch"); return { status: 0, stdout: "attached", stderr: "" }; } if (args[0] === "sandbox" && args[1] === "provider" && args[2] === "detach") { + mark("detach-attempted"); fs.rmSync(marker("attached"), { force: true }); + if (marked("policy-receipt-mismatch")) { + fs.rmSync(marker("policy-receipt-mismatch"), { force: true }); + mark("attachment-compensated"); + } return { status: 0, stdout: "Detached provider", stderr: "" }; } if (args[0] === "provider" && args[1] === "delete") { @@ -240,6 +260,12 @@ policies.getPresetContentGatewayState = () => { }; policies.applyPresetContent = () => { if (crashAfter === "policy-failure") return false; + if (receiptMutationScenario && marked("policy-receipt-mismatch")) { + console.error( + "Refusing to apply generated MCP policy: the NemoClaw policy creation receipt does not match the live sandbox policy.", + ); + return false; + } fs.appendFileSync(marker("policy-apply-log"), "apply\n", { mode: 0o600 }); mark("policy"); if (marked("attached")) mark("bound-policy"); @@ -257,6 +283,7 @@ processRecovery.executeSandboxExecCommand = (_sandbox, command) => { const isObservation = proof.includes("printf '%s\\n' absent"); const isPreupdateObservation = isObservation && + includeSecret && providerPresentAtStart && !credentialUpdatedThisProcess && !attachmentAttemptedThisProcess; @@ -520,8 +547,8 @@ let observedProviderName = null; const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); const { mockManagedEndpointlessProviderProfileRun } = require("./test/helpers/onboard-script-mocks.cjs"); const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); -const policies = require("./src/lib/policy/index.js"); const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); +${buildMcpRemovePolicyReceiptFixture()} gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ recovered: true, @@ -529,7 +556,6 @@ gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ before: { state: "healthy_named" }, after: { state: "healthy_named" }, }); - providerCommands.runOpenshellProviderCommand = (args) => { const profileResult = mockManagedEndpointlessProviderProfileRun(args); if (profileResult) return profileResult; @@ -546,6 +572,10 @@ providerCommands.runOpenshellProviderCommand = (args) => { observedProviderName = args[4]; const wasAttached = marked("attached"); fs.rmSync(marker("attached"), { force: true }); + if (marked("policy-receipt-mismatch")) { + fs.rmSync(marker("policy-receipt-mismatch"), { force: true }); + mark("attachment-compensated"); + } return { status: 0, stdout: wasAttached @@ -762,6 +792,175 @@ describe("MCP add crash consistency", () => { } }); + it("rotates the receipt after provider attachment before admitting one concurrent add (#9833)", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-add-receipt-race-")); + try { + initializeSandboxRegistry(home); + const script = buildAddProcessScript(home, "policy-receipt-after-attach"); + const first = spawnScript(home, script); + const second = spawnScript(home, script); + const results = await Promise.all([collectProcess(first), collectProcess(second)]); + const combinedOutput = results + .map((result) => `${result.stdout}\n${result.stderr}`) + .join("\n---\n"); + + expect(results.map((result) => result.status).sort(), combinedOutput).toEqual([0, 2]); + expect(results.find((result) => result.status === 2)?.stderr).toContain("already exists"); + expect(combinedOutput).not.toContain( + "policy creation receipt does not match the live sandbox policy", + ); + expect(readBridge(home).addState).toBeUndefined(); + expect(fs.existsSync(path.join(home, "policy-receipt-finalized.marker"))).toBe(true); + expect(fs.existsSync(path.join(home, "policy-receipt-mismatch.marker"))).toBe(false); + expect(fs.existsSync(path.join(home, "attached.marker"))).toBe(true); + expect(fs.existsSync(path.join(home, "policy.marker"))).toBe(true); + expect(fs.existsSync(path.join(home, "adapter.marker"))).toBe(true); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it("compensates an attachment when receipt rotation fails before CAS and permits retry (#9833)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-add-receipt-rollback-")); + try { + const refused = runAddProcess(home, "policy-receipt-finalize-pre-cas-failure"); + expect(refused.status, `${refused.stdout}\n${refused.stderr}`).toBe(2); + expect(refused.stderr).toContain("simulated pre-CAS receipt finalization failure"); + expect(readBridge(home)).toMatchObject({ addState: "preflighted" }); + expect(fs.existsSync(path.join(home, "policy-receipt-mismatch.marker"))).toBe(false); + expect(fs.existsSync(path.join(home, "attached.marker"))).toBe(false); + expect(fs.existsSync(path.join(home, "attachment-compensated.marker"))).toBe(true); + expect(fs.existsSync(path.join(home, "adapter.marker"))).toBe(false); + + const retried = runAddProcess(home, "policy-receipt-after-attach"); + expect(retried.status, `${retried.stdout}\n${retried.stderr}`).toBe(0); + expect(readBridge(home).addState).toBeUndefined(); + expect(fs.existsSync(path.join(home, "attached.marker"))).toBe(true); + expect(fs.existsSync(path.join(home, "policy.marker"))).toBe(true); + expect(fs.existsSync(path.join(home, "adapter.marker"))).toBe(true); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it("preserves an incomplete attachment when another writer rotates the receipt (#9833)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-add-receipt-writer-")); + try { + const refused = runAddProcess(home, "policy-receipt-changed-before-compensation"); + expect(refused.status, `${refused.stdout}\n${refused.stderr}`).toBe(2); + expect(refused.stderr).toContain("durable policy receipt changed"); + expect(readBridge(home)).toMatchObject({ addState: "preflighted" }); + expect(fs.existsSync(path.join(home, "attached.marker"))).toBe(true); + expect(fs.existsSync(path.join(home, "detach-attempted.marker"))).toBe(false); + expect(fs.existsSync(path.join(home, "attachment-compensated.marker"))).toBe(false); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it("accepts an exact coherent attachment after post-CAS receipt verification fails (#9833)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-add-receipt-post-cas-")); + try { + const result = runAddProcess(home, "policy-receipt-finalize-post-cas-failure"); + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + expect(readBridge(home).addState).toBeUndefined(); + expect(fs.existsSync(path.join(home, "attached.marker"))).toBe(true); + expect(fs.existsSync(path.join(home, "attachment-compensated.marker"))).toBe(false); + expect(fs.existsSync(path.join(home, "policy-receipt-mismatch.marker"))).toBe(false); + expect(fs.existsSync(path.join(home, "policy-receipt-finalized.marker"))).toBe(true); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it("compensates an exact attachment left before receipt rotation and retries (#9833)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-add-receipt-kill-")); + try { + const interrupted = runAddProcess(home, "policy-receipt-after-attach-kill"); + expect(interrupted.status, `${interrupted.stdout}\n${interrupted.stderr}`).toBe(88); + expect(readBridge(home)).toMatchObject({ addState: "preflighted" }); + expect(fs.existsSync(path.join(home, "attached.marker"))).toBe(true); + expect(fs.existsSync(path.join(home, "policy-receipt-mismatch.marker"))).toBe(true); + + const retried = runAddProcess(home, "policy-receipt-after-attach"); + expect(retried.status, `${retried.stdout}\n${retried.stderr}`).toBe(0); + expect(readBridge(home).addState).toBeUndefined(); + expect(fs.existsSync(path.join(home, "attachment-compensated.marker"))).toBe(true); + expect(fs.existsSync(path.join(home, "policy-receipt-mismatch.marker"))).toBe(false); + expect(fs.existsSync(path.join(home, "attached.marker"))).toBe(true); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it("does not detach after a transient recovery inspection when the receipt is coherent (#9833)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-add-transient-receipt-")); + try { + const interrupted = runAddProcess(home, "policy-receipt-after-attach-kill"); + expect(interrupted.status, `${interrupted.stdout}\n${interrupted.stderr}`).toBe(88); + fs.rmSync(path.join(home, "policy-receipt-mismatch.marker"), { force: true }); + + const refused = runAddProcess(home, "incomplete-add-transient-authority-failure"); + expect(refused.status, `${refused.stdout}\n${refused.stderr}`).toBe(2); + expect(refused.stderr).toContain("simulated transient policy inspection failure"); + expect(readBridge(home)).toMatchObject({ addState: "preflighted" }); + expect(fs.existsSync(path.join(home, "attached.marker"))).toBe(true); + expect(fs.existsSync(path.join(home, "attachment-compensated.marker"))).toBe(false); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it("rejects an initial receipt mismatch before reserving MCP state (#9833)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-add-initial-receipt-")); + try { + const refused = runAddProcess(home, "initial-policy-receipt-mismatch"); + expect(refused.status, `${refused.stdout}\n${refused.stderr}`).toBe(2); + expect(refused.stderr).toContain( + "policy creation receipt does not match the live sandbox policy", + ); + const sandbox = readMcpSandboxRegistry(home).sandboxes["crash-test"]; + expect(sandbox.mcp?.bridges ?? {}).toEqual({}); + expect({ + provider: fs.existsSync(path.join(home, "provider.marker")), + attachment: fs.existsSync(path.join(home, "attached.marker")), + policy: fs.existsSync(path.join(home, "policy.marker")), + adapter: fs.existsSync(path.join(home, "adapter.marker")), + }).toEqual({ provider: false, attachment: false, policy: false, adapter: false }); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it("withholds completed state after final authority refusal, retries, and removes cleanly (#9833)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-add-final-receipt-")); + try { + const refused = runAddProcess(home, "final-add-verification-refusal"); + expect(refused.status, `${refused.stdout}\n${refused.stderr}`).toBe(2); + expect(refused.stderr).toContain("simulated final add policy authority refusal"); + expect(readBridge(home)).toMatchObject({ addState: "preflighted" }); + + fs.rmSync(path.join(home, "reject-final-add-verification.marker"), { force: true }); + const retried = runAddProcess(home, "policy-receipt-after-attach"); + expect(retried.status, `${retried.stdout}\n${retried.stderr}`).toBe(0); + expect(readBridge(home).addState).toBeUndefined(); + + const removed = runRemoveProcess(home, false); + expect(removed.status, `${removed.stdout}\n${removed.stderr}`).toBe(0); + const sandbox = readMcpSandboxRegistry(home).sandboxes["crash-test"]; + expect(sandbox.mcp?.bridges ?? {}).toEqual({}); + expect(sandbox.customPolicies ?? []).toEqual([]); + expect({ + provider: fs.existsSync(path.join(home, "provider.marker")), + attachment: fs.existsSync(path.join(home, "attached.marker")), + policy: fs.existsSync(path.join(home, "policy.marker")), + adapter: fs.existsSync(path.join(home, "adapter.marker")), + }).toEqual({ provider: false, attachment: false, policy: false, adapter: false }); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + it("times out without committing an adapter while credential revisions remain unstable (#9764)", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-add-unstable-revision-")); try { diff --git a/test/mcp/mcp-destroy-lifecycle.test.ts b/test/mcp/mcp-destroy-lifecycle.test.ts index 1b86f0fc95f..347f2c74652 100644 --- a/test/mcp/mcp-destroy-lifecycle.test.ts +++ b/test/mcp/mcp-destroy-lifecycle.test.ts @@ -8,8 +8,15 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentMcpAdapter } from "../../src/lib/agent/defs"; import type { McpBridgeEntry } from "../../src/lib/state/registry"; +import { restoreEnv } from "../helpers/env-test-helpers"; +import { mcpDestroyBridgeEntries as bridgeEntries } from "../helpers/mcp-destroy-lifecycle-fixture"; +import { + MCP_POLICY_BASE, + MCP_POLICY_RECEIPT_AUTHORITY, +} from "../helpers/mcp-policy-receipt-process-fixture"; import { findObservedCredentialRevision } from "../helpers/mcp-provider-revision"; import { mockManagedEndpointlessProviderProfileRun } from "../helpers/onboard-script-mocks.cjs"; +import { stubRecreateJournal } from "../helpers/rebuild-recreate-journal-fixture"; const testState = vi.hoisted(() => { const home = `/tmp/nemoclaw-mcp-destroy-${process.pid}-${Date.now()}`; @@ -27,6 +34,7 @@ const testState = vi.hoisted(() => { adapterRegistered: true, applyPresetContent: vi.fn(), calls: [] as string[], + captureSandboxBasePolicy: vi.fn(), captureOpenshell: vi.fn(), executeGatewaySupervisorAction: vi.fn(), executeSandboxCommand: vi.fn(), @@ -35,6 +43,7 @@ const testState = vi.hoisted(() => { failProviderDetach: null as string | null, getLiveSandboxPolicyEntryDigest: vi.fn(), getPresetContentGatewayState: vi.fn(), + inspectPolicyMutationAuthority: vi.fn(), home, originalEnv, policyApplyCalls: 0, @@ -47,6 +56,8 @@ const testState = vi.hoisted(() => { attachedProviders: new Set(), recoverNamedGatewayRuntime: vi.fn(), removePreset: vi.fn(), + recheckPolicyMutationAuthority: vi.fn(), + finalizePolicyMutationReceipt: vi.fn(), runOpenshell: vi.fn(), runOpenshellProviderCommand: vi.fn(), stopNimContainer: vi.fn(), @@ -69,14 +80,24 @@ vi.mock("../../src/lib/adapters/openshell/runtime", async (importOriginal) => ({ runOpenshell: testState.runOpenshell, })); +vi.mock("../../src/lib/adapters/openshell/policy-authority", async (importOriginal) => ({ + ...(await importOriginal()), + captureSandboxBasePolicy: testState.captureSandboxBasePolicy, +})); + vi.mock("../../src/lib/gateway-runtime-action", () => ({ recoverNamedGatewayRuntime: testState.recoverNamedGatewayRuntime, })); -vi.mock("../../src/lib/policy", () => ({ +vi.mock("../../src/lib/policy", async (importOriginal) => ({ + ...(await importOriginal()), applyPresetContent: testState.applyPresetContent, + assertNemoClawManagedPolicy: vi.fn(), + finalizePolicyMutationReceipt: testState.finalizePolicyMutationReceipt, getLiveSandboxPolicyEntryDigest: testState.getLiveSandboxPolicyEntryDigest, getPresetContentGatewayState: testState.getPresetContentGatewayState, + inspectPolicyMutationAuthority: testState.inspectPolicyMutationAuthority, + recheckPolicyMutationAuthority: testState.recheckPolicyMutationAuthority, removePreset: testState.removePreset, })); @@ -99,59 +120,10 @@ vi.mock("../../src/lib/inference/nim", () => ({ import * as bridge from "../../src/lib/actions/sandbox/mcp-bridge"; import { isAgentMcpAdapter } from "../../src/lib/actions/sandbox/mcp-bridge-contracts"; import { runRebuildDestroyPhase } from "../../src/lib/actions/sandbox/rebuild-destroy-phase"; -import type { RebuildRecreateJournal } from "../../src/lib/actions/sandbox/rebuild-recreate-journal"; import * as registry from "../../src/lib/state/registry"; -function stubRecreateJournal(): RebuildRecreateJournal { - return { - id: "journal-1", - acceptedTarget: false, - sourceConfirmedAbsent: false, - gatewayAuthority: { - gatewayName: "nemoclaw", - gatewayPort: 8080, - mode: "nemoclaw-managed", - source: "standalone", - endpoint: null, - stateDir: null, - supervisor: null, - requiredCapabilities: [], - }, - targetGeneration: "generation-1", - targetIntentFingerprint: "intent-1", - markDeleting: vi.fn(), - observeSourceForDelete: vi.fn(() => "source" as const), - confirmDeleted: vi.fn(), - completeAcceptedTarget: vi.fn(), - }; -} - const MATCHING_OPENSHELL = path.resolve("test/fixtures/openshell-v0.0.106"); -const bridgeEntries: Record<"github" | "slack", McpBridgeEntry> = { - github: { - server: "github", - agent: "openclaw", - adapter: "mcporter", - url: "https://8.8.8.8/github", - env: ["GITHUB_TOKEN"], - providerName: "alpha-mcp-github", - providerId: "11111111-2222-4333-8444-555555555555", - policyName: "mcp-bridge-github", - addedAt: "2026-06-27T00:00:00.000Z", - }, - slack: { - server: "slack", - agent: "openclaw", - adapter: "mcporter", - url: "https://8.8.8.8/slack", - env: ["SLACK_TOKEN"], - providerName: "alpha-mcp-slack", - providerId: "66666666-7777-4888-8999-000000000000", - policyName: "mcp-bridge-slack", - addedAt: "2026-06-27T00:00:00.000Z", - }, -}; function ownedPolicy( server: "github" | "slack", options: { @@ -166,21 +138,18 @@ function ownedPolicy( const resolvedAddresses = options.resolvedAddresses ?? [new URL(entry.url).hostname]; return { name: entry.policyName, - content: bridge.buildMcpBridgePolicyYaml(entry.server, entry.url, adapter as AgentMcpAdapter, { - addresses: [...resolvedAddresses], - }, entry.providerName ?? ""), + content: bridge.buildMcpBridgePolicyYaml( + entry.server, + entry.url, + adapter as AgentMcpAdapter, + { + addresses: [...resolvedAddresses], + }, + entry.providerName ?? "", + ), sourcePath: "generated:nemoclaw-mcp-bridge", }; } -function restoreEnv(name: string, value: string | undefined): void { - switch (value) { - case undefined: - delete process.env[name]; - break; - default: - process.env[name] = value; - } -} async function captureMessage(action: () => Promise): Promise { try { await action(); @@ -226,6 +195,10 @@ beforeEach(() => { testState.failProviderDelete = null; testState.failProviderDetach = null; vi.resetAllMocks(); + testState.captureSandboxBasePolicy.mockReturnValue(MCP_POLICY_BASE); + testState.inspectPolicyMutationAuthority.mockReturnValue(MCP_POLICY_RECEIPT_AUTHORITY); + testState.recheckPolicyMutationAuthority.mockReturnValue(MCP_POLICY_RECEIPT_AUTHORITY); + testState.finalizePolicyMutationReceipt.mockReturnValue(undefined); testState.recoverNamedGatewayRuntime.mockResolvedValue({ recovered: true, attempted: false, @@ -260,7 +233,14 @@ beforeEach(() => { return { status: 0, stdout: "ready", stderr: "" }; } switch (true) { - case args[0] === "provider" && args[1] === "profile": return mockManagedEndpointlessProviderProfileRun(args) ?? { status: 0, stdout: "Imported provider profile", stderr: "" }; + case args[0] === "provider" && args[1] === "profile": + return ( + mockManagedEndpointlessProviderProfileRun(args) ?? { + status: 0, + stdout: "Imported provider profile", + stderr: "", + } + ); case args[0] === "provider" && args[1] === "get": { const provider = testState.providers.get(args[2]); return provider @@ -306,8 +286,12 @@ beforeEach(() => { case args[0] === "sandbox" && args[1] === "provider" && args[2] === "attach": testState.attachedProviders.add(args[4]); return { status: 0, stdout: "Attached provider", stderr: "" }; - case args[0] === "provider" && args[1] === "update" && args.length === 3 && testState.providers.has(args[2]): - testState.providers.get(args[2])!.resourceVersion = (testState.providers.get(args[2])!.resourceVersion ?? 1) + 1; + case args[0] === "provider" && + args[1] === "update" && + args.length === 3 && + testState.providers.has(args[2]): + testState.providers.get(args[2])!.resourceVersion = + (testState.providers.get(args[2])!.resourceVersion ?? 1) + 1; return { status: 0, stdout: "Updated provider", stderr: "" }; case args[0] === "provider" && args[1] === "delete" && @@ -1239,10 +1223,14 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { expect([...testState.providers.keys()]).toContain("alpha-mcp-github"); expect(testState.calls).toContain("sandbox provider attach alpha alpha-mcp-github"); expect(testState.providers.get("alpha-mcp-github")?.resourceVersion).toBe(2); - expect(testState.calls.some((call) => /^provider (create|update) .*--credential/.test(call))).toBe(false); + expect( + testState.calls.some((call) => /^provider (create|update) .*--credential/.test(call)), + ).toBe(false); expect(testState.policyApplyCalls).toBe(2); expect(testState.adapterCalls).toContain("command -v mcporter"); - expect(testState.adapterCalls.some((call) => call.includes("openshell:resolve:env:GITHUB_TOKEN"))).toBe(true); + expect( + testState.adapterCalls.some((call) => call.includes("openshell:resolve:env:GITHUB_TOKEN")), + ).toBe(true); expect(sandbox?.mcp?.bridges).toHaveProperty("github"); expect(sandbox?.mcp?.managedServerNames).toEqual(["github", "retired"]); expect(sandbox?.mcp?.destroyPreparedAt).toBeUndefined(); @@ -1319,7 +1307,9 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { expect(process.env.GITHUB_TOKEN).toBe("ambient-value-that-must-not-rotate"); expect(testState.providers.get("alpha-mcp-github")?.resourceVersion).toBe(2); - expect(testState.calls.some((call) => /^provider (create|update) .*--credential/.test(call))).toBe(false); + expect( + testState.calls.some((call) => /^provider (create|update) .*--credential/.test(call)), + ).toBe(false); expect([...testState.attachedProviders]).toContain("alpha-mcp-github"); expect(testState.adapterRegistered).toBe(true); expect(testState.policyApplyCalls).toBe(2); diff --git a/test/mcp/mcp-provider-detach-retry.test.ts b/test/mcp/mcp-provider-detach-retry.test.ts index c96df17b054..9828132e093 100644 --- a/test/mcp/mcp-provider-detach-retry.test.ts +++ b/test/mcp/mcp-provider-detach-retry.test.ts @@ -5,17 +5,58 @@ import { spawnSync } from "node:child_process"; import { describe, expect, it } from "vitest"; -type DetachScenario = "success" | "drift" | "exhausted" | "other-error"; +import { + MCP_POLICY_BASE, + MCP_POLICY_RECEIPT_AUTHORITY, +} from "../helpers/mcp-policy-receipt-process-fixture"; + +type DetachScenario = + | "success" + | "drift" + | "exhausted" + | "other-error" + | "attach-already-exact" + | "attach-authority-mismatch" + | "detach-authority-mismatch" + | "idempotent-detach-output" + | "detach-finalize-failure" + | "detach-post-cas-readback-failure"; function runDetachScenario(scenario: DetachScenario) { const script = String.raw` const scenario = ${JSON.stringify(scenario)}; +const policyAuthority = require("./src/lib/adapters/openshell/policy-authority.js"); +const policies = require("./src/lib/policy/index.js"); const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); const expectedId = "11111111-2222-4333-8444-555555555555"; const foreignId = "99999999-8888-4777-8666-555555555555"; let attached = true; let liveId = expectedId; let detachCalls = 0; +let attachCalls = 0; +let receiptFinalized = false; +const receiptAuthority = ${JSON.stringify(MCP_POLICY_RECEIPT_AUTHORITY)}; +if (scenario === "attach-authority-mismatch") attached = false; +policyAuthority.captureSandboxBasePolicy = () => ${JSON.stringify(MCP_POLICY_BASE)}; +policies.inspectPolicyMutationAuthority = () => { + if (scenario === "attach-authority-mismatch" || scenario === "detach-authority-mismatch") { + throw new Error("policy creation receipt does not match the live sandbox policy"); + } + return receiptAuthority; +}; +policies.assertNemoClawManagedPolicy = () => {}; +policies.recheckPolicyMutationAuthority = () => receiptAuthority; +policies.finalizePolicyMutationReceipt = () => { + receiptFinalized = true; + if (scenario === "detach-finalize-failure") { + throw new Error("simulated detach receipt finalization failure"); + } + if (scenario === "detach-post-cas-readback-failure") { + throw new policies.PolicyMutationReceiptFinalVerificationError( + "simulated post-CAS detach readback failure", + ); + } +}; providerCommands.runOpenshellProviderCommand = (args) => { if (args[0] === "sandbox" && args[1] === "provider" && args[2] === "list") { return attached @@ -34,7 +75,13 @@ providerCommands.runOpenshellProviderCommand = (args) => { if (scenario === "other-error") { return { status: 1, stdout: "", stderr: "Failed to detach provider: permission denied" }; } - if (detachCalls === 1 || scenario === "exhausted") { + if ( + (detachCalls === 1 && + scenario !== "detach-finalize-failure" && + scenario !== "detach-post-cas-readback-failure" && + scenario !== "idempotent-detach-output") || + scenario === "exhausted" + ) { if (scenario === "drift") liveId = foreignId; return { status: 1, @@ -43,8 +90,20 @@ providerCommands.runOpenshellProviderCommand = (args) => { }; } attached = false; + if (scenario === "idempotent-detach-output") { + return { + status: 0, + stdout: "Provider alpha-mcp-fake was not attached to sandbox alpha.", + stderr: "", + }; + } return { status: 0, stdout: "Detached provider alpha-mcp-fake from sandbox alpha.", stderr: "" }; } + if (args[0] === "sandbox" && args[1] === "provider" && args[2] === "attach") { + attachCalls += 1; + attached = true; + return { status: 0, stdout: "Attached provider", stderr: "" }; + } throw new Error("unexpected call: " + args.join(" ")); }; const providerActions = require("./src/lib/actions/sandbox/mcp-bridge-provider.js"); @@ -62,11 +121,24 @@ const entry = { let outcome = null; let message = null; try { - outcome = providerActions.detachProvider("alpha", entry); + if (scenario === "attach-already-exact" || scenario === "attach-authority-mismatch") { + providerActions.attachProvider("alpha", entry); + outcome = "attached"; + } else { + outcome = providerActions.detachProvider("alpha", entry); + } } catch (error) { message = error.message; } -process.stdout.write(JSON.stringify({ outcome, message, detachCalls, attached, liveId })); +process.stdout.write(JSON.stringify({ + outcome, + message, + detachCalls, + attachCalls, + attached, + liveId, + receiptFinalized, +})); `; const result = spawnSync(process.execPath, ["-e", script], { cwd: process.cwd(), @@ -77,8 +149,10 @@ process.stdout.write(JSON.stringify({ outcome, message, detachCalls, attached, l outcome: string | null; message: string | null; detachCalls: number; + attachCalls: number; attached: boolean; liveId: string; + receiptFinalized: boolean; }; } @@ -88,7 +162,9 @@ describe("MCP provider detach retry", () => { outcome: "detached", message: null, detachCalls: 2, + attachCalls: 0, attached: false, + receiptFinalized: true, }); }); @@ -98,6 +174,7 @@ describe("MCP provider detach retry", () => { expect(result.message).toContain("sandbox was modified by another operation"); expect(result.detachCalls).toBe(1); expect(result.attached).toBe(true); + expect(result.receiptFinalized).toBe(false); }); it("bounds repeated sandbox mutation conflicts", () => { @@ -106,6 +183,7 @@ describe("MCP provider detach retry", () => { expect(result.message).toContain("sandbox was modified by another operation"); expect(result.detachCalls).toBe(2); expect(result.attached).toBe(true); + expect(result.receiptFinalized).toBe(false); }); it("does not retry unrelated detach failures", () => { @@ -114,5 +192,61 @@ describe("MCP provider detach retry", () => { expect(result.message).toContain("permission denied"); expect(result.detachCalls).toBe(1); expect(result.attached).toBe(true); + expect(result.receiptFinalized).toBe(false); + }); + + it.each(["attach-authority-mismatch", "detach-authority-mismatch"] as const)( + "fails closed before an exact %s mutation when policy authority does not match (#9833)", + (scenario) => { + const result = runDetachScenario(scenario); + expect(result.outcome).toBeNull(); + expect(result.message).toContain("policy creation receipt does not match"); + expect(result.detachCalls).toBe(0); + expect(result.attachCalls).toBe(0); + expect(result.receiptFinalized).toBe(false); + }, + ); + + it("does not attach or rotate the receipt when the exact attachment exists (#9833)", () => { + expect(runDetachScenario("attach-already-exact")).toMatchObject({ + outcome: "attached", + message: null, + attachCalls: 0, + attached: true, + receiptFinalized: false, + }); + }); + + it("does not reattach after detach receipt finalization fails (#9833)", () => { + const result = runDetachScenario("detach-finalize-failure"); + expect(result.outcome).toBeNull(); + expect(result.message).toContain("simulated detach receipt finalization failure"); + expect(result.detachCalls).toBe(1); + expect(result.attachCalls).toBe(0); + expect(result.attached).toBe(false); + expect(result.receiptFinalized).toBe(true); + }); + + it("rotates the receipt when exact detach ends absent with idempotent output (#9833)", () => { + expect(runDetachScenario("idempotent-detach-output")).toMatchObject({ + outcome: "detached", + message: null, + detachCalls: 1, + attachCalls: 0, + attached: false, + receiptFinalized: true, + }); + }); + + it("accepts an exact detached edge after a post-CAS readback failure (#9833)", () => { + const result = runDetachScenario("detach-post-cas-readback-failure"); + expect(result).toMatchObject({ + outcome: "detached", + message: null, + detachCalls: 1, + attachCalls: 0, + attached: false, + receiptFinalized: true, + }); }); }); diff --git a/test/mcp/mcp-provider-ownership.test.ts b/test/mcp/mcp-provider-ownership.test.ts index 5c206ef2d31..b13cb3e64b5 100644 --- a/test/mcp/mcp-provider-ownership.test.ts +++ b/test/mcp/mcp-provider-ownership.test.ts @@ -8,6 +8,8 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; +import { buildManagedMcpPolicyReceiptFixture } from "../helpers/mcp-policy-receipt-process-fixture"; + function runRemoveIdentityRace(swapAt: "detach" | "delete") { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-provider-race-")); const script = ` @@ -16,7 +18,8 @@ const swapAt = ${JSON.stringify(swapAt)}; const registry = require("./src/lib/state/registry.js"); const agentDefs = require("./src/lib/agent/defs.js"); const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); -const policies = require("./src/lib/policy/index.js"); +${buildManagedMcpPolicyReceiptFixture()} +policies.finalizePolicyMutationReceipt = () => {}; const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); const expectedId = "11111111-2222-4333-8444-555555555555"; @@ -119,7 +122,8 @@ process.env.HOME = ${JSON.stringify(home)}; const registry = require("./src/lib/state/registry.js"); const agentDefs = require("./src/lib/agent/defs.js"); const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); -const policies = require("./src/lib/policy/index.js"); +${buildManagedMcpPolicyReceiptFixture()} +policies.finalizePolicyMutationReceipt = () => {}; const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); const expectedId = "11111111-2222-4333-8444-555555555555"; @@ -343,12 +347,14 @@ bridge.statusMcpBridge("alpha", "fake").then( expect(status.provider.detail).toContain("Expected stable provider ID"); }); - it("clears multiple dangling stock OpenShell provider references without listing between them", () => { + it("clears dangling provider references and rotates each managed receipt", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-provider-dangling-")); const script = String.raw` process.env.HOME = ${JSON.stringify(home)}; const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); const calls = []; +${buildManagedMcpPolicyReceiptFixture()} +policies.finalizePolicyMutationReceipt = () => calls.push("receipt finalize"); const attached = new Set(["alpha-mcp-fake", "alpha-mcp-second"]); providerCommands.runOpenshellProviderCommand = (args) => { calls.push(args.join(" ")); @@ -423,10 +429,12 @@ process.stdout.write(JSON.stringify({ before, firstOutcome, afterFirst, secondOu "provider get alpha-mcp-fake", "sandbox provider detach alpha alpha-mcp-fake", "provider get alpha-mcp-fake", + "receipt finalize", "sandbox provider list alpha", "provider get alpha-mcp-second", "sandbox provider detach alpha alpha-mcp-second", "provider get alpha-mcp-second", + "receipt finalize", "sandbox provider list alpha", ]); }); diff --git a/test/mcp/mcp-restart-policy-order.test.ts b/test/mcp/mcp-restart-policy-order.test.ts index 4301e5ad4fe..a6920540ced 100644 --- a/test/mcp/mcp-restart-policy-order.test.ts +++ b/test/mcp/mcp-restart-policy-order.test.ts @@ -182,6 +182,7 @@ const providerCommands = require("./src/lib/adapters/openshell/provider-command. const { mockManagedEndpointlessProviderProfileRun } = require("./test/helpers/onboard-script-mocks.cjs"); const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); const policies = require("./src/lib/policy/index.js"); +const policyAuthority = require("./src/lib/adapters/openshell/policy-authority.js"); const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); const generated = require("./src/lib/actions/sandbox/mcp-bridge-policy.js"); @@ -201,6 +202,28 @@ const entry = { policyName: "mcp-bridge-example", addedAt: "2026-06-01T00:00:00.000Z", }; +const receiptAuthority = { + authority: "nemoclaw-managed", + authorityRecordedNow: false, + gatewayName: "nemoclaw", + inspection: { + authority: "nemoclaw-managed", + effectivePolicy: {}, + policyIdentity: { hash: "receipt-bound-policy", activeVersion: 1 }, + }, + policyCreationReceipt: { policyHash: "receipt-bound-policy", policyVersion: 1 }, +}; +policyAuthority.captureSandboxBasePolicy = () => + "version: 1\nnetwork_policies:\n baseline: {}\n"; +policies.inspectPolicyMutationAuthority = () => receiptAuthority; +policies.inspectManagedPolicyCompensationBoundary = () => ({ + gatewayName: receiptAuthority.gatewayName, + inspection: receiptAuthority.inspection, + policyCreationReceipt: receiptAuthority.policyCreationReceipt, +}); +policies.assertNemoClawManagedPolicy = () => {}; +policies.recheckPolicyMutationAuthority = () => receiptAuthority; +policies.finalizePolicyMutationReceipt = () => {}; gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ recovered: true,