Skip to content
5 changes: 4 additions & 1 deletion docs/manage-sandboxes/recover-rebuild-sandboxes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,10 @@ The rebuild command preserves the mounted workspace and registered policies whil
</AgentOnly>

<AgentOnly variant="hermes">
The rebuild command preserves Hermes state and registered policies while recreating the container.
The rebuild command preserves Hermes state, registered policies, and managed MCP configuration while recreating the container.
After state restore, NemoClaw restores managed MCP configuration through the normal lifecycle, then re-proves or recovers gateway health and performs final MCP reconciliation.
`rebuild` exits nonzero instead of reporting success when it cannot verify final gateway health or managed MCP state.
Follow the printed recovery guidance, using `$$nemoclaw <sandbox-name> recover` for gateway health and `$$nemoclaw <sandbox-name> mcp restart` for incomplete managed MCP restoration.
</AgentOnly>

<AgentOnly variant="deepagents">
Expand Down
181 changes: 181 additions & 0 deletions src/lib/actions/sandbox/rebuild-hermes-post-restore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createRebuildFlowHarness,
resetRebuildFlowTestEnvironment,
restoreRebuildFlowTestEnvironment,
} from "../../../../test/helpers/rebuild-flow-harness";

describe("Hermes rebuild post-restore verification", () => {
beforeEach(resetRebuildFlowTestEnvironment);
afterEach(restoreRebuildFlowTestEnvironment);

it("fails instead of reporting readiness when restored state leaves the gateway down (#7084)", async () => {
const mcpEntry = {
server: "blender",
providerName: "nemoclaw-mcp-alpha-blender",
};
const harness = createRebuildFlowHarness({
agentName: "hermes",
checkAndRecoverSandboxProcesses: () => ({
checked: true,
wasRunning: false,
recovered: false,
forwardRecovered: false,
}),
mcpPreparation: {
entries: [mcpEntry],
detachedProviderEntries: [mcpEntry],
scrubbedAdapterEntries: [mcpEntry],
},
sandboxEntry: { agent: "hermes" },
});
harness.restoreMcpBridgesAfterRebuildSpy.mockRejectedValueOnce(
new Error("gateway unavailable"),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

await expect(
harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }),
).rejects.toThrow("Hermes post-restore verification failed");

const output = harness.logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("rebuilt but some post-restore steps were incomplete");
expect(output).toContain("Hermes gateway health was not verified after state restore");
expect(output).toContain("MCP bridge definitions were preserved but not fully refreshed");
expect(output).not.toContain("rebuilt successfully");
expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith("alpha", [mcpEntry]);
});

it("accepts restored MCP configuration only after final gateway recovery (#7084)", async () => {
const mcpEntry = {
server: "blender",
providerName: "nemoclaw-mcp-alpha-blender",
};
const harness = createRebuildFlowHarness({
agentName: "hermes",
checkAndRecoverSandboxProcesses: () => ({
checked: true,
wasRunning: false,
recovered: true,
forwardRecovered: true,
}),
mcpPreparation: {
entries: [mcpEntry],
detachedProviderEntries: [mcpEntry],
scrubbedAdapterEntries: [mcpEntry],
},
sandboxEntry: { agent: "hermes" },
});

await expect(
harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }),
).resolves.toBeUndefined();

expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith("alpha", [mcpEntry]);
expect(harness.restoreMcpBridgesAfterRebuildSpy.mock.invocationCallOrder[0]).toBeLessThan(
harness.checkAndRecoverSandboxProcessesSpy.mock.invocationCallOrder[0],
);
expect(harness.logSpy).toHaveBeenCalledWith(
expect.stringContaining("Hermes gateway recovered after state restore"),
);
});

