Skip to content
Closed
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
22 changes: 11 additions & 11 deletions docs/manage-sandboxes/recover-rebuild-sandboxes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,24 @@ $$nemoclaw <sandbox-name> start
```

This path preserves the sandbox workspace and repairs the agent runtime and host-side forwards after the container starts.
If the container is paused, follow the printed `docker unpause` guidance instead.
If the Docker container is paused, `start` unpauses it.
If the container is missing or OpenShell reports another terminal phase such as `Failed`, follow the printed `rebuild --yes` guidance so NemoClaw can recreate the sandbox from its recorded metadata.

<AgentOnly variant="openclaw,hermes">
For a Docker-driver sandbox, `start` binds managed recovery and verification to the existing container's immutable runtime identity.
The command preserves the current Shields posture and does not rebuild, replace, or destroy the container.
If another recovery layer fails, run the printed `recover` command before you retry `start`.

</AgentOnly>
<AgentOnly variant="openclaw">
If the sandbox has shields up and the OpenClaw gateway does not start after the container restarts, lower shields before you rebuild:
If an expired Shields auto-restore blocks locked startup access, finish the restore and retry `start`:

```bash
$$nemoclaw <sandbox-name> shields down
$$nemoclaw <sandbox-name> shields up
$$nemoclaw <sandbox-name> start
```

While holding the config mutation lock, NemoClaw confirms that no startup process runs and no readiness lease exists.
Only then does it accept `shields down`.
Other locked-config operations still require the lease.
This failed-startup recovery path requires a sandbox image that includes the in-container OpenClaw config and state guards.
If NemoClaw reports that the config guard is absent, upgrade the CLI.
Then rebuild the sandbox before you retry recovery.
After shields are down, start the sandbox again.
When the OpenClaw gateway is healthy, rerun `shields up`.
Use `shields down` only when you intend to change protected configuration.
</AgentOnly>

## Recover the Agent Runtime
Expand Down
4 changes: 3 additions & 1 deletion docs/manage-sandboxes/run-sandboxes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,10 @@ $$nemoclaw <sandbox-name> start
```

<AgentOnly variant="openclaw,hermes">
After Docker reports the existing container as running, NemoClaw checks the selected agent's managed startup state and recovers missing processes when needed.
After the lifecycle provider reports the existing runtime as running, NemoClaw checks the selected agent's managed startup state and recovers missing processes when needed.
NemoClaw completes this recovery before its final OpenShell readiness and host-forward checks.
For the Docker driver, the command binds recovery and verification to the existing container's immutable runtime identity.
It does not recreate the container.
</AgentOnly>
<AgentOnly variant="deepagents">
After Docker reports the existing container as running, NemoClaw verifies the managed terminal runtime before the command reports success.
Expand Down
23 changes: 23 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1538,6 +1538,29 @@ Starting an already-running sandbox skips the container start and still runs the
A paused container is unpaused.
If the container was removed entirely, `start` fails and points you to `$$nemoclaw <name> rebuild`.

<AgentOnly variant="openclaw,hermes">

For an OpenClaw or Hermes sandbox that uses the Docker driver, `start` binds recovery and verification to the existing container's immutable runtime identity.
The command does not rebuild, replace, or destroy that container.

`start` returns success only after these checks pass:

- The authenticated managed gateway is healthy in the same runtime identity.
- OpenShell reports the sandbox in the `Ready` phase.
- Every required host forward is active.
- A final authenticated managed-gateway check passes after inference-route verification and any applicable best-effort pairing pass.

If a check fails, the command exits nonzero, identifies the failed layer, and preserves the existing container.
A failure after the Docker start can leave that container running but unready.
For most recovery failures, run the printed `$$nemoclaw <name> recover` command before you retry `start`.

</AgentOnly>
<AgentOnly variant="openclaw">

If an expired Shields auto-restore blocks startup access, run `$$nemoclaw <name> shields up`, then retry `$$nemoclaw <name> start`.

</AgentOnly>

### `$$nemoclaw <name> status`

