Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions test/e2e/live/mcp-bridge-hermes-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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
Expand Down
2 changes: 2 additions & 0 deletions test/e2e/live/mcp-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
assertHermesManagedAddSurvivesLockedGatewayRestartAndStateLayout,
assertHermesReloadRollback,
assertHermesRemovalSurvivesGatewayRestart,
reopenHermesMcpMaintenanceWindow,
} from "./mcp-bridge-hermes-lifecycle.ts";
import {
buildMcpBridgeExactMainEnv,
Expand Down Expand Up @@ -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",
Expand Down
103 changes: 101 additions & 2 deletions test/e2e/support/mcp-bridge-hermes-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ShellProbeResult> {
this.calls.push({ command: command.command, args: [...command.args], options });
return this.responses.shift() ?? shellResult();
}
}

function sandboxWithInspectionState(state: string): SandboxClient {
return new SandboxClient({
Expand Down Expand Up @@ -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"],
}),
]);
});
});
Loading