Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
23df45b
refactor(onboard): extract sandbox create-failure reporting into a mo…
Dongni-Yang Jul 8, 2026
d08316f
refactor(onboard): extract the sandbox create step into a module
Dongni-Yang Jul 8, 2026
c321655
merge main into PR branch
jyaunches Jul 9, 2026
53d2799
Merge remote-tracking branch 'origin/main' into fix/6258-extract-onbo…
jyaunches Jul 9, 2026
fb7af18
Merge remote-tracking branch 'origin/main' into fix/6258-extract-onbo…
jyaunches Jul 9, 2026
e278551
fix(onboard): preserve sandbox readiness failure status
jyaunches Jul 9, 2026
1287ab2
style(onboard): format sandbox failure test
jyaunches Jul 9, 2026
66c1b04
Merge remote-tracking branch 'origin/main' into fix/6258-extract-onbo…
jyaunches Jul 9, 2026
b7e27f4
test(onboard): cover terminal ready detach gate
jyaunches Jul 9, 2026
a10b2fc
fix(onboard): isolate create poll side effects
jyaunches Jul 9, 2026
9c4edb3
test(sandbox): cover create stream poll errors
jyaunches Jul 9, 2026
0bb30ac
test(onboard): cover driver-aware create detach
jyaunches Jul 9, 2026
1ccf759
Merge remote-tracking branch 'origin/main' into fix/6258-extract-onbo…
jyaunches Jul 9, 2026
f3395d2
test(onboard): keep ready gate tests linear
jyaunches Jul 9, 2026
249fd75
Merge remote-tracking branch 'origin/main' into fix/6258-extract-onbo…
jyaunches Jul 9, 2026
24ecc5c
fix(onboard): preserve create failure checks
jyaunches Jul 9, 2026
1e89000
test(snapshot): expect argv create command
jyaunches Jul 9, 2026
f8f21c0
Merge remote-tracking branch 'origin/main' into fix/6258-extract-onbo…
jyaunches Jul 9, 2026
393efd1
test(snapshot): keep file within size budget
jyaunches Jul 9, 2026
36be647
test(snapshot): type argv create assertion
jyaunches Jul 9, 2026
05c0c98
fix(onboard): close create security advisor gaps
jyaunches Jul 9, 2026
349d113
fix(onboard): unify create ready gate detection
jyaunches Jul 9, 2026
3a6016d
fix(sandbox): require failure check for create poll hooks
jyaunches Jul 9, 2026
88a72a3
refactor(sandbox): extract create stream progress patterns
jyaunches Jul 9, 2026
31b64ed
fix(sandbox): abort create poll hook failures
jyaunches Jul 9, 2026
5ebf46f
Merge remote-tracking branch 'origin/main' into fix/6258-extract-onbo…
jyaunches Jul 9, 2026
0b066c3
test(sandbox): trace ready check poll errors
jyaunches Jul 9, 2026
7a3f18f
Merge remote-tracking branch 'origin/main' into fix/6258-extract-onbo…
jyaunches Jul 9, 2026
1a4603a
Merge remote-tracking branch 'origin/main' into fix/6258-extract-onbo…
jyaunches Jul 9, 2026
c403937
fix(sandbox): document create stream legacy shell overload
jyaunches Jul 9, 2026
c643a58
Merge remote-tracking branch 'origin/main' into fix/6258-extract-onbo…
jyaunches Jul 9, 2026
36d3d82
test(snapshot): guard auto-create failure registry state
jyaunches Jul 9, 2026
b283f54
Merge remote-tracking branch 'origin/main' into fix/6258-extract-onbo…
jyaunches Jul 9, 2026
08a1308
Merge remote-tracking branch 'origin/main' into fix/6258-extract-onbo…
jyaunches Jul 9, 2026
c7a9b41
fix(sandbox): inherit env for argv create stream
jyaunches Jul 9, 2026
7f80f0a
Merge remote-tracking branch 'origin/main' into fix/6258-extract-onbo…
jyaunches Jul 9, 2026
04581fc
fix(cli): keep create output streams separate
cv Jul 9, 2026
f0edd66
Merge remote-tracking branch 'origin/main' into fix/6258-extract-onbo…
jyaunches Jul 9, 2026
92bb378
Merge remote-tracking branch 'origin/main' into fix/6258-extract-onbo…
jyaunches Jul 9, 2026
d32adf2
Merge remote-tracking branch 'origin/main' into fix/6258-extract-onbo…
jyaunches Jul 9, 2026
a541d02
Merge remote-tracking branch 'origin/main' into fix/6258-extract-onbo…
jyaunches Jul 9, 2026
1ce4510
Merge remote-tracking branch 'origin/main' into fix/6258-extract-onbo…
jyaunches Jul 9, 2026
82518f7
Merge remote-tracking branch 'origin/main' into fix/6258-extract-onbo…
jyaunches Jul 9, 2026
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
141 changes: 141 additions & 0 deletions src/lib/actions/sandbox/snapshot-auto-create-failure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it, vi } from "vitest";
import type { SnapshotStreamSandboxCreateMock } from "./snapshot-create-stream-test-types";