Show sandbox-scoped status, health, and inference configuration for one registered sandbox.
Expand Down
69 changes: 68 additions & 1 deletion src/lib/actions/sandbox/connect-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,7 @@ describe("connectSandbox flow", () => {

expect(harness.checkAndRecoverSpy).toHaveBeenCalledWith(
"alpha",
expect.objectContaining({ quiet: true }),
expect.objectContaining({ preserveContainer: false, quiet: true }),
);
expect(harness.runAutoPairSpy).toHaveBeenCalledWith("alpha");
expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith(
Expand All @@ -521,6 +521,28 @@ describe("connectSandbox flow", () => {
);
});

it("preserves the existing container during a lifecycle-owned final probe (#8662)", async () => {
const harness = createConnectHarness();

await expect(
harness.connectSandbox("alpha", {
expectedContainerId: "a".repeat(64),
preserveContainer: true,
probeOnly: true,
}),
).resolves.toBeUndefined();

expect(harness.checkAndRecoverSpy).toHaveBeenCalledWith(
"alpha",
expect.objectContaining({
expectedContainerId: "a".repeat(64),
preserveContainer: true,
quiet: true,
}),
);
expect(harness.runAutoPairSpy).toHaveBeenCalledWith("alpha");
});

it("probe-only mode exits before reporting success when inference.local returns no trusted result (#8502)", async () => {
const harness = createConnectHarness({
registryEntry: {
Expand Down Expand Up @@ -580,6 +602,51 @@ describe("connectSandbox flow", () => {
);
expect(exitSpy).toHaveBeenCalledWith(1);
});

it.each([
{
expectedLayer: "managed supervisor",
processCheck: {
checked: false,
forwardRecovered: false,
managedSupervisorFailureDetail:
"supervisor unavailable: Authorization: Bearer opaque-final-probe-token",
managedSupervisorUnavailable: true,
recovered: false,
wasRunning: false,
},
},
{
expectedLayer: "OpenShell readiness",
processCheck: {
checked: true,
forwardRecovered: false,
openshellReadinessFailed: true,
openshellReadinessFailureDetail:
"the sandbox did not become ready in OpenShell, so its host forward was not started",
recovered: false,
wasRunning: false,
},
},
])("probe-only mode preserves the actionable $expectedLayer failure (#8662)", async ({
expectedLayer,
processCheck,
}) => {
const harness = createConnectHarness({ processCheck });

await expect(
harness.connectSandbox("alpha", { preserveContainer: true, probeOnly: true }),
).rejects.toThrow("process.exit(1)");

const errorOutput = harness.errorSpy.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
expect(errorOutput).toContain(expectedLayer);
expect(errorOutput).toContain("existing sandbox was preserved");
expect(errorOutput).toContain("nemoclaw alpha recover");
expect(errorOutput).not.toContain("opaque-final-probe-token");
expect(errorOutput.includes("<REDACTED>")).toBe(expectedLayer === "managed supervisor");
expect(exitSpy).toHaveBeenCalledWith(1);
});

it("probe-only mode reports the supported repair when relaunch is quarantined (#7801)", async () => {
const harness = createConnectHarness({
processCheck: { checked: true, wasRunning: false, recovered: false },
Expand Down
102 changes: 95 additions & 7 deletions src/lib/actions/sandbox/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import {
import { isWsl } from "../../platform";
import { ROOT } from "../../runner";
import * as sandboxVersion from "../../sandbox/version";
import { redact } from "../../security/redact";
import { redact, redactFull } from "../../security/redact";
import {
isSandboxReady,
isTerminalSandboxPhase,
Expand Down Expand Up @@ -84,17 +84,31 @@ import {
executeSandboxExecCommand,
type GatewayRestartFailureLayer,
type ManagedGatewayControlCompletion,
type ManagedGatewayRecoveryFailure,
pinnedManagedGatewayProbeFailure,
resolveSandboxDashboardPort,
type SandboxProcessRecoveryOptions,
} from "./process-recovery";
import { runTerminalAgentConnectProbe } from "./terminal-connect-probe";
import { applyOpenShellVmDnsMonkeypatch, shouldApplyVmDnsMonkeypatch } from "./vm-dns-monkeypatch";

export { runConnectAutoPairApprovalPass };
export { pinnedManagedGatewayProbeFailure, runConnectAutoPairApprovalPass };

export type SandboxConnectOptions = {
/** Immutable direct-container identity retained by lifecycle `start`. */
expectedContainerId?: string;
probeOnly?: boolean;
/** Internal recovery guard used by `start`; this is not a CLI flag. */
preserveContainer?: boolean;
};

export type SandboxStartupRecoveryOptions = Omit<
SandboxProcessRecoveryOptions,
"preserveContainer" | "quiet"
>;
export type SandboxStartupRecoveryResult = ReturnType<typeof checkAndRecoverSandboxProcesses>;
export type SandboxStartupManagedGatewayRecoveryFailure = ManagedGatewayRecoveryFailure;

type SpawnLikeResult = {
status: number | null;
signal?: NodeJS.Signals | null;
Expand Down Expand Up @@ -235,7 +249,39 @@ function exitOnForwardRecoveryFailure(
process.exit(1);
}

async function runSandboxConnectProbe(sandboxName: string): Promise<void> {
function exitOnPreservedStartupRecoveryFailure(
sandboxName: string,
agentName: string,
layer: "managed gateway health" | "managed supervisor" | "OpenShell readiness",
detail: unknown,
): never {
const sanitizedDetail = sanitizeSandboxStartupRecoveryDetail(
String(detail ?? "startup recovery failed"),
);
console.error(
` Probe failed: ${agentName} startup recovery in '${sandboxName}' failed during ${layer}: ${sanitizedDetail}.`,
);
console.error(
` The existing sandbox was preserved. Run \`${CLI_NAME} ${sandboxName} recover\` and retry \`${CLI_NAME} ${sandboxName} start\`.`,
);
process.exit(1);
}

export function sanitizeSandboxStartupRecoveryDetail(raw: string): string {
const normalized = raw
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
.replace(/\s+/gu, " ")
.trim();
return redactFull(normalized).slice(0, 240);
}

async function runSandboxConnectProbe(
sandboxName: string,
{
expectedContainerId,
preserveContainer = false,
}: Pick<SandboxConnectOptions, "expectedContainerId" | "preserveContainer"> = {},
): Promise<void> {
const agent = agentRuntime.getSessionAgent(sandboxName);
const agentName = agentRuntime.getAgentDisplayName(agent);
if (agent && !agentRuntime.hasGatewayRuntime(agent)) {
Expand All @@ -256,10 +302,42 @@ async function runSandboxConnectProbe(sandboxName: string): Promise<void> {
let recoveryFailureLayer: GatewayRestartFailureLayer | null = null;
const processCheck = checkAndRecoverSandboxProcesses(sandboxName, {
quiet: true,
expectedContainerId,
preserveContainer,
onRecoveryFailureLayer: (layer) => {
recoveryFailureLayer = layer;
},
});
if ("managedSupervisorUnavailable" in processCheck && processCheck.managedSupervisorUnavailable) {
exitOnPreservedStartupRecoveryFailure(
sandboxName,
agentName,
"managed supervisor",
"managedSupervisorFailureDetail" in processCheck
? processCheck.managedSupervisorFailureDetail
: "the managed supervisor did not become available",
);
}
if ("managedGatewayProbeFailed" in processCheck && processCheck.managedGatewayProbeFailed) {
exitOnPreservedStartupRecoveryFailure(
sandboxName,
agentName,
"managed gateway health",
"managedGatewayProbeFailureDetail" in processCheck
? processCheck.managedGatewayProbeFailureDetail
: "the authenticated managed gateway health probe did not pass",
);
}
if ("openshellReadinessFailed" in processCheck && processCheck.openshellReadinessFailed) {
exitOnPreservedStartupRecoveryFailure(
sandboxName,
agentName,
"OpenShell readiness",
"openshellReadinessFailureDetail" in processCheck
? processCheck.openshellReadinessFailureDetail
: "the sandbox did not become ready in OpenShell",
);
}
if (!processCheck.checked) {
console.error(
` Probe failed: could not inspect the ${agentName} gateway inside sandbox '${sandboxName}'.`,
Expand Down Expand Up @@ -925,8 +1003,15 @@ function maybeEnsureHermesToolGatewayBroker(sb: SandboxEntry | null): void {
}
}

export function restoreSandboxStartupState(sandboxName: string): void {
checkAndRecoverSandboxProcesses(sandboxName, { quiet: true });
export function restoreSandboxStartupState(
sandboxName: string,
options: SandboxStartupRecoveryOptions = {},
): SandboxStartupRecoveryResult {
return checkAndRecoverSandboxProcesses(sandboxName, {
...options,
preserveContainer: true,
quiet: true,
});
}

function restoreInteractiveTerminal(): void {
Expand Down Expand Up @@ -1227,7 +1312,7 @@ export async function prepareInteractiveSession(

export async function connectSandbox(
sandboxName: string,
{ probeOnly = false }: SandboxConnectOptions = {},
{ expectedContainerId, probeOnly = false, preserveContainer = false }: SandboxConnectOptions = {},
): Promise<void> {
if (probeOnly) {
await runConnectEntryPreflight(sandboxName, { probeOnly: true });
Expand All @@ -1239,7 +1324,10 @@ export async function connectSandbox(
// before any in-sandbox process or host-forward mutation. The readiness
// polls are already owner-scoped; this also catches registry changes.
await ensureLiveSandboxOrExit(sandboxName, { gatewayRecovery: "observe" });
return await runSandboxConnectProbe(sandboxName);
return await runSandboxConnectProbe(sandboxName, {
expectedContainerId,
preserveContainer,
});
}

const { agent, sb } = await prepareInteractiveSession(sandboxName);
Expand Down
8 changes: 7 additions & 1 deletion src/lib/actions/sandbox/docker-health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ function fixture({
throwOnInspect?: boolean;
throwOnPaused?: boolean;
knownSandboxes?: string[];
labeledContainers?: Array<{ name: string; status: string; running: boolean }>;
labeledContainers?: Array<{
containerId: string;
name: string;
status: string;
running: boolean;
}>;
} = {}) {
const sandbox: Partial<SandboxEntry> = { name: "my-assistant", openshellDriver: driver };
const runningNames = new Set(
Expand All @@ -44,6 +49,7 @@ function fixture({
.map((name) => name.trim())
.filter(Boolean)
.map((name) => ({
containerId: "a".repeat(64),
name,
status: runningNames.has(name) ? "Up 1 minute" : "Exited (0) 1 minute ago",
running: runningNames.has(name),
Expand Down
Loading
Loading