Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
19 changes: 19 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1538,6 +1538,25 @@ 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">

`start` binds recovery and verification to the immutable identity of the existing container.
It does not rebuild, replace, or destroy that container.

`start` returns success only after these checks pass:

- The authenticated managed gateway is healthy.
- OpenShell reports the sandbox in the `Ready` phase.
- Every required host forward is active.
- A final authenticated managed-gateway check passes after inference-route and pairing reconciliation.

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`.
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
71 changes: 70 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,53 @@ 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");
if (expectedLayer === "managed supervisor") {
expect(errorOutput).toContain("<REDACTED>");
}
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 {
return redactFull(raw)
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
.replace(/\s+/gu, " ")
.trim()
.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