const captureOpenshellMock = vi.fn(() => ({ status: 0, output: "alpha Ready\n" }));
const getSandboxMock = vi.fn((name?: string) =>
name === "alpha"
? {
name: "alpha",
agent: "openclaw",
gatewayName: "nemoclaw",
imageTag: "nemoclaw-alpha:test",
openshellDriver: "docker",
provider: "nvidia-nim",
model: "nvidia/model-a",
}
: null,
);
const registerSandboxMock = vi.fn();
const restoreSandboxStateMock = vi.fn();
const streamSandboxCreateMock = vi.fn<SnapshotStreamSandboxCreateMock>(async () => ({
status: 7,
output: "create failed before registry write",
sawProgress: false,
forcedReady: false,
}));

vi.mock("../../adapters/docker", () => ({ dockerCapture: vi.fn(() => "") }));
vi.mock("../../adapters/openshell/runtime", () => ({
captureOpenshell: captureOpenshellMock,
getOpenshellBinary: vi.fn(() => "openshell"),
runOpenshell: vi.fn(() => ({ status: 0, output: "" })),
}));
vi.mock("../../credentials/store", () => ({ prompt: vi.fn() }));
vi.mock("../../domain/sandbox/destroy", () => ({
getSandboxDeleteOutcome: vi.fn(() => ({ alreadyGone: false, gatewayUnreachable: false })),
}));
vi.mock("../../inference/gateway-route-compatibility", () => ({
checkGatewayRouteCompatibility: vi.fn(() => ({ ok: true })),
formatGatewayRouteConflict: vi.fn(() => "route conflict"),
}));
vi.mock("../../inference/gateway-route-mutation-lock", () => ({
withGatewayRouteMutationLock: vi.fn((_gateway, fn) => fn()),
}));
vi.mock("../../inference/nim", () => ({
stopNimContainer: vi.fn(),
stopNimContainerByName: vi.fn(),
}));
vi.mock("../../messaging/channels", () => ({ listMessagingProviderSuffixes: vi.fn(() => []) }));
vi.mock("../../policy", () => ({
applyPreset: vi.fn(() => true),
applyPresetContent: vi.fn(() => true),
getAppliedPresets: vi.fn(() => []),
getCustomPolicies: vi.fn(() => []),
getPresetContentGatewayState: vi.fn(() => "absent"),
loadPresetForSandbox: vi.fn(() => null),
removePreset: vi.fn(() => true),
}));
vi.mock("../../runner", () => ({
ROOT: "/repo",
run: vi.fn(() => ({ status: 0 })),
shellQuote: (value: string) => `'${value}'`,
validateName: vi.fn((value: string) => value),
}));
vi.mock("../../runtime-recovery", () => ({
parseLiveSandboxNames: vi.fn(() => new Set(["alpha"])),
}));
vi.mock("../../sandbox/create-stream", () => ({ streamSandboxCreate: streamSandboxCreateMock }));
vi.mock("../../shields", () => ({
get isShieldsDown() {
return true;
},
repairMutableConfigPerms: vi.fn(() => ({ applied: true, verified: true, errors: [] })),
shieldsUp: vi.fn(),
}));
vi.mock("../../shields/timer-bound-lock", () => ({
withTimerBoundShieldsMutationLock: vi.fn((_sandbox, _command, fn) => fn()),
}));
vi.mock("../../shields/timer-control", () => ({ readTimerMarker: vi.fn(() => null) }));
vi.mock("../../state/gateway", () => ({
isGatewayHealthy: vi.fn(() => true),
isSandboxReady: vi.fn((output: string, sandboxName: string) =>
output.includes(`${sandboxName} Ready`),
),
}));
vi.mock("../../state/mcp-lifecycle-lock", () => ({
withSandboxMutationLock: vi.fn((_sandbox, fn) => fn()),
}));
vi.mock("../../state/registry", () => ({
getSandbox: getSandboxMock,
listSandboxes: vi.fn(() => ({ sandboxes: [getSandboxMock("alpha")], defaultSandbox: "alpha" })),
registerSandbox: registerSandboxMock,
removeSandbox: vi.fn(),
updateSandbox: vi.fn(),
}));
vi.mock("../../state/sandbox", () => ({
backupSandboxState: vi.fn(),
findBackup: vi.fn(() => ({ match: null })),
getLatestBackup: vi.fn(() => ({
timestamp: "2026-06-15T00:00:00.000Z",
backupPath: "/tmp/backup-alpha",
})),
listBackups: vi.fn(() => []),
restoreSandboxState: restoreSandboxStateMock,
}));
vi.mock("./destroy", () => ({
cleanupShieldsDestroyArtifacts: vi.fn(),
removeSandboxRegistryEntry: vi.fn(),
}));
vi.mock("./sandbox-gateway-routing", () => ({
probeGatewayRunning: vi.fn(() => true),
selectSandboxGatewayIfRegistered: vi.fn(() => true),
usesGatewayMetadataProbe: vi.fn(
(driver?: string | null) => driver === "docker" || driver === "vm",
),
}));