it("returns a failed rebuild when managed Hermes MCP restoration is incomplete (#7084)", async () => {
const mcpEntry = {
server: "blender",
providerName: "nemoclaw-mcp-alpha-blender",
};
const harness = createRebuildFlowHarness({
agentName: "hermes",
mcpPreparation: {
entries: [mcpEntry],
detachedProviderEntries: [mcpEntry],
scrubbedAdapterEntries: [mcpEntry],
},
sandboxEntry: { agent: "hermes" },
});
harness.restoreMcpBridgesAfterRebuildSpy.mockRejectedValueOnce(new Error("reload failed"));

await expect(
harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }),
).rejects.toThrow("Hermes post-restore verification failed");

const output = harness.logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("MCP bridge definitions were preserved but not fully refreshed");
expect(output).not.toContain("rebuilt successfully");
});

it("fails when the final gateway check refuses MCP reconciliation (#7084)", async () => {
const mcpEntry = {
server: "blender",
providerName: "nemoclaw-mcp-alpha-blender",
};
const harness = createRebuildFlowHarness({
agentName: "hermes",
checkAndRecoverSandboxProcesses: () => ({
checked: true,
wasRunning: true,
recovered: false,
forwardRecovered: false,
mcpReconciliationRefused: true,
}),
mcpPreparation: {
entries: [mcpEntry],
detachedProviderEntries: [mcpEntry],
scrubbedAdapterEntries: [mcpEntry],
},
sandboxEntry: { agent: "hermes" },
});

await expect(
harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }),
).rejects.toThrow("Hermes post-restore verification failed");

expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith("alpha", [mcpEntry]);
expect(harness.logSpy).not.toHaveBeenCalledWith(
expect.stringContaining("rebuilt successfully"),
);
});

it("fails before recovery when recreated Hermes identity is missing (#7084)", async () => {
const harness = createRebuildFlowHarness({
agentName: "hermes",
sessionAgentName: null,
sandboxEntry: { agent: "hermes" },
});

await expect(
harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }),
).rejects.toThrow(
"Recreated sandbox agent identity did not match the authoritative rebuild target",
);

expect(harness.checkAndRecoverSandboxProcessesSpy).not.toHaveBeenCalled();
expect(harness.restoreMcpBridgesAfterRebuildSpy).not.toHaveBeenCalled();
expect(harness.logSpy).not.toHaveBeenCalledWith(
expect.stringContaining("rebuilt successfully"),
);
});

it("fails before recovery when recreated Hermes identity mismatches (#7084)", async () => {
const harness = createRebuildFlowHarness({
agentName: "hermes",
sessionAgentName: "langchain-deepagents-code",
sandboxEntry: { agent: "hermes" },
});

await expect(
harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }),
).rejects.toThrow(
"Recreated sandbox agent identity did not match the authoritative rebuild target",
);

expect(harness.checkAndRecoverSandboxProcessesSpy).not.toHaveBeenCalled();
expect(harness.restoreMcpBridgesAfterRebuildSpy).not.toHaveBeenCalled();
expect(harness.logSpy).not.toHaveBeenCalledWith(
expect.stringContaining("rebuilt successfully"),
);
});
});
67 changes: 67 additions & 0 deletions src/lib/actions/sandbox/rebuild-hermes-post-restore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { CLI_NAME } from "../../cli/branding";
import * as processRecovery from "./process-recovery";

export type HermesPostRestoreGatewayState =
| "not-applicable"
| "healthy"
| "recovered"
| "unverified";

type GatewayRecoveryObservation = {
checked: boolean;
wasRunning: boolean | null;
recovered: boolean;
forwardRecoveryFailed?: boolean;
secretBoundaryRefused?: boolean;
mcpReconciliationRefused?: boolean;
};

interface HermesPostRestoreGatewayDeps {
checkAndRecoverSandboxProcesses?: (
sandboxName: string,
options: { quiet: boolean },
) => GatewayRecoveryObservation;
}

