diff --git a/test/e2e/live/mcp-bridge-hermes-lifecycle.ts b/test/e2e/live/mcp-bridge-hermes-lifecycle.ts index 5044cc5e31a..541ca88eebc 100644 --- a/test/e2e/live/mcp-bridge-hermes-lifecycle.ts +++ b/test/e2e/live/mcp-bridge-hermes-lifecycle.ts @@ -189,6 +189,44 @@ export async function assertHermesManagedAddSurvivesLockedGatewayRestartAndState expectExitZero(shieldsDown, "unlock Hermes config for remaining managed MCP lifecycle"); } +/** + * Rebuild can outlive the inherited Shields-down timer and correctly return + * with lockdown restored. Normalize the posture first so an already-down + * sandbox cannot retain an almost-expired timer, then open a fresh window for + * the final managed MCP mutation. + */ +export async function reopenHermesMcpMaintenanceWindow( + host: HostCliClient, + sandboxName: string, +): Promise { + const shieldsUp = await host.nemoclaw([sandboxName, "shields", "up"], { + artifactName: "hermes-mcp-shields-up-before-post-rebuild-remove", + env: buildAvailabilityProbeEnv(), + redactionValues: [HOST_SECRET, ROTATED_HOST_SECRET], + timeoutMs: 3 * 60_000, + }); + expectExitZero(shieldsUp, "normalize Hermes shields before post-rebuild MCP removal"); + + const shieldsDown = await host.nemoclaw( + [ + sandboxName, + "shields", + "down", + "--timeout", + "15m", + "--reason", + "Post-rebuild MCP removal E2E", + ], + { + artifactName: "hermes-mcp-shields-down-before-post-rebuild-remove", + env: buildAvailabilityProbeEnv(), + redactionValues: [HOST_SECRET, ROTATED_HOST_SECRET], + timeoutMs: 3 * 60_000, + }, + ); + expectExitZero(shieldsDown, "open a fresh Hermes MCP maintenance window after rebuild"); +} + /** * Inject a first-reload failure around the packaged transaction helper, then * require its real rollback reload to restore the prior config, both integrity diff --git a/test/e2e/live/mcp-bridge.test.ts b/test/e2e/live/mcp-bridge.test.ts index 3524d9119d9..1cc045d5a4f 100644 --- a/test/e2e/live/mcp-bridge.test.ts +++ b/test/e2e/live/mcp-bridge.test.ts @@ -38,6 +38,7 @@ import { assertHermesManagedAddSurvivesLockedGatewayRestartAndStateLayout, assertHermesReloadRollback, assertHermesRemovalSurvivesGatewayRestart, + reopenHermesMcpMaintenanceWindow, } from "./mcp-bridge-hermes-lifecycle.ts"; import { buildMcpBridgeExactMainEnv, @@ -1226,6 +1227,7 @@ mcpBridgeShardTest("hermes")( artifactName: "hermes-real-mcp-tool-call-after-rebuild", expectedSecret: ROTATED_HOST_SECRET, }); + await reopenHermesMcpMaintenanceWindow(host, HERMES_SANDBOX_NAME); await removeBridgeAndAssertEmpty(host, sandbox, { agent: "hermes", adapter: "hermes-config", diff --git a/test/e2e/support/mcp-bridge-hermes-lifecycle.test.ts b/test/e2e/support/mcp-bridge-hermes-lifecycle.test.ts index a39d2a25223..bb87a202d11 100644 --- a/test/e2e/support/mcp-bridge-hermes-lifecycle.test.ts +++ b/test/e2e/support/mcp-bridge-hermes-lifecycle.test.ts @@ -3,8 +3,55 @@ import { describe, expect, it } from "vitest"; -import { SandboxClient } from "../fixtures/clients/sandbox.ts"; -import { assertHermesReloadRollback } from "../live/mcp-bridge-hermes-lifecycle.ts"; +import { type CommandRunner, HostCliClient, SandboxClient } from "../fixtures/clients/index.ts"; +import type { + ShellProbeResult, + ShellProbeRunOptions, + TrustedShellCommand, +} from "../fixtures/shell-probe.ts"; +import { + assertHermesReloadRollback, + reopenHermesMcpMaintenanceWindow, +} from "../live/mcp-bridge-hermes-lifecycle.ts"; + +interface RunnerCall { + command: string; + args: string[]; + options?: ShellProbeRunOptions; +} + +function shellResult(exitCode = 0): ShellProbeResult { + return { + command: [], + exitCode, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + artifacts: { + stdout: "/tmp/stdout", + stderr: "/tmp/stderr", + result: "/tmp/result", + }, + }; +} + +class RecordingRunner implements CommandRunner { + readonly calls: RunnerCall[] = []; + private readonly responses: ShellProbeResult[]; + + constructor(responses: ShellProbeResult[] = []) { + this.responses = [...responses]; + } + + async run( + command: TrustedShellCommand, + options?: ShellProbeRunOptions, + ): Promise { + this.calls.push({ command: command.command, args: [...command.args], options }); + return this.responses.shift() ?? shellResult(); + } +} function sandboxWithInspectionState(state: string): SandboxClient { return new SandboxClient({ @@ -48,3 +95,55 @@ describe("Hermes MCP live rollback inspection", () => { }); }); }); + +describe("Hermes MCP post-rebuild maintenance", () => { + it("opens a fresh Shields-down timer before the final config mutation", async () => { + const runner = new RecordingRunner(); + const host = new HostCliClient(runner, { cliPath: "nemoclaw" }); + + await reopenHermesMcpMaintenanceWindow(host, "hermes-e2e"); + + expect(runner.calls).toEqual([ + expect.objectContaining({ + command: "nemoclaw", + args: ["hermes-e2e", "shields", "up"], + options: expect.objectContaining({ + artifactName: "hermes-mcp-shields-up-before-post-rebuild-remove", + timeoutMs: 3 * 60_000, + }), + }), + expect.objectContaining({ + command: "nemoclaw", + args: [ + "hermes-e2e", + "shields", + "down", + "--timeout", + "15m", + "--reason", + "Post-rebuild MCP removal E2E", + ], + options: expect.objectContaining({ + artifactName: "hermes-mcp-shields-down-before-post-rebuild-remove", + timeoutMs: 3 * 60_000, + }), + }), + ]); + }); + + it("keeps Shields up when posture normalization fails", async () => { + const runner = new RecordingRunner([shellResult(1)]); + const host = new HostCliClient(runner, { cliPath: "nemoclaw" }); + + await expect(reopenHermesMcpMaintenanceWindow(host, "hermes-e2e")).rejects.toThrow( + "normalize Hermes shields before post-rebuild MCP removal failed: exit=1", + ); + + expect(runner.calls).toEqual([ + expect.objectContaining({ + command: "nemoclaw", + args: ["hermes-e2e", "shields", "up"], + }), + ]); + }); +});