describe("snapshot restore auto-create failures", () => {
it("does not register a ghost sandbox when auto-create fails", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "log").mockImplementation(() => {});
const { runSandboxSnapshot } = await import("./snapshot");

await expect(
runSandboxSnapshot("alpha", { kind: "restore", to: "beta" }),
).rejects.toMatchObject({
exitCode: 1,
});

expect(streamSandboxCreateMock).toHaveBeenCalledWith(
"openshell",
expect.arrayContaining(["sandbox", "create", "--name", "beta"]),
expect.any(Object),
expect.objectContaining({ initialPhase: "create" }),
);
expect(registerSandboxMock).not.toHaveBeenCalled();
expect(restoreSandboxStateMock).not.toHaveBeenCalled();
});
});
14 changes: 14 additions & 0 deletions src/lib/actions/sandbox/snapshot-create-stream-test-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type {
StreamSandboxCreateOptions,
StreamSandboxCreateResult,
} from "../../sandbox/create-stream";

export type SnapshotStreamSandboxCreateMock = (
command: string,
args: readonly string[],
env?: NodeJS.ProcessEnv,
options?: StreamSandboxCreateOptions,
) => Promise<StreamSandboxCreateResult>;
34 changes: 13 additions & 21 deletions src/lib/actions/sandbox/snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SANDBOX_EXEC_STARTED_MARKER } from "./sandbox-exec-output";
import type { SnapshotStreamSandboxCreateMock } from "./snapshot-create-stream-test-types";