/**
* Re-prove Hermes gateway health after workspace state restoration.
*
* Inner onboarding verifies the fresh image before rebuild restores the prior
* state. That restore can still stop or wedge the gateway, so its earlier
* readiness message is not authoritative for rebuild completion.
*/
export function ensureHermesGatewayAfterStateRestore(
sandboxName: string,
agentName: string,
deps: HermesPostRestoreGatewayDeps = {},
): HermesPostRestoreGatewayState {
if (agentName !== "hermes") return "not-applicable";
const checkAndRecover =
deps.checkAndRecoverSandboxProcesses ?? processRecovery.checkAndRecoverSandboxProcesses;
const observation: GatewayRecoveryObservation = checkAndRecover(sandboxName, { quiet: true });
if (
!observation.checked ||
observation.forwardRecoveryFailed === true ||
observation.secretBoundaryRefused === true ||
observation.mcpReconciliationRefused === true
) {
return "unverified";
}
if (observation.wasRunning === true) return "healthy";
if (observation.recovered) return "recovered";
return "unverified";
}

export function printHermesGatewayRestoreRecovery(
sandboxName: string,
state: HermesPostRestoreGatewayState,
writeLine: (message: string) => void = console.log,
): void {
if (state !== "unverified") return;
writeLine(
` Hermes gateway health was not verified after state restore — run \`${CLI_NAME} ${sandboxName} recover\` before relying on this sandbox`,
);
}
2 changes: 2 additions & 0 deletions src/lib/actions/sandbox/rebuild-mcp-phase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export async function restoreMcpAfterRebuild(
}

