diff --git a/test/deepagents-mcp-legacy-lifecycle.test.ts b/test/deepagents-mcp-legacy-lifecycle.test.ts index 3a7a8347e76..d4e68dc925b 100644 --- a/test/deepagents-mcp-legacy-lifecycle.test.ts +++ b/test/deepagents-mcp-legacy-lifecycle.test.ts @@ -1,24 +1,54 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + applyPresetContent: vi.fn(), + executeGatewaySupervisorAction: vi.fn(), + executeSandboxCommand: vi.fn(), + executeSandboxExecCommand: vi.fn(), + getPresetContentGatewayState: vi.fn(), + recoverNamedGatewayRuntime: vi.fn(), + removePreset: vi.fn(), + runOpenshellProviderCommand: vi.fn(), +})); + +vi.mock("../src/lib/actions/global", () => ({ + runOpenshellProviderCommand: mocks.runOpenshellProviderCommand, +})); + +vi.mock("../src/lib/gateway-runtime-action", () => ({ + recoverNamedGatewayRuntime: mocks.recoverNamedGatewayRuntime, +})); + +vi.mock("../src/lib/policy", () => ({ + applyPresetContent: mocks.applyPresetContent, + getPresetContentGatewayState: mocks.getPresetContentGatewayState, + removePreset: mocks.removePreset, +})); + +vi.mock("../src/lib/actions/sandbox/process-recovery", () => ({ + executeGatewaySupervisorAction: mocks.executeGatewaySupervisorAction, + executeSandboxCommand: mocks.executeSandboxCommand, + executeSandboxExecCommand: mocks.executeSandboxExecCommand, +})); const MATCHING_OPENSHELL = path.resolve("test/fixtures/openshell-v0.0.72"); +const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_OPENSHELL_BIN = process.env.NEMOCLAW_OPENSHELL_BIN; +const ORIGINAL_OPENSHELL_GATEWAY = process.env.OPENSHELL_GATEWAY; +const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-deepagents-mcp-legacy-")); + +process.env.HOME = TMP_HOME; +process.env.NEMOCLAW_OPENSHELL_BIN = MATCHING_OPENSHELL; -function runLegacyLifecycle(body: string) { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-deepagents-mcp-legacy-")); - const script = String.raw` -process.env.HOME = ${JSON.stringify(home)}; -const registry = require("./src/lib/state/registry.js"); -const globalActions = require("./src/lib/actions/global.js"); -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 registry = await import("../src/lib/state/registry"); +const bridge = await import("../src/lib/actions/sandbox/mcp-bridge"); const providerId = "11111111-2222-4333-8444-555555555555"; let providerExists = true; @@ -28,177 +58,189 @@ let adapterRemovalOutcome = ""; let deepAgentsCapability = false; let policyApplyCalls = 0; let policyState = "match"; -const adapterCalls = []; +let adapterCalls: string[] = []; -gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ - recovered: true, - attempted: false, - before: { state: "healthy_named" }, - after: { state: "healthy_named" }, -}); -globalActions.runOpenshellProviderCommand = (args) => { - const command = args.join(" "); - if (command === "status --output json") { - return { status: 0, stdout: "ready", stderr: "" }; - } - if (args[0] === "provider" && args[1] === "get") { - return providerExists - ? { - status: 0, - stdout: "Id: " + providerId + "\nType: generic\nResource version: 1\nCredential keys: GITHUB_TOKEN\n", - stderr: "", - } - : { status: 1, stdout: "", stderr: "Provider not found" }; - } - if (args[0] === "sandbox" && args[1] === "provider" && args[2] === "list") { - return { - status: 0, - stdout: attached - ? "NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS\nalpha-mcp-github generic 1 0\n" - : "No providers attached to sandbox alpha.\n", - stderr: "", - }; - } - if (args[0] === "sandbox" && args[1] === "provider" && args[2] === "detach") { - attached = false; - return { status: 0, stdout: "Detached provider", stderr: "" }; - } - if (args[0] === "sandbox" && args[1] === "provider" && args[2] === "attach") { - attached = true; - return { status: 0, stdout: "Attached provider", stderr: "" }; - } - if (args[0] === "provider" && args[1] === "delete") { - providerExists = false; - attached = false; - return { status: 0, stdout: "Deleted provider", stderr: "" }; - } - throw new Error("Unexpected OpenShell call: " + command); -}; -policies.getPresetContentGatewayState = () => policyState; -policies.applyPresetContent = () => { - policyApplyCalls += 1; - policyState = "match"; - return true; -}; -policies.removePreset = () => { - policyState = "absent"; - return true; -}; -processRecovery.executeSandboxCommand = (_sandbox, command) => { - adapterCalls.push(command); - if (command === "/usr/local/bin/deepagents-code --nemoclaw-mcp-capability") { - return deepAgentsCapability - ? { status: 0, stdout: "NEMOCLAW_DEEPAGENTS_MCP_CAPABILITY=2\n", stderr: "" } - : { status: 2, stdout: "", stderr: "unknown option" }; - } - if (command.includes("servers.pop(payload['server'])")) { - const outcome = adapterRemovalOutcome || (adapterRegistered ? "removed" : "absent"); - if (outcome !== "unowned") adapterRegistered = false; - return { - status: 0, - stdout: "NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=" + outcome + "\n", - stderr: "", - }; - } - if (command.includes("data = {'mcpServers': payload['expectedServers']}")) { - adapterRegistered = true; - return { - status: 0, - stdout: command.includes("NEMOCLAW_DEEPAGENTS_MCP_ROLLBACK_RESTORED") - ? "NEMOCLAW_DEEPAGENTS_MCP_ROLLBACK_RESTORED=1\n" - : "", - stderr: "", - }; - } - if (command.includes("print('registered' if ok else ('mismatch' if present else 'absent'))")) { - return { - status: 0, - stdout: adapterRegistered ? "registered\n" : "absent\n", - stderr: "", - }; - } - return { status: 0, stdout: "", stderr: "" }; -}; -processRecovery.executeSandboxExecCommand = (_sandbox, command) => { - const encoded = command.match(/printf '%s' '([A-Za-z0-9+/=]+)' \| base64 -d/)?.[1] || ""; - const proof = encoded ? Buffer.from(encoded, "base64").toString("utf8") : command; - const isRevisionObservation = proof.includes("valid_placeholder()"); - const isDetachedProof = - !isRevisionObservation && proof.includes('[ -z "\${GITHUB_TOKEN+x}" ]'); +function lifecycleResult() { return { - status: isDetachedProof && attached ? 1 : 0, - stdout: attached ? "canonical" : "absent", - stderr: "", + attached, + adapterRegistered, + providerExists, + policyApplyCalls, + markerCalls: adapterCalls.filter((call) => + call.includes("deepagents-code --nemoclaw-mcp-capability"), + ).length, }; -}; - -const entry = { - server: "github", - agent: "langchain-deepagents-code", - adapter: "deepagents-config", - url: "https://8.8.8.8/github", - env: ["GITHUB_TOKEN"], - providerName: "alpha-mcp-github", - providerId, - policyName: "mcp-bridge-github", - addedAt: "2026-06-27T00:00:00.000Z", -}; -registry.registerSandbox({ - name: "alpha", - agent: "langchain-deepagents-code", - gatewayName: "nemoclaw", - mcp: { bridges: { github: entry } }, -}); -registry.addCustomPolicy("alpha", { - name: entry.policyName, - content: "network_policies: {}\n", - sourcePath: "generated:nemoclaw-mcp-bridge", -}); -const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); -${body} -`; - const result = spawnSync(process.execPath, ["-e", script], { - cwd: process.cwd(), - encoding: "utf8", - env: { ...process.env, HOME: home, NEMOCLAW_OPENSHELL_BIN: MATCHING_OPENSHELL }, - }); - fs.rmSync(home, { recursive: true, force: true }); - return result; } -function parseResult(result: ReturnType) { - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - return JSON.parse(result.stdout.slice(result.stdout.indexOf("{"))) as { - error?: string; - entryCount?: number; - attached: boolean; - adapterRegistered: boolean; - providerExists: boolean; - policyApplyCalls: number; - markerCalls: number; - registryEntryPresent?: boolean; - }; +function restoreEnvironmentVariable(name: string, value: string | undefined): void { + switch (value) { + case undefined: + delete process.env[name]; + break; + default: + process.env[name] = value; + } } -const resultExpression = `JSON.stringify({ - attached, - adapterRegistered, - providerExists, - policyApplyCalls, - markerCalls: adapterCalls.filter((call) => - call.includes("deepagents-code --nemoclaw-mcp-capability") - ).length, -})`; +afterAll(() => { + restoreEnvironmentVariable("HOME", ORIGINAL_HOME); + restoreEnvironmentVariable("NEMOCLAW_OPENSHELL_BIN", ORIGINAL_OPENSHELL_BIN); + restoreEnvironmentVariable("OPENSHELL_GATEWAY", ORIGINAL_OPENSHELL_GATEWAY); + fs.rmSync(TMP_HOME, { recursive: true, force: true }); +}); + +beforeEach(() => { + fs.rmSync(path.dirname(registry.REGISTRY_FILE), { recursive: true, force: true }); + restoreEnvironmentVariable("OPENSHELL_GATEWAY", ORIGINAL_OPENSHELL_GATEWAY); + + providerExists = true; + attached = true; + adapterRegistered = true; + adapterRemovalOutcome = ""; + deepAgentsCapability = false; + policyApplyCalls = 0; + policyState = "match"; + adapterCalls = []; + + mocks.runOpenshellProviderCommand.mockReset().mockImplementation((args: string[]) => { + const command = args.join(" "); + switch (true) { + case command === "status --output json": + return { status: 0, stdout: "ready", stderr: "" }; + case args[0] === "provider" && args[1] === "get": + return providerExists + ? { + status: 0, + stdout: `Id: ${providerId}\nType: generic\nResource version: 1\nCredential keys: GITHUB_TOKEN\n`, + stderr: "", + } + : { status: 1, stdout: "", stderr: "Provider not found" }; + case args[0] === "sandbox" && args[1] === "provider" && args[2] === "list": + return { + status: 0, + stdout: attached + ? "NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS\nalpha-mcp-github generic 1 0\n" + : "No providers attached to sandbox alpha.\n", + stderr: "", + }; + case args[0] === "sandbox" && args[1] === "provider" && args[2] === "detach": + attached = false; + return { status: 0, stdout: "Detached provider", stderr: "" }; + case args[0] === "sandbox" && args[1] === "provider" && args[2] === "attach": + attached = true; + return { status: 0, stdout: "Attached provider", stderr: "" }; + case args[0] === "provider" && args[1] === "delete": + providerExists = false; + attached = false; + return { status: 0, stdout: "Deleted provider", stderr: "" }; + default: + throw new Error(`Unexpected OpenShell call: ${command}`); + } + }); + + mocks.recoverNamedGatewayRuntime.mockReset().mockResolvedValue({ + recovered: true, + attempted: false, + before: { state: "healthy_named" }, + after: { state: "healthy_named" }, + }); + + mocks.getPresetContentGatewayState.mockReset().mockImplementation(() => policyState); + mocks.applyPresetContent.mockReset().mockImplementation(() => { + policyApplyCalls += 1; + policyState = "match"; + return true; + }); + mocks.removePreset.mockReset().mockImplementation(() => { + policyState = "absent"; + return true; + }); + + mocks.executeGatewaySupervisorAction.mockReset(); + mocks.executeSandboxCommand + .mockReset() + .mockImplementation((_sandbox: string, command: string) => { + adapterCalls.push(command); + switch (true) { + case command === "/usr/local/bin/deepagents-code --nemoclaw-mcp-capability": + return deepAgentsCapability + ? { status: 0, stdout: "NEMOCLAW_DEEPAGENTS_MCP_CAPABILITY=2\n", stderr: "" } + : { status: 2, stdout: "", stderr: "unknown option" }; + case command.includes("servers.pop(payload['server'])"): { + const outcome = adapterRemovalOutcome || (adapterRegistered ? "removed" : "absent"); + adapterRegistered = outcome === "unowned" ? adapterRegistered : false; + return { + status: 0, + stdout: `NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=${outcome}\n`, + stderr: "", + }; + } + case command.includes("data = {'mcpServers': payload['expectedServers']}"): + adapterRegistered = true; + return { + status: 0, + stdout: command.includes("NEMOCLAW_DEEPAGENTS_MCP_ROLLBACK_RESTORED") + ? "NEMOCLAW_DEEPAGENTS_MCP_ROLLBACK_RESTORED=1\n" + : "", + stderr: "", + }; + case command.includes( + "print('registered' if ok else ('mismatch' if present else 'absent'))", + ): + return { + status: 0, + stdout: adapterRegistered ? "registered\n" : "absent\n", + stderr: "", + }; + default: + return { status: 0, stdout: "", stderr: "" }; + } + }); + + mocks.executeSandboxExecCommand + .mockReset() + .mockImplementation((_sandbox: string, command: string) => { + const encoded = command.match(/printf '%s' '([A-Za-z0-9+/=]+)' \| base64 -d/)?.[1] || ""; + const proof = encoded ? Buffer.from(encoded, "base64").toString("utf8") : command; + const isRevisionObservation = proof.includes("valid_placeholder()"); + const isDetachedProof = + !isRevisionObservation && proof.includes('[ -z "${GITHUB_TOKEN+x}" ]'); + return { + status: isDetachedProof && attached ? 1 : 0, + stdout: attached ? "canonical" : "absent", + stderr: "", + }; + }); + + const entry = { + server: "github", + agent: "langchain-deepagents-code", + adapter: "deepagents-config" as const, + url: "https://8.8.8.8/github", + env: ["GITHUB_TOKEN"], + providerName: "alpha-mcp-github", + providerId, + policyName: "mcp-bridge-github", + addedAt: "2026-06-27T00:00:00.000Z", + }; + registry.registerSandbox({ + name: "alpha", + agent: "langchain-deepagents-code", + gatewayName: "nemoclaw", + mcp: { bridges: { github: entry } }, + }); + registry.addCustomPolicy("alpha", { + name: entry.policyName, + content: "network_policies: {}\n", + sourcePath: "generated:nemoclaw-mcp-bridge", + }); +}); describe("legacy Deep Agents managed MCP lifecycle", () => { - it("removes an existing entry without requiring the new launcher marker", () => { - const result = runLegacyLifecycle(` -(async () => { - await bridge.removeMcpBridge("alpha", "github"); - process.stdout.write(${resultExpression}); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - expect(parseResult(result)).toMatchObject({ + it("removes an existing entry without requiring the new launcher marker", async () => { + await bridge.removeMcpBridge("alpha", "github"); + + expect(lifecycleResult()).toMatchObject({ attached: false, adapterRegistered: false, providerExists: false, @@ -206,15 +248,12 @@ describe("legacy Deep Agents managed MCP lifecycle", () => { }); }); - it("treats an already-absent legacy entry as an idempotent removal retry", () => { - const result = runLegacyLifecycle(` -adapterRegistered = false; -(async () => { - await bridge.removeMcpBridge("alpha", "github"); - process.stdout.write(${resultExpression}); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - expect(parseResult(result)).toMatchObject({ + it("treats an already-absent legacy entry as an idempotent removal retry", async () => { + adapterRegistered = false; + + await bridge.removeMcpBridge("alpha", "github"); + + expect(lifecycleResult()).toMatchObject({ attached: false, adapterRegistered: false, providerExists: false, @@ -222,30 +261,21 @@ adapterRegistered = false; }); }); - it("preserves ownership state when legacy adapter cleanup is unproved", () => { - const result = runLegacyLifecycle(` -adapterRemovalOutcome = "unowned"; -(async () => { - let error = ""; - try { - await bridge.removeMcpBridge("alpha", "github", { force: true }); - } catch (caught) { - error = caught instanceof Error ? caught.message : String(caught); - } - process.stdout.write(JSON.stringify({ - error, - attached, - adapterRegistered, - providerExists, - policyApplyCalls, - registryEntryPresent: Boolean(registry.getSandbox("alpha")?.mcp?.bridges?.github), - markerCalls: adapterCalls.filter((call) => - call.includes("deepagents-code --nemoclaw-mcp-capability") - ).length, - })); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - expect(parseResult(result)).toMatchObject({ + it("preserves ownership state when legacy adapter cleanup is unproved", async () => { + adapterRemovalOutcome = "unowned"; + + let error = ""; + try { + await bridge.removeMcpBridge("alpha", "github", { force: true }); + } catch (caught) { + error = caught instanceof Error ? caught.message : String(caught); + } + + expect({ + error, + ...lifecycleResult(), + registryEntryPresent: Boolean(registry.getSandbox("alpha")?.mcp?.bridges?.github), + }).toMatchObject({ error: expect.stringMatching(/left residual resources/), adapterRegistered: true, providerExists: true, @@ -258,23 +288,10 @@ adapterRemovalOutcome = "unowned"; ["destroy", "prepareMcpBridgesForDestroy"], ["rebuild", "prepareMcpBridgesForRebuild"], ] as const) { - it(`${label} teardown does not require the marker from the old image`, () => { - const result = runLegacyLifecycle(` -(async () => { - const preparation = await bridge.${method}("alpha"); - process.stdout.write(JSON.stringify({ - entryCount: preparation.entries.length, - attached, - adapterRegistered, - providerExists, - policyApplyCalls, - markerCalls: adapterCalls.filter((call) => - call.includes("deepagents-code --nemoclaw-mcp-capability") - ).length, - })); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - expect(parseResult(result)).toMatchObject({ + it(`${label} teardown does not require the marker from the old image`, async () => { + const preparation = await bridge[method]("alpha"); + + expect({ entryCount: preparation.entries.length, ...lifecycleResult() }).toMatchObject({ entryCount: 1, attached: false, adapterRegistered: false, @@ -283,28 +300,17 @@ adapterRemovalOutcome = "unowned"; }); }); - it(`${label} teardown fails closed when adapter ownership is unproved`, () => { - const result = runLegacyLifecycle(` -adapterRemovalOutcome = "unowned"; -(async () => { - let error = ""; - try { - await bridge.${method}("alpha"); - } catch (caught) { - error = caught instanceof Error ? caught.message : String(caught); - } - process.stdout.write(JSON.stringify({ - error, - attached, - adapterRegistered, - providerExists, - markerCalls: adapterCalls.filter((call) => - call.includes("deepagents-code --nemoclaw-mcp-capability") - ).length, - })); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - expect(parseResult(result)).toMatchObject({ + it(`${label} teardown fails closed when adapter ownership is unproved`, async () => { + adapterRemovalOutcome = "unowned"; + + let error = ""; + try { + await bridge[method]("alpha"); + } catch (caught) { + error = caught instanceof Error ? caught.message : String(caught); + } + + expect({ error, ...lifecycleResult() }).toMatchObject({ error: expect.stringMatching(/Could not prove removal of the exact managed adapter entry/), attached: true, adapterRegistered: true, @@ -314,29 +320,16 @@ adapterRemovalOutcome = "unowned"; }); } - it("proves the replacement image marker before post-rebuild reattachment", () => { - const result = runLegacyLifecycle(` -(async () => { - const preparation = await bridge.prepareMcpBridgesForRebuild("alpha"); - let error = ""; - try { - await bridge.restoreMcpBridgesAfterRebuild("alpha", preparation.entries); - } catch (caught) { - error = caught instanceof Error ? caught.message : String(caught); - } - process.stdout.write(JSON.stringify({ - error, - attached, - adapterRegistered, - providerExists, - policyApplyCalls, - markerCalls: adapterCalls.filter((call) => - call.includes("deepagents-code --nemoclaw-mcp-capability") - ).length, - })); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - expect(parseResult(result)).toMatchObject({ + it("proves the replacement image marker before post-rebuild reattachment", async () => { + const preparation = await bridge.prepareMcpBridgesForRebuild("alpha"); + let error = ""; + try { + await bridge.restoreMcpBridgesAfterRebuild("alpha", preparation.entries); + } catch (caught) { + error = caught instanceof Error ? caught.message : String(caught); + } + + expect({ error, ...lifecycleResult() }).toMatchObject({ error: expect.stringMatching(/does not contain managed MCP capability v2/i), attached: false, adapterRegistered: false, @@ -346,32 +339,31 @@ adapterRemovalOutcome = "unowned"; }); }); - for (const [label, prepare, restore] of [ - [ - "destroy", - "prepareMcpBridgesForDestroy", - "restoreMcpBridgesAfterDestroyAbort('alpha', preparation)", - ], - [ - "rebuild", - "prepareMcpBridgesForRebuild", - "reattachMcpProvidersAfterRebuildAbort('alpha', preparation.detachedProviderEntries, preparation.scrubbedAdapterEntries)", - ], - ] as const) { - it(`restores the old image when ${label} deletion aborts`, () => { - const result = runLegacyLifecycle(` -(async () => { - const preparation = await bridge.${prepare}("alpha"); - await bridge.${restore}; - process.stdout.write(${resultExpression}); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - expect(parseResult(result)).toMatchObject({ - attached: true, - adapterRegistered: true, - providerExists: true, - markerCalls: 0, - }); + it("restores the old image when destroy deletion aborts", async () => { + const preparation = await bridge.prepareMcpBridgesForDestroy("alpha"); + await bridge.restoreMcpBridgesAfterDestroyAbort("alpha", preparation); + + expect(lifecycleResult()).toMatchObject({ + attached: true, + adapterRegistered: true, + providerExists: true, + markerCalls: 0, }); - } + }); + + it("restores the old image when rebuild deletion aborts", async () => { + const preparation = await bridge.prepareMcpBridgesForRebuild("alpha"); + await bridge.reattachMcpProvidersAfterRebuildAbort( + "alpha", + preparation.detachedProviderEntries, + preparation.scrubbedAdapterEntries, + ); + + expect(lifecycleResult()).toMatchObject({ + attached: true, + adapterRegistered: true, + providerExists: true, + markerCalls: 0, + }); + }); }); diff --git a/test/deepagents-mcp-runtime-capability.test.ts b/test/deepagents-mcp-runtime-capability.test.ts index a5579a2e8de..d417b73dbe3 100644 --- a/test/deepagents-mcp-runtime-capability.test.ts +++ b/test/deepagents-mcp-runtime-capability.test.ts @@ -1,38 +1,38 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync } from "node:child_process"; +import { describe, expect, it, vi } from "vitest"; -import { describe, expect, it } from "vitest"; +const mocks = vi.hoisted(() => ({ + executeGatewaySupervisorAction: vi.fn(), + executeSandboxCommand: vi.fn(), +})); + +vi.mock("../src/lib/actions/sandbox/process-recovery", () => ({ + executeGatewaySupervisorAction: mocks.executeGatewaySupervisorAction, + executeSandboxCommand: mocks.executeSandboxCommand, +})); + +import { assertAgentMcpMutationRuntimeCapability } from "../src/lib/actions/sandbox/mcp-bridge-adapters"; type ProbeResult = { status: number; stdout: string; stderr: string } | null; function runDeepAgentsProbe(result: ProbeResult) { - const script = String.raw` -const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); -const calls = []; -processRecovery.executeSandboxCommand = (sandboxName, command) => { - calls.push({ sandboxName, command }); - return ${JSON.stringify(result)}; -}; -const adapters = require("./src/lib/actions/sandbox/mcp-bridge-adapters.js"); -let message = ""; -try { - adapters.assertAgentMcpMutationRuntimeCapability("deepagents-box", "deepagents-config"); -} catch (error) { - message = error instanceof Error ? error.message : String(error); -} -process.stdout.write(JSON.stringify({ calls, message })); -`; - const child = spawnSync(process.execPath, ["-e", script], { - cwd: process.cwd(), - encoding: "utf8", - env: process.env, - }); - expect(child.status, `${child.stdout}\n${child.stderr}`).toBe(0); - return JSON.parse(child.stdout) as { - calls: Array<{ sandboxName: string; command: string }>; - message: string; + mocks.executeSandboxCommand.mockReset().mockReturnValue(result); + + let message = ""; + try { + assertAgentMcpMutationRuntimeCapability("deepagents-box", "deepagents-config"); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + return { + calls: mocks.executeSandboxCommand.mock.calls.map(([sandboxName, command]) => ({ + sandboxName, + command, + })), + message, }; } diff --git a/test/hermes-mcp-startup-probe.test.ts b/test/hermes-mcp-startup-probe.test.ts index b7a1b0ff187..243f7755086 100644 --- a/test/hermes-mcp-startup-probe.test.ts +++ b/test/hermes-mcp-startup-probe.test.ts @@ -1,9 +1,33 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync } from "node:child_process"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -import { describe, expect, it } from "vitest"; +const mocks = vi.hoisted(() => ({ + executeGatewaySupervisorAction: vi.fn(), + isShieldsDown: vi.fn(), + runOpenshellProviderCommand: vi.fn(), + waitUntil: vi.fn(), +})); + +vi.mock("../src/lib/actions/global", () => ({ + runOpenshellProviderCommand: mocks.runOpenshellProviderCommand, +})); + +vi.mock("../src/lib/actions/sandbox/process-recovery", () => ({ + executeGatewaySupervisorAction: mocks.executeGatewaySupervisorAction, + executeSandboxCommand: vi.fn(), +})); + +vi.mock("../src/lib/core/wait", () => ({ + waitUntil: mocks.waitUntil, +})); + +vi.mock("../src/lib/shields", () => ({ + isShieldsDown: mocks.isShieldsDown, +})); + +import { assertAgentMcpMutationRuntimeCapability } from "../src/lib/actions/sandbox/mcp-bridge-adapters"; type ProbeResult = { status: number; stdout: string; stderr: string }; type SupervisorResult = ProbeResult | null; @@ -13,56 +37,48 @@ function runHermesProbe( shieldsDown = true, supervisorResults: SupervisorResult[] = [], ) { - const script = String.raw` -const globalActions = require("./src/lib/actions/global.js"); -const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); -const wait = require("./src/lib/core/wait.js"); -const shields = require("./src/lib/shields/index.js"); -const results = ${JSON.stringify(results)}; -const supervisorResults = ${JSON.stringify(supervisorResults)}; -let calls = 0; -let recoveryCalls = 0; -const recoveryActions = []; -globalActions.runOpenshellProviderCommand = () => results[calls++]; -processRecovery.executeGatewaySupervisorAction = (_sandbox, action, timeout) => { - recoveryActions.push({ action, timeout }); - return supervisorResults[recoveryCalls++] ?? null; -}; -wait.waitUntil = (condition, optionsOrTimeout) => { - const maxAttempts = typeof optionsOrTimeout === "object" - ? (optionsOrTimeout.maxAttempts ?? Number.POSITIVE_INFINITY) - : Number.POSITIVE_INFINITY; - let attempts = 0; - while (calls < results.length && attempts < maxAttempts) { - attempts += 1; - if (condition()) return true; + let calls = 0; + let recoveryCalls = 0; + const recoveryActions: Array<{ action: string; timeout: number }> = []; + + mocks.runOpenshellProviderCommand.mockImplementation(() => results[calls++]); + mocks.executeGatewaySupervisorAction.mockImplementation( + (_sandbox: string, action: string, timeout: number) => { + recoveryActions.push({ action, timeout }); + return supervisorResults[recoveryCalls++] ?? null; + }, + ); + mocks.waitUntil.mockImplementation( + (condition: () => boolean, optionsOrTimeout?: number | { maxAttempts?: number }): boolean => { + const maxAttempts = + typeof optionsOrTimeout === "object" + ? (optionsOrTimeout.maxAttempts ?? Number.POSITIVE_INFINITY) + : Number.POSITIVE_INFINITY; + let attempts = 0; + let ready = false; + while (!ready && calls < results.length && attempts < maxAttempts) { + attempts += 1; + ready = condition(); + } + return ready; + }, + ); + mocks.isShieldsDown.mockReturnValue(shieldsDown); + + let message = ""; + try { + assertAgentMcpMutationRuntimeCapability("hermes-box", "hermes-config"); + } catch (error) { + message = error instanceof Error ? error.message : String(error); } - return false; -}; -shields.isShieldsDown = () => ${JSON.stringify(shieldsDown)}; -const adapters = require("./src/lib/actions/sandbox/mcp-bridge-adapters.js"); -let message = ""; -try { - adapters.assertAgentMcpMutationRuntimeCapability("hermes-box", "hermes-config"); -} catch (error) { - message = error instanceof Error ? error.message : String(error); -} -process.stdout.write(JSON.stringify({ calls, recoveryActions, message })); -`; - const result = spawnSync(process.execPath, ["-e", script], { - cwd: process.cwd(), - encoding: "utf8", - env: process.env, - timeout: 30_000, - }); - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - return JSON.parse(result.stdout) as { - calls: number; - recoveryActions: Array<{ action: string; timeout: number }>; - message: string; - }; + + return { calls, recoveryActions, message }; } +beforeEach(() => { + vi.resetAllMocks(); +}); + const starting: ProbeResult = { status: 1, stdout: "", diff --git a/test/mcp-destroy-lifecycle.test.ts b/test/mcp-destroy-lifecycle.test.ts index afa69d1a9aa..9f4100a2b17 100644 --- a/test/mcp-destroy-lifecycle.test.ts +++ b/test/mcp-destroy-lifecycle.test.ts @@ -1,226 +1,312 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync } from "node:child_process"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { McpBridgeEntry } from "../src/lib/state/registry"; + +const testState = vi.hoisted(() => { + const home = `/tmp/nemoclaw-mcp-destroy-${process.pid}-${Date.now()}`; + const originalEnv = { + GITHUB_TOKEN: process.env.GITHUB_TOKEN, + HOME: process.env.HOME, + NEMOCLAW_OPENSHELL_BIN: process.env.NEMOCLAW_OPENSHELL_BIN, + OPENSHELL_GATEWAY: process.env.OPENSHELL_GATEWAY, + SLACK_TOKEN: process.env.SLACK_TOKEN, + }; + process.env.HOME = home; + + return { + adapterCalls: [] as string[], + adapterRegistered: true, + applyPresetContent: vi.fn(), + calls: [] as string[], + executeGatewaySupervisorAction: vi.fn(), + executeSandboxCommand: vi.fn(), + executeSandboxExecCommand: vi.fn(), + failProviderDelete: null as string | null, + failProviderDetach: null as string | null, + getPresetContentGatewayState: vi.fn(), + home, + originalEnv, + policyApplyCalls: 0, + providers: new Map(), + attachedProviders: new Set(), + recoverNamedGatewayRuntime: vi.fn(), + removePreset: vi.fn(), + runOpenshellProviderCommand: vi.fn(), + }; +}); + +vi.mock("../src/lib/actions/global", () => ({ + runOpenshellProviderCommand: testState.runOpenshellProviderCommand, +})); + +vi.mock("../src/lib/gateway-runtime-action", () => ({ + recoverNamedGatewayRuntime: testState.recoverNamedGatewayRuntime, +})); + +vi.mock("../src/lib/policy", () => ({ + applyPresetContent: testState.applyPresetContent, + getPresetContentGatewayState: testState.getPresetContentGatewayState, + removePreset: testState.removePreset, +})); + +vi.mock("../src/lib/actions/sandbox/process-recovery", () => ({ + executeGatewaySupervisorAction: testState.executeGatewaySupervisorAction, + executeSandboxCommand: testState.executeSandboxCommand, + executeSandboxExecCommand: testState.executeSandboxExecCommand, +})); + +import * as bridge from "../src/lib/actions/sandbox/mcp-bridge"; +import * as registry from "../src/lib/state/registry"; const MATCHING_OPENSHELL = path.resolve("test/fixtures/openshell-v0.0.72"); -function runDestroyLifecycleScenario(body: string) { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-destroy-")); - const script = ` -process.env.HOME = ${JSON.stringify(home)}; -const registry = require("./src/lib/state/registry.js"); -const globalActions = require("./src/lib/actions/global.js"); -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 providers = new Map([ - [ - "alpha-mcp-github", - { credential: "GITHUB_TOKEN", id: "11111111-2222-4333-8444-555555555555" }, - ], - [ - "alpha-mcp-slack", - { credential: "SLACK_TOKEN", id: "66666666-7777-4888-8999-000000000000" }, - ], -]); -const attachedProviders = new Set(providers.keys()); -const calls = []; -const adapterCalls = []; -let adapterRegistered = true; -let policyApplyCalls = 0; -let failProviderDelete = null; -let failProviderDetach = null; -globalActions.runOpenshellProviderCommand = (args) => { - calls.push(args.join(" ")); - if (args.join(" ") === "status --output json") { - return { - status: 0, - stdout: "ready", - stderr: "", - }; +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") { + return { + name: `mcp-bridge-${server}`, + content: "network_policies: {}\n", + 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; } - if (args[0] === "provider" && args[1] === "get") { - const provider = providers.get(args[2]); - return provider - ? { status: 0, stdout: "Id: " + provider.id + "\\nType: generic\\nResource version: 1\\nCredential keys: " + provider.credential + "\\n", stderr: "" } - : { status: 1, stdout: "", stderr: "Provider not found" }; +} + +async function captureMessage(action: () => Promise): Promise { + try { + await action(); + return ""; + } catch (error) { + return error instanceof Error ? error.message : String(error); } - if (args[0] === "sandbox" && args[1] === "provider" && args[2] === "list") { - const names = [...attachedProviders]; - const danglingName = names.find((name) => !providers.has(name)); - if (danglingName) { - return { - status: 9, - stdout: "", - stderr: "FailedPrecondition: provider '" + danglingName + "' not found", - }; +} + +beforeEach(() => { + fs.rmSync(testState.home, { recursive: true, force: true }); + process.env.HOME = testState.home; + process.env.NEMOCLAW_OPENSHELL_BIN = MATCHING_OPENSHELL; + delete process.env.GITHUB_TOKEN; + delete process.env.SLACK_TOKEN; + delete process.env.OPENSHELL_GATEWAY; + + testState.providers.clear(); + testState.providers.set("alpha-mcp-github", { + credential: "GITHUB_TOKEN", + id: "11111111-2222-4333-8444-555555555555", + }); + testState.providers.set("alpha-mcp-slack", { + credential: "SLACK_TOKEN", + id: "66666666-7777-4888-8999-000000000000", + }); + testState.attachedProviders.clear(); + testState.attachedProviders.add("alpha-mcp-github"); + testState.attachedProviders.add("alpha-mcp-slack"); + testState.calls.length = 0; + testState.adapterCalls.length = 0; + testState.adapterRegistered = true; + testState.policyApplyCalls = 0; + testState.failProviderDelete = null; + testState.failProviderDetach = null; + + vi.resetAllMocks(); + testState.recoverNamedGatewayRuntime.mockResolvedValue({ + recovered: true, + attempted: false, + before: { state: "healthy_named" }, + after: { state: "healthy_named" }, + }); + testState.applyPresetContent.mockImplementation(() => { + testState.policyApplyCalls += 1; + return true; + }); + testState.getPresetContentGatewayState.mockReturnValue("match"); + testState.removePreset.mockReturnValue(true); + + testState.runOpenshellProviderCommand.mockImplementation((args: string[]) => { + testState.calls.push(args.join(" ")); + switch (args.join(" ")) { + case "status --output json": + return { status: 0, stdout: "ready", stderr: "" }; } - return { - status: 0, - stdout: - names.length > 0 - ? "NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS\\n" + - names - .map((name) => name + " generic 1 0") - .join("\\n") + - "\\n" - : "No providers attached to sandbox " + args[3] + ".\\n", - stderr: "", - }; - } - if (args[0] === "sandbox" && args[1] === "provider" && args[2] === "detach") { - if (failProviderDetach === args[4]) { - return { status: 9, stdout: "", stderr: "provider detach failed" }; + switch (true) { + case args[0] === "provider" && args[1] === "get": { + const provider = testState.providers.get(args[2]); + return provider + ? { + status: 0, + stdout: `Id: ${provider.id}\nType: generic\nResource version: 1\nCredential keys: ${provider.credential}\n`, + stderr: "", + } + : { status: 1, stdout: "", stderr: "Provider not found" }; + } } - attachedProviders.delete(args[4]); - return { status: 0, stdout: "Detached provider", stderr: "" }; - } - if (args[0] === "sandbox" && args[1] === "provider" && args[2] === "attach") { - attachedProviders.add(args[4]); - return { status: 0, stdout: "Attached provider", stderr: "" }; - } - if (args[0] === "provider" && args[1] === "delete") { - if (failProviderDelete === args[2]) { - return { status: 9, stdout: "", stderr: "provider delete failed" }; + switch (true) { + case args[0] === "sandbox" && args[1] === "provider" && args[2] === "list": { + const names = [...testState.attachedProviders]; + const danglingName = names.find((name) => !testState.providers.has(name)); + return danglingName + ? { + status: 9, + stdout: "", + stderr: `FailedPrecondition: provider '${danglingName}' not found`, + } + : { + status: 0, + stdout: + names.length > 0 + ? `NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS\n${names + .map((name) => `${name} generic 1 0`) + .join("\n")}\n` + : `No providers attached to sandbox ${args[3]}.\n`, + stderr: "", + }; + } } - attachedProviders.delete(args[2]); - providers.delete(args[2]); - return { status: 0, stdout: "Deleted provider", stderr: "" }; - } - throw new Error("Unexpected OpenShell call: " + args.join(" ")); -}; -gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ - recovered: true, - attempted: false, - before: { state: "healthy_named" }, - after: { state: "healthy_named" }, -}); -policies.applyPresetContent = () => { - policyApplyCalls += 1; - return true; -}; -policies.getPresetContentGatewayState = () => "match"; -policies.removePreset = () => true; -processRecovery.executeSandboxCommand = (_sandbox, command) => { - adapterCalls.push(command); - if (command.includes("'config' 'add'")) { - adapterRegistered = true; - return { status: 0, stdout: "", stderr: "" }; - } - if (command.includes('["config", "remove"')) { - adapterRegistered = false; - return { status: 0, stdout: "", stderr: "" }; - } - if (command.includes('["config", "get"')) { + switch (true) { + case args[0] === "sandbox" && + args[1] === "provider" && + args[2] === "detach" && + testState.failProviderDetach === args[4]: + return { status: 9, stdout: "", stderr: "provider detach failed" }; + case args[0] === "sandbox" && args[1] === "provider" && args[2] === "detach": + testState.attachedProviders.delete(args[4]); + return { status: 0, stdout: "Detached provider", stderr: "" }; + 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] === "delete" && + testState.failProviderDelete === args[2]: + return { status: 9, stdout: "", stderr: "provider delete failed" }; + case args[0] === "provider" && args[1] === "delete": + testState.attachedProviders.delete(args[2]); + testState.providers.delete(args[2]); + return { status: 0, stdout: "Deleted provider", stderr: "" }; + default: + throw new Error(`Unexpected OpenShell call: ${args.join(" ")}`); + } + }); + + testState.executeSandboxCommand.mockImplementation((_sandbox: string, command: string) => { + testState.adapterCalls.push(command); + switch (true) { + case command.includes("'config' 'add'"): + testState.adapterRegistered = true; + return { status: 0, stdout: "", stderr: "" }; + case command.includes('["config", "remove"'): + testState.adapterRegistered = false; + return { status: 0, stdout: "", stderr: "" }; + case command.includes('["config", "get"'): + return { + status: 0, + stdout: testState.adapterRegistered ? "registered\n" : "absent\n", + stderr: "", + }; + default: + return { + status: 0, + stdout: command === "command -v mcporter" ? "/usr/local/bin/mcporter\n" : "", + stderr: "", + }; + } + }); + + testState.executeSandboxExecCommand.mockImplementation((_sandbox: string, command: string) => { + const encoded = command.match(/printf '%s' '([A-Za-z0-9+/=]+)' \| base64 -d/)?.[1] ?? ""; + const proof = encoded ? Buffer.from(encoded, "base64").toString("utf8") : command; + const isRevisionObservation = proof.includes("printf '%s\\n' absent"); + const observedCredential = proof.includes("openshell:resolve:env:GITHUB_TOKEN") + ? "GITHUB_TOKEN" + : proof.includes("openshell:resolve:env:SLACK_TOKEN") + ? "SLACK_TOKEN" + : null; + const credentialAttached = + observedCredential !== null && + [...testState.attachedProviders].some( + (providerName) => testState.providers.get(providerName)?.credential === observedCredential, + ); return { - status: 0, - stdout: adapterRegistered ? "registered\\n" : "absent\\n", + status: + proof.includes("allow_all_known_mcp_methods") || + proof.includes('[ -z "${') || + proof.includes("openshell:resolve:env:GITHUB_TOKEN") || + proof.includes("openshell:resolve:env:SLACK_TOKEN") + ? 0 + : 1, + stdout: isRevisionObservation ? (credentialAttached ? "canonical" : "absent") : "", stderr: "", }; - } - return { - status: 0, - stdout: command === "command -v mcporter" ? "/usr/local/bin/mcporter\\n" : "", - stderr: "", - }; -}; -processRecovery.executeSandboxExecCommand = (_sandbox, command) => { - const encoded = command.match(/printf '%s' '([A-Za-z0-9+/=]+)' \| base64 -d/)?.[1] || ""; - const proof = encoded ? Buffer.from(encoded, "base64").toString("utf8") : command; - const isRevisionObservation = proof.includes("printf '%s\\\\n' absent"); - const observedCredential = proof.includes("openshell:resolve:env:GITHUB_TOKEN") - ? "GITHUB_TOKEN" - : proof.includes("openshell:resolve:env:SLACK_TOKEN") - ? "SLACK_TOKEN" - : null; - const credentialAttached = - observedCredential !== null && - [...attachedProviders].some( - (providerName) => providers.get(providerName)?.credential === observedCredential, - ); - return { - status: - proof.includes("allow_all_known_mcp_methods") || - proof.includes('[ -z "\${') || - proof.includes("openshell:resolve:env:GITHUB_TOKEN") || - proof.includes("openshell:resolve:env:SLACK_TOKEN") - ? 0 - : 1, - stdout: isRevisionObservation ? (credentialAttached ? "canonical" : "absent") : "", - stderr: "", - }; -}; - -const bridgeEntry = (server, credential) => ({ - server, - agent: "openclaw", - adapter: "mcporter", - url: "https://8.8.8.8/" + server, - env: [credential], - providerName: "alpha-mcp-" + server, - providerId: providers.get("alpha-mcp-" + server).id, - policyName: "mcp-bridge-" + server, - addedAt: "2026-06-27T00:00:00.000Z", + }); }); -const bridgeEntries = { - github: bridgeEntry("github", "GITHUB_TOKEN"), - slack: bridgeEntry("slack", "SLACK_TOKEN"), -}; -const ownedPolicy = (server) => ({ - name: "mcp-bridge-" + server, - content: "network_policies: {}\\n", - sourcePath: "generated:nemoclaw-mcp-bridge", + +afterAll(() => { + fs.rmSync(testState.home, { recursive: true, force: true }); + for (const [name, value] of Object.entries(testState.originalEnv)) restoreEnv(name, value); }); -${body} -`; - const result = spawnSync(process.execPath, ["-e", script], { - cwd: process.cwd(), - encoding: "utf8", - env: { ...process.env, HOME: home, NEMOCLAW_OPENSHELL_BIN: MATCHING_OPENSHELL }, - }); - fs.rmSync(home, { recursive: true, force: true }); - return result; -} describe("authenticated MCP sandbox destroy lifecycle", () => { for (const method of [ "prepareMcpBridgesForAbsentSandboxDestroy", "prepareMcpBridgesForAbsentSandboxRebuild", ] as const) { - it(`clears a providerless preflighted add during ${method}`, () => { - const result = runDestroyLifecycleScenario(` -providers.delete("alpha-mcp-github"); -attachedProviders.delete("alpha-mcp-github"); -const pending = { ...bridgeEntries.github, addState: "preflighted" }; -delete pending.providerId; -registry.registerSandbox({ - name: "alpha", - agent: "openclaw", - mcp: { bridges: { github: pending } }, -}); -registry.addCustomPolicy("alpha", ownedPolicy("github")); -policies.getPresetContentGatewayState = () => { throw new Error("absent rebuild queried live policy"); }; -const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); -(async () => { - const preparation = await bridge.${method}("alpha"); - process.stdout.write(JSON.stringify({ preparation, sandbox: registry.getSandbox("alpha") })); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout) as { - preparation: { entries: unknown[] }; - sandbox: { mcp?: unknown; customPolicies?: unknown }; - }; - expect(payload.preparation.entries).toEqual([]); - expect(payload.sandbox.mcp).toBeUndefined(); - expect(payload.sandbox.customPolicies).toBeUndefined(); + it(`clears a providerless preflighted add during ${method}`, async () => { + testState.providers.delete("alpha-mcp-github"); + testState.attachedProviders.delete("alpha-mcp-github"); + const pending: McpBridgeEntry = { ...bridgeEntries.github, addState: "preflighted" }; + delete pending.providerId; + registry.registerSandbox({ + name: "alpha", + agent: "openclaw", + mcp: { bridges: { github: pending } }, + }); + registry.addCustomPolicy("alpha", ownedPolicy("github")); + testState.getPresetContentGatewayState.mockImplementation(() => { + throw new Error("absent rebuild queried live policy"); + }); + + const preparation = await bridge[method]("alpha"); + const sandbox = registry.getSandbox("alpha"); + + expect(preparation.entries).toEqual([]); + expect(sandbox?.mcp).toBeUndefined(); + expect(sandbox?.customPolicies).toBeUndefined(); }); } @@ -229,691 +315,393 @@ const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); "prepareMcpBridgesForAbsentSandboxRebuild", ] as const) { for (const marker of ["destroyPreparedAt", "destroyPendingAt"] as const) { - it(`rejects ${method} while ${marker} is durable`, () => { - const result = runDestroyLifecycleScenario(` -registry.registerSandbox({ - name: "alpha", - agent: "openclaw", - gatewayName: "nemoclaw", - mcp: { - bridges: { github: bridgeEntries.github }, - ${marker}: "2026-07-02T22:49:42.000Z", - }, -}); -registry.addCustomPolicy("alpha", ownedPolicy("github")); -const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); -(async () => { - let message = ""; - try { - await bridge.${method}("alpha"); - } catch (error) { - message = error.message; - } - process.stdout.write(JSON.stringify({ - message, - sandbox: registry.getSandbox("alpha"), - calls, - adapterCalls, - })); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout) as { - message: string; - sandbox: { mcp: Record }; - calls: string[]; - adapterCalls: string[]; - }; - expect(payload.message).toContain("incomplete MCP destroy transaction"); - expect(payload.sandbox.mcp).toHaveProperty(marker); - expect(payload.calls).toEqual([]); - expect(payload.adapterCalls).toEqual([]); + it(`rejects ${method} while ${marker} is durable`, async () => { + registry.registerSandbox({ + name: "alpha", + agent: "openclaw", + gatewayName: "nemoclaw", + mcp: { + bridges: { github: bridgeEntries.github }, + [marker]: "2026-07-02T22:49:42.000Z", + }, + }); + registry.addCustomPolicy("alpha", ownedPolicy("github")); + + const message = await captureMessage(() => bridge[method]("alpha")); + const sandbox = registry.getSandbox("alpha"); + + expect(message).toContain("incomplete MCP destroy transaction"); + expect(sandbox?.mcp).toHaveProperty(marker); + expect(testState.calls).toEqual([]); + expect(testState.adapterCalls).toEqual([]); }); } } - it("prepares an absent-sandbox rebuild without adapter exec or provider detach", () => { - const result = runDestroyLifecycleScenario(` -delete process.env.GITHUB_TOKEN; -registry.registerSandbox({ - name: "alpha", - agent: "openclaw", - gatewayName: "nemoclaw", - mcp: { bridges: { github: bridgeEntries.github } }, -}); -registry.addCustomPolicy("alpha", ownedPolicy("github")); -policies.getPresetContentGatewayState = () => { throw new Error("absent rebuild queried live policy"); }; -const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); -(async () => { - const preparation = await bridge.prepareMcpBridgesForAbsentSandboxRebuild("alpha"); - process.stdout.write(JSON.stringify({ - preparation, - providers: [...providers.keys()], - calls, - adapterCalls, - })); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout) as { - preparation: { - entries: unknown[]; - detachedProviderEntries: unknown[]; - scrubbedAdapterEntries: unknown[]; - }; - providers: string[]; - calls: string[]; - adapterCalls: string[]; - }; - expect(payload.preparation.entries).toHaveLength(1); - expect(payload.preparation.detachedProviderEntries).toEqual([]); - expect(payload.preparation.scrubbedAdapterEntries).toEqual([]); - expect(payload.calls).toEqual(["provider get alpha-mcp-github"]); - expect(payload.adapterCalls).toEqual([]); - expect(payload.providers).toContain("alpha-mcp-github"); + it("prepares an absent-sandbox rebuild without adapter exec or provider detach", async () => { + registry.registerSandbox({ + name: "alpha", + agent: "openclaw", + gatewayName: "nemoclaw", + mcp: { bridges: { github: bridgeEntries.github } }, + }); + registry.addCustomPolicy("alpha", ownedPolicy("github")); + testState.getPresetContentGatewayState.mockImplementation(() => { + throw new Error("absent rebuild queried live policy"); + }); + + const preparation = await bridge.prepareMcpBridgesForAbsentSandboxRebuild("alpha"); + + expect(preparation.entries).toHaveLength(1); + expect(preparation.detachedProviderEntries).toEqual([]); + expect(preparation.scrubbedAdapterEntries).toEqual([]); + expect(testState.calls).toEqual(["provider get alpha-mcp-github"]); + expect(testState.adapterCalls).toEqual([]); + expect([...testState.providers.keys()]).toContain("alpha-mcp-github"); }); - for (const method of ["prepareMcpBridgesForRebuild"] as const) { - it(`rejects policy drift before ${method} mutates adapter or provider state`, () => { - const result = runDestroyLifecycleScenario(` -registry.registerSandbox({ - name: "alpha", - agent: "openclaw", - gatewayName: "nemoclaw", - mcp: { bridges: { github: bridgeEntries.github } }, -}); -registry.addCustomPolicy("alpha", ownedPolicy("github")); -policies.getPresetContentGatewayState = () => "drift"; -const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); -(async () => { - let message = ""; - try { - await bridge.${method}("alpha"); - } catch (error) { - message = error.message; - } - process.stdout.write(JSON.stringify({ message, calls, adapterCalls })); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout) as { - message: string; - calls: string[]; - adapterCalls: string[]; - }; - expect(payload.message).toMatch(/policy.*drift/i); - expect(payload.calls).toEqual([]); - expect(payload.adapterCalls).toEqual([]); + it("rejects policy drift before prepareMcpBridgesForRebuild mutates adapter or provider state", async () => { + registry.registerSandbox({ + name: "alpha", + agent: "openclaw", + gatewayName: "nemoclaw", + mcp: { bridges: { github: bridgeEntries.github } }, }); - } + registry.addCustomPolicy("alpha", ownedPolicy("github")); + testState.getPresetContentGatewayState.mockReturnValue("drift"); - it("rejects an unowned same-name policy record during absent-sandbox rebuild", () => { - const result = runDestroyLifecycleScenario(` -registry.registerSandbox({ - name: "alpha", - agent: "openclaw", - gatewayName: "nemoclaw", - mcp: { bridges: { github: bridgeEntries.github } }, -}); -registry.addCustomPolicy("alpha", { - ...ownedPolicy("github"), - content: "operator-owned-content", - sourcePath: "/operator/policy.yaml", -}); -policies.getPresetContentGatewayState = () => { throw new Error("absent rebuild queried live policy"); }; -const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); -(async () => { - let message = ""; - try { - await bridge.prepareMcpBridgesForAbsentSandboxRebuild("alpha"); - } catch (error) { - message = error.message; - } - process.stdout.write(JSON.stringify({ message, calls, adapterCalls })); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout) as { - message: string; - calls: string[]; - adapterCalls: string[]; - }; - expect(payload.message).toMatch(/unowned same-name registry record/); - expect(payload.calls).toEqual([]); - expect(payload.adapterCalls).toEqual([]); + const message = await captureMessage(() => bridge.prepareMcpBridgesForRebuild("alpha")); + + expect(message).toMatch(/policy.*drift/i); + expect(testState.calls).toEqual([]); + expect(testState.adapterCalls).toEqual([]); }); - it("finalizes an externally absent sandbox without attempting sandbox adapter exec", () => { - const result = runDestroyLifecycleScenario(` -registry.registerSandbox({ - name: "alpha", - agent: "openclaw", - mcp: { - bridges: { github: bridgeEntries.github }, - managedServerNames: ["github", "retired"], - }, -}); -registry.addCustomPolicy("alpha", ownedPolicy("github")); -const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); -(async () => { - const preparation = await bridge.prepareMcpBridgesForAbsentSandboxDestroy("alpha"); - await bridge.finalizeMcpBridgesAfterSandboxDelete("alpha", preparation); - process.stdout.write(JSON.stringify({ - preparation, - sandbox: registry.getSandbox("alpha"), - providers: [...providers.keys()], - calls, - adapterCalls, - })); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout) as { - preparation: { entries: unknown[] }; - sandbox: { mcp?: unknown; customPolicies?: unknown }; - providers: string[]; - calls: string[]; - adapterCalls: string[]; - }; - expect(payload.preparation.entries).toHaveLength(1); - expect(payload.adapterCalls).toEqual([]); - expect(payload.calls.some((call) => call.includes("sandbox provider"))).toBe(false); - expect(payload.providers).not.toContain("alpha-mcp-github"); - expect(payload.sandbox.mcp).toBeUndefined(); - expect(payload.sandbox.customPolicies).toBeUndefined(); + it("rejects an unowned same-name policy record during absent-sandbox rebuild", async () => { + registry.registerSandbox({ + name: "alpha", + agent: "openclaw", + gatewayName: "nemoclaw", + mcp: { bridges: { github: bridgeEntries.github } }, + }); + registry.addCustomPolicy("alpha", { + ...ownedPolicy("github"), + content: "operator-owned-content", + sourcePath: "/operator/policy.yaml", + }); + testState.getPresetContentGatewayState.mockImplementation(() => { + throw new Error("absent rebuild queried live policy"); + }); + + const message = await captureMessage(() => + bridge.prepareMcpBridgesForAbsentSandboxRebuild("alpha"), + ); + + expect(message).toMatch(/unowned same-name registry record/); + expect(testState.calls).toEqual([]); + expect(testState.adapterCalls).toEqual([]); }); - it("restores policy, attachment, and adapter without rotating an exported host secret", () => { - const result = runDestroyLifecycleScenario(` -process.env.GITHUB_TOKEN = "ambient-value-that-must-not-rotate"; -registry.registerSandbox({ - name: "alpha", - agent: "openclaw", - mcp: { - bridges: { github: bridgeEntries.github }, - managedServerNames: ["github", "retired"], - }, -}); -registry.addCustomPolicy("alpha", ownedPolicy("github")); -const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); -(async () => { - const preparation = await bridge.prepareMcpBridgesForDestroy("alpha"); - await bridge.restoreMcpBridgesAfterDestroyAbort("alpha", preparation); - process.stdout.write(JSON.stringify({ - sandbox: registry.getSandbox("alpha"), - providers: [...providers.keys()], - calls, - adapterCalls, - policyApplyCalls, - secretPresent: Object.prototype.hasOwnProperty.call(process.env, "GITHUB_TOKEN"), - })); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout.slice(result.stdout.indexOf("{"))) as { - sandbox: { - mcp: { - bridges: Record; - managedServerNames?: string[]; - destroyPreparedAt?: string; - destroyPendingAt?: string; - }; - }; - providers: string[]; - calls: string[]; - adapterCalls: string[]; - policyApplyCalls: number; - secretPresent: boolean; - }; - expect(payload.secretPresent).toBe(true); - expect(payload.providers).toContain("alpha-mcp-github"); + it("finalizes an externally absent sandbox without attempting sandbox adapter exec", async () => { + registry.registerSandbox({ + name: "alpha", + agent: "openclaw", + mcp: { + bridges: { github: bridgeEntries.github }, + managedServerNames: ["github", "retired"], + }, + }); + registry.addCustomPolicy("alpha", ownedPolicy("github")); + + const preparation = await bridge.prepareMcpBridgesForAbsentSandboxDestroy("alpha"); + await bridge.finalizeMcpBridgesAfterSandboxDelete("alpha", preparation); + const sandbox = registry.getSandbox("alpha"); + + expect(preparation.entries).toHaveLength(1); + expect(testState.adapterCalls).toEqual([]); + expect(testState.calls.some((call) => call.includes("sandbox provider"))).toBe(false); + expect([...testState.providers.keys()]).not.toContain("alpha-mcp-github"); + expect(sandbox?.mcp).toBeUndefined(); + expect(sandbox?.customPolicies).toBeUndefined(); + }); + + it("restores policy, attachment, and adapter without rotating an exported host secret", async () => { + process.env.GITHUB_TOKEN = "ambient-value-that-must-not-rotate"; + registry.registerSandbox({ + name: "alpha", + agent: "openclaw", + mcp: { + bridges: { github: bridgeEntries.github }, + managedServerNames: ["github", "retired"], + }, + }); + registry.addCustomPolicy("alpha", ownedPolicy("github")); + + const preparation = await bridge.prepareMcpBridgesForDestroy("alpha"); + await bridge.restoreMcpBridgesAfterDestroyAbort("alpha", preparation); + const sandbox = registry.getSandbox("alpha"); + + expect(Object.hasOwn(process.env, "GITHUB_TOKEN")).toBe(true); + expect([...testState.providers.keys()]).toContain("alpha-mcp-github"); expect( - payload.calls.some((call) => call === "sandbox provider attach alpha alpha-mcp-github"), + testState.calls.some((call) => call === "sandbox provider attach alpha alpha-mcp-github"), ).toBe(true); - expect(payload.calls.some((call) => /^provider (create|update) /.test(call))).toBe(false); - expect(payload.policyApplyCalls).toBe(1); - expect(payload.adapterCalls).toContain("command -v mcporter"); + expect(testState.calls.some((call) => /^provider (create|update) /.test(call))).toBe(false); + expect(testState.policyApplyCalls).toBe(1); + expect(testState.adapterCalls).toContain("command -v mcporter"); expect( - payload.adapterCalls.some((call) => call.includes("openshell:resolve:env:GITHUB_TOKEN")), + testState.adapterCalls.some((call) => call.includes("openshell:resolve:env:GITHUB_TOKEN")), ).toBe(true); - expect(payload.sandbox.mcp.bridges).toHaveProperty("github"); - expect(payload.sandbox.mcp.managedServerNames).toEqual(["github", "retired"]); - expect(payload.sandbox.mcp.destroyPreparedAt).toBeUndefined(); - expect(payload.sandbox.mcp.destroyPendingAt).toBeUndefined(); + expect(sandbox?.mcp?.bridges).toHaveProperty("github"); + expect(sandbox?.mcp?.managedServerNames).toEqual(["github", "retired"]); + expect(sandbox?.mcp?.destroyPreparedAt).toBeUndefined(); + expect(sandbox?.mcp?.destroyPendingAt).toBeUndefined(); }); - it("restores the durable destroy marker when abort rollback fails", () => { - const result = runDestroyLifecycleScenario(` -registry.registerSandbox({ - name: "alpha", - agent: "openclaw", - mcp: { - bridges: { github: bridgeEntries.github }, - managedServerNames: ["github", "retired"], - }, -}); -registry.addCustomPolicy("alpha", ownedPolicy("github")); -const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); -(async () => { - const preparation = await bridge.prepareMcpBridgesForDestroy("alpha"); - policies.applyPresetContent = () => false; - let error = ""; - try { - await bridge.restoreMcpBridgesAfterDestroyAbort("alpha", preparation); - } catch (caught) { - error = caught instanceof Error ? caught.message : String(caught); - } - process.stdout.write(JSON.stringify({ - error, - sandbox: registry.getSandbox("alpha"), - attached: [...attachedProviders], - adapterRegistered, - })); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout) as { - error: string; - sandbox: { - mcp: { - bridges: Record; - managedServerNames?: string[]; - destroyPreparedAt?: string; - }; - }; - attached: string[]; - adapterRegistered: boolean; - }; - expect(payload.error).toMatch(/failed to activate generated MCP policy/i); - expect(payload.sandbox.mcp.bridges).toHaveProperty("github"); - expect(payload.sandbox.mcp.managedServerNames).toEqual(["github", "retired"]); - expect(payload.sandbox.mcp.destroyPreparedAt).toBeTruthy(); - expect(payload.attached).not.toContain("alpha-mcp-github"); - expect(payload.adapterRegistered).toBe(false); + it("restores the durable destroy marker when abort rollback fails", async () => { + registry.registerSandbox({ + name: "alpha", + agent: "openclaw", + mcp: { + bridges: { github: bridgeEntries.github }, + managedServerNames: ["github", "retired"], + }, + }); + registry.addCustomPolicy("alpha", ownedPolicy("github")); + + const preparation = await bridge.prepareMcpBridgesForDestroy("alpha"); + testState.applyPresetContent.mockReturnValue(false); + const error = await captureMessage(() => + bridge.restoreMcpBridgesAfterDestroyAbort("alpha", preparation), + ); + const sandbox = registry.getSandbox("alpha"); + + expect(error).toMatch(/failed to activate generated MCP policy/i); + expect(sandbox?.mcp?.bridges).toHaveProperty("github"); + expect(sandbox?.mcp?.managedServerNames).toEqual(["github", "retired"]); + expect(sandbox?.mcp?.destroyPreparedAt).toBeTruthy(); + expect([...testState.attachedProviders]).not.toContain("alpha-mcp-github"); + expect(testState.adapterRegistered).toBe(false); }); - it("preserves credentials and bridge state until sandbox deletion is confirmed", () => { - const result = runDestroyLifecycleScenario(` -registry.registerSandbox({ - name: "alpha", - agent: "openclaw", - mcp: { bridges: { github: bridgeEntries.github } }, -}); -registry.addCustomPolicy("alpha", ownedPolicy("github")); -registry.addCustomPolicy("alpha", { name: "operator", content: "version: 1\\n" }); -const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); -(async () => { - const preparation = await bridge.prepareMcpBridgesForDestroy("alpha"); - const afterPrepare = registry.getSandbox("alpha"); - await bridge.finalizeMcpBridgesAfterSandboxDelete("alpha", preparation); - const afterFinalize = registry.getSandbox("alpha"); - process.stdout.write(JSON.stringify({ - afterPrepare, - afterFinalize, - providers: [...providers.keys()], - calls, - adapterCalls, - })); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout) as { - afterPrepare: { - mcp: { - bridges: Record; - destroyPreparedAt?: string; - destroyPendingAt?: string; - }; - customPolicies: Array<{ name: string }>; - }; - afterFinalize: { - mcp?: unknown; - customPolicies: Array<{ name: string }>; - }; - providers: string[]; - calls: string[]; - adapterCalls: string[]; - }; - expect(payload.afterPrepare.mcp.bridges).toHaveProperty("github"); - expect(payload.afterPrepare.mcp.destroyPreparedAt).toBeTruthy(); - expect(payload.afterPrepare.mcp.destroyPendingAt).toBeUndefined(); - expect(payload.afterPrepare.customPolicies.map((policy) => policy.name)).toContain( + it("preserves credentials and bridge state until sandbox deletion is confirmed", async () => { + registry.registerSandbox({ + name: "alpha", + agent: "openclaw", + mcp: { bridges: { github: bridgeEntries.github } }, + }); + registry.addCustomPolicy("alpha", ownedPolicy("github")); + registry.addCustomPolicy("alpha", { name: "operator", content: "version: 1\n" }); + + const preparation = await bridge.prepareMcpBridgesForDestroy("alpha"); + const afterPrepare = registry.getSandbox("alpha"); + await bridge.finalizeMcpBridgesAfterSandboxDelete("alpha", preparation); + const afterFinalize = registry.getSandbox("alpha"); + + expect(afterPrepare?.mcp?.bridges).toHaveProperty("github"); + expect(afterPrepare?.mcp?.destroyPreparedAt).toBeTruthy(); + expect(afterPrepare?.mcp?.destroyPendingAt).toBeUndefined(); + expect(afterPrepare?.customPolicies?.map((policy) => policy.name)).toContain( "mcp-bridge-github", ); - expect(payload.afterFinalize.mcp).toBeUndefined(); - expect(payload.afterFinalize.customPolicies.map((policy) => policy.name)).toEqual(["operator"]); - expect(payload.providers).not.toContain("alpha-mcp-github"); + expect(afterFinalize?.mcp).toBeUndefined(); + expect(afterFinalize?.customPolicies?.map((policy) => policy.name)).toEqual(["operator"]); + expect([...testState.providers.keys()]).not.toContain("alpha-mcp-github"); expect( - payload.calls.some((call) => call === "sandbox provider detach alpha alpha-mcp-github"), + testState.calls.some((call) => call === "sandbox provider detach alpha alpha-mcp-github"), ).toBe(true); expect( - payload.adapterCalls.some((call) => call.includes("config") && call.includes("remove")), + testState.adapterCalls.some((call) => call.includes("config") && call.includes("remove")), ).toBe(true); }); - it("restores a rebuilt sandbox without rotating an exported MCP credential", () => { - const result = runDestroyLifecycleScenario(` -process.env.GITHUB_TOKEN = "ambient-value-that-must-not-rotate"; -attachedProviders.delete("alpha-mcp-github"); -adapterRegistered = false; -registry.registerSandbox({ - name: "alpha", - agent: "openclaw", - mcp: { bridges: { github: bridgeEntries.github } }, -}); -registry.addCustomPolicy("alpha", ownedPolicy("github")); -const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); -(async () => { - await bridge.restoreMcpBridgesAfterRebuild("alpha", [bridgeEntries.github]); - process.stdout.write(JSON.stringify({ - calls, - attached: [...attachedProviders], - adapterRegistered, - policyApplyCalls, - })); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout.slice(result.stdout.indexOf("{"))) as { - calls: string[]; - attached: string[]; - adapterRegistered: boolean; - policyApplyCalls: number; - }; - expect(payload.calls.some((call) => /^provider (create|update) /.test(call))).toBe(false); - expect(payload.attached).toContain("alpha-mcp-github"); - expect(payload.adapterRegistered).toBe(true); - expect(payload.policyApplyCalls).toBe(1); + it("restores a rebuilt sandbox without rotating an exported MCP credential", async () => { + process.env.GITHUB_TOKEN = "ambient-value-that-must-not-rotate"; + testState.attachedProviders.delete("alpha-mcp-github"); + testState.adapterRegistered = false; + registry.registerSandbox({ + name: "alpha", + agent: "openclaw", + mcp: { bridges: { github: bridgeEntries.github } }, + }); + registry.addCustomPolicy("alpha", ownedPolicy("github")); + + await bridge.restoreMcpBridgesAfterRebuild("alpha", [bridgeEntries.github]); + + expect(testState.calls.some((call) => /^provider (create|update) /.test(call))).toBe(false); + expect([...testState.attachedProviders]).toContain("alpha-mcp-github"); + expect(testState.adapterRegistered).toBe(true); + expect(testState.policyApplyCalls).toBe(1); }); for (const [label, prepareFunction] of [ ["destroy", "prepareMcpBridgesForDestroy"], ["rebuild", "prepareMcpBridgesForRebuild"], ] as const) { - it(`reattaches an already-absent first provider when a later ${label} detach fails`, () => { - const result = runDestroyLifecycleScenario(` -registry.registerSandbox({ - name: "alpha", - agent: "openclaw", - gatewayName: "nemoclaw", - mcp: { bridges: bridgeEntries }, -}); -registry.addCustomPolicy("alpha", ownedPolicy("github")); -registry.addCustomPolicy("alpha", ownedPolicy("slack")); -// Simulate a prior process dying after the first detach but before a durable -// prepared marker. The retry must own rollback of this already-absent binding. -attachedProviders.delete("alpha-mcp-github"); -failProviderDetach = "alpha-mcp-slack"; -const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); -(async () => { - let message = ""; - try { - await bridge.${prepareFunction}("alpha"); - } catch (error) { - message = error.message; - } - process.stdout.write(JSON.stringify({ - message, - attached: [...attachedProviders].sort(), - calls, - adapterRegistered, - })); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout) as { - message: string; - attached: string[]; - calls: string[]; - adapterRegistered: boolean; - }; - expect(payload.message).toContain("provider detach failed"); - expect(payload.attached).toEqual(["alpha-mcp-github", "alpha-mcp-slack"]); + it(`reattaches an already-absent first provider when a later ${label} detach fails`, async () => { + registry.registerSandbox({ + name: "alpha", + agent: "openclaw", + gatewayName: "nemoclaw", + mcp: { bridges: bridgeEntries }, + }); + registry.addCustomPolicy("alpha", ownedPolicy("github")); + registry.addCustomPolicy("alpha", ownedPolicy("slack")); + // Simulate a prior process dying after the first detach but before a durable + // prepared marker. The retry must own rollback of this already-absent binding. + testState.attachedProviders.delete("alpha-mcp-github"); + testState.failProviderDetach = "alpha-mcp-slack"; + + const message = await captureMessage(() => bridge[prepareFunction]("alpha")); + + expect(message).toContain("provider detach failed"); + expect([...testState.attachedProviders].sort()).toEqual([ + "alpha-mcp-github", + "alpha-mcp-slack", + ]); expect( - payload.calls.some((call) => call === "sandbox provider attach alpha alpha-mcp-github"), + testState.calls.some((call) => call === "sandbox provider attach alpha alpha-mcp-github"), ).toBe(true); - expect(payload.adapterRegistered).toBe(true); + expect(testState.adapterRegistered).toBe(true); }); } - it("reattaches every desired provider when rebuild deletion aborts after a retry", () => { - const result = runDestroyLifecycleScenario(` -registry.registerSandbox({ - name: "alpha", - agent: "openclaw", - gatewayName: "nemoclaw", - mcp: { bridges: bridgeEntries }, -}); -registry.addCustomPolicy("alpha", ownedPolicy("github")); -registry.addCustomPolicy("alpha", ownedPolicy("slack")); -// The first rebuild process died after detaching github. A retry completes -// preparation, then sandbox deletion is modeled as failed by invoking abort. -attachedProviders.delete("alpha-mcp-github"); -const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); -(async () => { - const preparation = await bridge.prepareMcpBridgesForRebuild("alpha"); - const detachedBeforeAbort = [...attachedProviders].sort(); - await bridge.reattachMcpProvidersAfterRebuildAbort( - "alpha", - preparation.detachedProviderEntries, - preparation.scrubbedAdapterEntries, - ); - process.stdout.write(JSON.stringify({ - preparation, - detachedBeforeAbort, - attachedAfterAbort: [...attachedProviders].sort(), - calls, - adapterRegistered, - })); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout) as { - preparation: { detachedProviderEntries: unknown[] }; - detachedBeforeAbort: string[]; - attachedAfterAbort: string[]; - calls: string[]; - adapterRegistered: boolean; - }; - expect(payload.preparation.detachedProviderEntries).toHaveLength(2); - expect(payload.detachedBeforeAbort).toEqual([]); - expect(payload.attachedAfterAbort).toEqual(["alpha-mcp-github", "alpha-mcp-slack"]); + it("reattaches every desired provider when rebuild deletion aborts after a retry", async () => { + registry.registerSandbox({ + name: "alpha", + agent: "openclaw", + gatewayName: "nemoclaw", + mcp: { bridges: bridgeEntries }, + }); + registry.addCustomPolicy("alpha", ownedPolicy("github")); + registry.addCustomPolicy("alpha", ownedPolicy("slack")); + // The first rebuild process died after detaching github. A retry completes + // preparation, then sandbox deletion is modeled as failed by invoking abort. + testState.attachedProviders.delete("alpha-mcp-github"); + + const preparation = await bridge.prepareMcpBridgesForRebuild("alpha"); + const detachedBeforeAbort = [...testState.attachedProviders].sort(); + await bridge.reattachMcpProvidersAfterRebuildAbort( + "alpha", + preparation.detachedProviderEntries, + preparation.scrubbedAdapterEntries, + ); + + expect(preparation.detachedProviderEntries).toHaveLength(2); + expect(detachedBeforeAbort).toEqual([]); + expect([...testState.attachedProviders].sort()).toEqual([ + "alpha-mcp-github", + "alpha-mcp-slack", + ]); expect( - payload.calls.some((call) => call === "sandbox provider attach alpha alpha-mcp-github"), + testState.calls.some((call) => call === "sandbox provider attach alpha alpha-mcp-github"), ).toBe(true); - expect(payload.adapterRegistered).toBe(true); + expect(testState.adapterRegistered).toBe(true); }); - it("keeps a pending manifest after partial provider deletion and completes on retry", () => { - const result = runDestroyLifecycleScenario(` -registry.registerSandbox({ - name: "alpha", - agent: "openclaw", - mcp: { - bridges: bridgeEntries, - managedServerNames: ["github", "retired", "slack"], - }, -}); -registry.addCustomPolicy("alpha", ownedPolicy("github")); -registry.addCustomPolicy("alpha", ownedPolicy("slack")); -const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); -(async () => { - const preparation = await bridge.prepareMcpBridgesForDestroy("alpha"); - failProviderDelete = "alpha-mcp-slack"; - let firstError = ""; - try { - await bridge.finalizeMcpBridgesAfterSandboxDelete("alpha", preparation, { force: true }); - } catch (error) { - firstError = error.message; - } - const afterFailure = registry.getSandbox("alpha"); - failProviderDelete = null; - const retry = await bridge.prepareMcpBridgesForDestroy("alpha"); - await bridge.finalizeMcpBridgesAfterSandboxDelete("alpha", retry, { force: true }); - process.stdout.write(JSON.stringify({ - firstError, - afterFailure, - retry, - afterRetry: registry.getSandbox("alpha"), - providers: [...providers.keys()], - calls, - })); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout) as { - firstError: string; - afterFailure: { - mcp: { - bridges: Record; - managedServerNames?: string[]; - destroyPreparedAt?: string; - destroyPendingAt?: string; - }; - customPolicies: Array<{ name: string }>; - }; - retry: { destroyAlreadyPending: boolean }; - afterRetry: { mcp?: unknown; customPolicies?: unknown }; - providers: string[]; - calls: string[]; - }; - expect(payload.firstError).toContain("provider delete failed"); - expect(payload.afterFailure.mcp.destroyPendingAt).toBeTruthy(); - expect(payload.afterFailure.mcp.destroyPreparedAt).toBeUndefined(); - expect(payload.afterFailure.mcp.managedServerNames).toEqual(["github", "retired", "slack"]); - expect(Object.keys(payload.afterFailure.mcp.bridges)).toEqual(["github", "slack"]); - expect(payload.afterFailure.customPolicies).toHaveLength(2); - expect(payload.retry.destroyAlreadyPending).toBe(true); - expect(payload.afterRetry.mcp).toBeUndefined(); - expect(payload.afterRetry.customPolicies).toBeUndefined(); - expect(payload.providers).toEqual([]); + it("keeps a pending manifest after partial provider deletion and completes on retry", async () => { + registry.registerSandbox({ + name: "alpha", + agent: "openclaw", + mcp: { + bridges: bridgeEntries, + managedServerNames: ["github", "retired", "slack"], + }, + }); + registry.addCustomPolicy("alpha", ownedPolicy("github")); + registry.addCustomPolicy("alpha", ownedPolicy("slack")); + + const preparation = await bridge.prepareMcpBridgesForDestroy("alpha"); + testState.failProviderDelete = "alpha-mcp-slack"; + const firstError = await captureMessage(() => + bridge.finalizeMcpBridgesAfterSandboxDelete("alpha", preparation, { force: true }), + ); + const afterFailure = registry.getSandbox("alpha"); + testState.failProviderDelete = null; + const retry = await bridge.prepareMcpBridgesForDestroy("alpha"); + await bridge.finalizeMcpBridgesAfterSandboxDelete("alpha", retry, { force: true }); + const afterRetry = registry.getSandbox("alpha"); + + expect(firstError).toContain("provider delete failed"); + expect(afterFailure?.mcp?.destroyPendingAt).toBeTruthy(); + expect(afterFailure?.mcp?.destroyPreparedAt).toBeUndefined(); + expect(afterFailure?.mcp?.managedServerNames).toEqual(["github", "retired", "slack"]); + expect(Object.keys(afterFailure?.mcp?.bridges ?? {})).toEqual(["github", "slack"]); + expect(afterFailure?.customPolicies).toHaveLength(2); + expect(retry.destroyAlreadyPending).toBe(true); + expect(afterRetry?.mcp).toBeUndefined(); + expect(afterRetry?.customPolicies).toBeUndefined(); + expect([...testState.providers.keys()]).toEqual([]); expect( - payload.calls.filter((call) => call === "sandbox provider detach alpha alpha-mcp-github"), + testState.calls.filter((call) => call === "sandbox provider detach alpha alpha-mcp-github"), ).toHaveLength(1); }); - it("resumes from the durable prepared phase after delete-before-finalize interruption", () => { - const result = runDestroyLifecycleScenario(` -registry.registerSandbox({ - name: "alpha", - agent: "openclaw", - mcp: { bridges: { github: bridgeEntries.github } }, -}); -registry.addCustomPolicy("alpha", ownedPolicy("github")); -const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); -(async () => { - await bridge.prepareMcpBridgesForDestroy("alpha"); - const callsAfterFirstPrepare = calls.length; - const adapterCallsAfterFirstPrepare = adapterCalls.length; - const retry = await bridge.prepareMcpBridgesForDestroy("alpha"); - await bridge.finalizeMcpBridgesAfterSandboxDelete("alpha", retry); - process.stdout.write(JSON.stringify({ - callsAfterFirstPrepare, - adapterCallsAfterFirstPrepare, - calls, - adapterCalls, - retry, - sandbox: registry.getSandbox("alpha"), - providers: [...providers.keys()], - })); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout) as { - callsAfterFirstPrepare: number; - adapterCallsAfterFirstPrepare: number; - calls: string[]; - adapterCalls: string[]; - retry: { - destroyAlreadyPrepared: boolean; - destroyAlreadyPending: boolean; - }; - sandbox: { mcp?: unknown }; - providers: string[]; - }; - expect(payload.retry.destroyAlreadyPrepared).toBe(true); - expect(payload.retry.destroyAlreadyPending).toBe(false); + it("resumes from the durable prepared phase after delete-before-finalize interruption", async () => { + registry.registerSandbox({ + name: "alpha", + agent: "openclaw", + mcp: { bridges: { github: bridgeEntries.github } }, + }); + registry.addCustomPolicy("alpha", ownedPolicy("github")); + + await bridge.prepareMcpBridgesForDestroy("alpha"); + const callsAfterFirstPrepare = testState.calls.length; + const adapterCallsAfterFirstPrepare = testState.adapterCalls.length; + const retry = await bridge.prepareMcpBridgesForDestroy("alpha"); + await bridge.finalizeMcpBridgesAfterSandboxDelete("alpha", retry); + const sandbox = registry.getSandbox("alpha"); + + expect(retry.destroyAlreadyPrepared).toBe(true); + expect(retry.destroyAlreadyPending).toBe(false); expect( - payload.calls - .slice(0, payload.callsAfterFirstPrepare) + testState.calls + .slice(0, callsAfterFirstPrepare) .some((call) => call === "sandbox provider detach alpha alpha-mcp-github"), ).toBe(true); expect( - payload.calls - .slice(payload.callsAfterFirstPrepare) + testState.calls + .slice(callsAfterFirstPrepare) .filter((call) => call.includes("sandbox provider detach")), ).toEqual([]); - expect(payload.adapterCalls).toHaveLength(payload.adapterCallsAfterFirstPrepare); - expect(payload.sandbox.mcp).toBeUndefined(); - expect(payload.providers).not.toContain("alpha-mcp-github"); + expect(testState.adapterCalls).toHaveLength(adapterCallsAfterFirstPrepare); + expect(sandbox?.mcp).toBeUndefined(); + expect([...testState.providers.keys()]).not.toContain("alpha-mcp-github"); }); - it("does not let force delete a drifted global provider", () => { - const result = runDestroyLifecycleScenario(` -providers.set("alpha-mcp-github", { - credential: "OTHER_TOKEN", - id: "11111111-2222-4333-8444-555555555555", -}); -registry.registerSandbox({ - name: "alpha", - agent: "openclaw", - mcp: { - bridges: { github: bridgeEntries.github }, - destroyPendingAt: "2026-06-27T01:00:00.000Z", - }, -}); -registry.addCustomPolicy("alpha", ownedPolicy("github")); -const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); -(async () => { - const sandbox = registry.getSandbox("alpha"); - const preparation = { - entries: Object.values(sandbox.mcp.bridges), - detachedProviderEntries: [], - scrubbedAdapterEntries: [], - destroyAlreadyPrepared: false, - destroyAlreadyPending: true, - }; - let message = ""; - try { - await bridge.finalizeMcpBridgesAfterSandboxDelete("alpha", preparation, { force: true }); - } catch (error) { - message = error.message; - } - process.stdout.write(JSON.stringify({ - message, - sandbox: registry.getSandbox("alpha"), - providers: [...providers.keys()], - calls, - })); -})().catch((error) => { console.error(error); process.exit(1); }); -`); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout) as { - message: string; - sandbox: { mcp: { bridges: Record } }; - providers: string[]; - calls: string[]; + it("does not let force delete a drifted global provider", async () => { + testState.providers.set("alpha-mcp-github", { + credential: "OTHER_TOKEN", + id: "11111111-2222-4333-8444-555555555555", + }); + registry.registerSandbox({ + name: "alpha", + agent: "openclaw", + mcp: { + bridges: { github: bridgeEntries.github }, + destroyPendingAt: "2026-06-27T01:00:00.000Z", + }, + }); + registry.addCustomPolicy("alpha", ownedPolicy("github")); + const preparation = { + entries: [bridgeEntries.github], + detachedProviderEntries: [], + scrubbedAdapterEntries: [], + destroyAlreadyPrepared: false, + destroyAlreadyPending: true, }; - expect(payload.message).toContain("no longer exactly matches"); - expect(payload.message).toContain("--force does not delete"); - expect(payload.sandbox.mcp.bridges).toHaveProperty("github"); - expect(payload.providers).toContain("alpha-mcp-github"); - expect(payload.calls.some((call) => call.startsWith("provider delete alpha-mcp-github "))).toBe( - false, + + const message = await captureMessage(() => + bridge.finalizeMcpBridgesAfterSandboxDelete("alpha", preparation, { force: true }), ); + const sandbox = registry.getSandbox("alpha"); + + expect(message).toContain("no longer exactly matches"); + expect(message).toContain("--force does not delete"); + expect(sandbox?.mcp?.bridges).toHaveProperty("github"); + expect([...testState.providers.keys()]).toContain("alpha-mcp-github"); + expect( + testState.calls.some((call) => call.startsWith("provider delete alpha-mcp-github ")), + ).toBe(false); }); });