type OpenshellCaptureResult = {
status: number | null;
Expand All @@ -16,16 +16,7 @@ type OpenshellCaptureResult = {
error?: Error;
signal?: NodeJS.Signals | null;
};
type SandboxRecord = {
name: string;
agent?: string | null;
gatewayName?: string | null;
imageTag?: string | null;
openshellDriver?: string | null;
observabilityEnabled?: boolean;
provider?: string | null;
model?: string | null;
};
type SandboxRecord = { name: string; observabilityEnabled?: boolean } & Record<string, unknown>;
type DcodeProbeState = "active" | "idle" | "unverifiable" | "no-runtime";

function dcodeProbeOutput(state: DcodeProbeState, extra = ""): string {
Expand Down Expand Up @@ -139,13 +130,12 @@ const runOpenshellMock = vi.fn((args: string[]) => {
args[0] === "sandbox" && args[1] === "delete" && lifecycleMock.events.push("delete");
return { status: 0, output: "" };
});
const streamSandboxCreateMock = vi.fn(
async (_command: string, _env: NodeJS.ProcessEnv, _options?: Record<string, unknown>) => ({
status: 0,
output: "",
forcedReady: false,
}),
);
const streamSandboxCreateMock = vi.fn<SnapshotStreamSandboxCreateMock>(async () => ({
status: 0,
output: "",
sawProgress: false,
forcedReady: false,
}));
const dcodeSandboxEntry = {
name: "alpha",
agent: "langchain-deepagents-code",
Expand Down Expand Up @@ -984,9 +974,11 @@ describe("runSandboxSnapshot", () => {
getLatestBackupMock.mockReturnValue({ ...latestBackupFixture });
const { runSandboxSnapshot } = await import("./snapshot");
await runSandboxSnapshot("alpha", { kind: "restore", to: "beta" });
const [createCommandValue, createEnv] = streamSandboxCreateMock.mock.calls[0] ?? [];
const createCommand = String(createCommandValue ?? "");
expect(createCommand).toContain(`'NEMOCLAW_OBSERVABILITY=${expectedValue}'`);
const createCall = streamSandboxCreateMock.mock.calls[0] ?? [];
const createArgs = createCall[1] as readonly string[];
const createEnv = createCall[2] as NodeJS.ProcessEnv | undefined;
expect(createCall[0]).toBe("openshell");
expect(createArgs).toContain(`NEMOCLAW_OBSERVABILITY=${expectedValue}`);
expect(createEnv?.NEMOCLAW_OBSERVABILITY).toBeUndefined();
expect(registerSandboxMock).toHaveBeenCalledWith(
expect.objectContaining({
Expand Down
11 changes: 5 additions & 6 deletions src/lib/actions/sandbox/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
} from "../../onboard/observability-policy-presets";
import { normalizePolicyTierName } from "../../onboard/policy-tier-suppression";
import * as policies from "../../policy";
import { ROOT, run, shellQuote, validateName } from "../../runner";
import { ROOT, run, validateName } from "../../runner";
import { parseLiveSandboxNames } from "../../runtime-recovery";
import { streamSandboxCreate } from "../../sandbox/create-stream";
import * as shields from "../../shields";
Expand Down Expand Up @@ -240,8 +240,8 @@ async function autoCreateSandboxFromSource(
const createEnv = { ...process.env };
delete createEnv.NEMOCLAW_OBSERVABILITY;

const cmdParts = [
openshellBin,
const command = openshellBin;
const commandArgs = [
"sandbox",
"create",
"--name",
Expand All @@ -253,12 +253,11 @@ async function autoCreateSandboxFromSource(
"--auto-providers",
"--",
...startupCommand,
].map((p) => shellQuote(p));
const command = `${cmdParts.join(" ")} 2>&1`;
];

console.log(` '${dstName}' does not exist. Creating from '${srcName}' image (${fromImage})...`);

const createResult = await streamSandboxCreate(command, createEnv, {
const createResult = await streamSandboxCreate(command, commandArgs, createEnv, {
// Use a pre-built image, so skip build+push and jump to pod creation.
initialPhase: "create",
// Wait until the sandbox actually reaches Ready state, not just appears in the list.
Expand Down
Loading
Loading