export function postRestoreCompleted(status: {
hermesGatewayRestoreUnverified: boolean;
messagingHostForwardUnverified: boolean;
mcpBridgeRestoreUnverified: boolean;
mutableConfigHashRefreshUnverified: boolean;
Expand All @@ -135,6 +136,7 @@ export function postRestoreCompleted(status: {
}): boolean {
return (
status.restoreSucceeded &&
!status.hermesGatewayRestoreUnverified &&
!status.mutablePermsRepairUnverified &&
!status.mutableConfigHashRefreshUnverified &&
!status.messagingHostForwardUnverified &&
Expand Down
1 change: 1 addition & 0 deletions src/lib/actions/sandbox/rebuild-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ async function rebuildSandboxUnlocked(
await runRebuildPostRestorePhase({
sandboxName,
sandboxEntry,
targetAgentName: rebuildAgent || "openclaw",
messagingPlan,
backupManifest: backup.backupManifest,
mcpEntries: mcpPreparation.entries,
Expand Down
47 changes: 43 additions & 4 deletions src/lib/actions/sandbox/rebuild-post-restore-phase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import type { RebuildBackupManifest } from "./rebuild-backup-phase";
import { refreshMutableOpenClawConfigHashAfterPostRestoreWrites } from "./rebuild-config-hash";
import type { RebuildBail, RebuildLog } from "./rebuild-credential-preflight";
import type { RebuildSandboxEntry } from "./rebuild-flow-helpers";
import {
ensureHermesGatewayAfterStateRestore,
printHermesGatewayRestoreRecovery,
} from "./rebuild-hermes-post-restore";
import {
type McpRebuildPreparation,
postRestoreCompleted,
Expand All @@ -27,6 +31,7 @@ import { reapplyMessagingManifestAfterOpenClawDoctor } from "./rebuild-messaging
export interface RebuildPostRestorePhaseInput {
sandboxName: string;
sandboxEntry: RebuildSandboxEntry;
targetAgentName: string;
messagingPlan: SandboxMessagingPlan | null;
backupManifest: RebuildBackupManifest;
mcpEntries: McpRebuildPreparation["entries"];
Expand Down Expand Up @@ -102,6 +107,7 @@ export async function runRebuildPostRestorePhase(
const {
sandboxName,
sandboxEntry: sb,
targetAgentName,
messagingPlan,
backupManifest,
mcpEntries,
Expand All @@ -120,9 +126,23 @@ export async function runRebuildPostRestorePhase(
log,
bail,
} = input;
const rebuiltAgent = agentRuntime.getSessionAgent(sandboxName);
const rebuiltAgentName = agentRuntime.getAgentDisplayName(rebuiltAgent);
const agentDef = rebuiltAgent ? loadAgent(rebuiltAgent.name) : loadAgent("openclaw");
const recreatedEntry = registry.getSandbox(sandboxName);
const recreatedAgent = agentRuntime.getSessionAgent(sandboxName);
const recreatedRegistryAgentName = recreatedEntry?.agent ?? "openclaw";
const recreatedRuntimeAgentName = recreatedAgent?.name ?? "openclaw";
if (
!recreatedEntry ||
recreatedRegistryAgentName !== targetAgentName ||
recreatedRuntimeAgentName !== targetAgentName
) {
console.error(
` ${YW}\u26a0${R} Recreated sandbox agent identity could not be verified against the rebuild target.`,
);
bail("Recreated sandbox agent identity did not match the authoritative rebuild target.");
return;
}
const agentDef = loadAgent(targetAgentName);
const rebuiltAgentName = agentDef.displayName;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let mutablePermsRepairUnverified = false;
let mutableConfigHashRefreshUnverified = false;
let messagingHostForwardUnverified = false;
Expand All @@ -131,7 +151,7 @@ export async function runRebuildPostRestorePhase(
failedPresetRemovals.length > 0 ||
!policyPresetReconciliationVerified;

if (agentDef.name === "openclaw") {
if (targetAgentName === "openclaw") {
log("Running openclaw doctor --fix inside sandbox for post-upgrade structure repair");
const doctorResult = executeSandboxCommand(sandboxName, "openclaw doctor --fix");
log(
Expand Down Expand Up @@ -183,6 +203,16 @@ export async function runRebuildPostRestorePhase(
}

const mcpBridgeRestoreUnverified = !(await restoreMcpAfterRebuild(sandboxName, mcpEntries));
const hermesGatewayRestoreState = ensureHermesGatewayAfterStateRestore(
sandboxName,
targetAgentName,
);
const hermesGatewayRestoreUnverified = hermesGatewayRestoreState === "unverified";
if (hermesGatewayRestoreState === "healthy") {
console.log(` ${G}\u2713${R} Hermes gateway health verified after state restore`);
} else if (hermesGatewayRestoreState === "recovered") {
console.log(` ${G}\u2713${R} Hermes gateway recovered after state restore`);
}
const { policies: restoredBuiltinPresets, policyPresetsFinalized } =
resolveRestoredPolicyRegistryState(
{
Expand Down Expand Up @@ -212,6 +242,7 @@ export async function runRebuildPostRestorePhase(

console.log("");
const postRestoreComplete = postRestoreCompleted({
hermesGatewayRestoreUnverified,
messagingHostForwardUnverified,
mcpBridgeRestoreUnverified,
mutableConfigHashRefreshUnverified,
Expand Down Expand Up @@ -252,6 +283,7 @@ export async function runRebuildPostRestorePhase(
` Messaging webhook forward was not verified \u2014 run \`${CLI_NAME} ${sandboxName} connect\` after resolving the port conflict`,
);
}
printHermesGatewayRestoreRecovery(sandboxName, hermesGatewayRestoreState);
printMcpRestoreRecovery(sandboxName, mcpBridgeRestoreUnverified);
if (policyPresetRestoreIncomplete) {
if (failedPresets.length > 0) {
Expand All @@ -275,6 +307,13 @@ export async function runRebuildPostRestorePhase(
bail(`Rebuild completed with unverified live policy reconciliation for '${sandboxName}'.`);
return;
}
if (
targetAgentName === "hermes" &&
(hermesGatewayRestoreUnverified || mcpBridgeRestoreUnverified)
) {
bail(`Hermes post-restore verification failed for '${sandboxName}'.`);
return;
}
if (preparedBackupRecovery && !postRestoreComplete) {
bail(
`Prepared backup recovery for '${sandboxName}' completed with unverified post-restore state.`,
Expand Down
Loading
Loading