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
23 changes: 11 additions & 12 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1723,14 +1723,19 @@ const entries = Object.entries(registry.sandboxes);
if (entries.some(([name, entry]) => !name.trim() || !isObjectRecord(entry) || entry.name !== name)) {
process.exit(1);
}
// Keep this raw-registry predicate in sync with isRouteOnlySandboxReservation()
// in src/lib/state/registry.ts.
const sandboxes = entries.filter(
([, entry]) => !(entry.pendingRouteReservation === true && entry.createdAt === undefined),
);

if (process.argv[3] === "count") {
process.stdout.write(String(entries.length));
process.stdout.write(String(sandboxes.length));
process.exit(0);
}
if (process.argv[3] !== "ambiguous-names") process.exit(1);

const ambiguous = entries
const ambiguous = sandboxes
.filter(([, entry]) => {
const version = entry.nemoclawVersion;
const hasFingerprint = typeof version === "string" && version.trim().length > 0;
Expand Down Expand Up @@ -2891,16 +2896,10 @@ main() {

step 3 "Onboarding"
if [ -n "$_cli_runner" ]; then
if [[ -f "${HOME}/.nemoclaw/sandboxes.json" ]] && node -e '
const fs = require("fs");
try {
const data = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const count = Object.keys(data.sandboxes || {}).length;
process.exit(count > 0 ? 0 : 1);
} catch {
process.exit(1);
}
' "${HOME}/.nemoclaw/sandboxes.json"; then
local _registered_sandbox_count=""
if [[ -f "${HOME}/.nemoclaw/sandboxes.json" ]] \
&& _registered_sandbox_count="$(registered_sandbox_count)" \
&& [[ "$_registered_sandbox_count" -gt 0 ]]; then
warn "Existing sandbox sessions detected. Onboarding may disrupt running agents."
if [[ "${NEMOCLAW_SINGLE_SESSION:-}" == "1" ]]; then
error "Aborting — NEMOCLAW_SINGLE_SESSION is set. Destroy existing sessions with '${_CLI_BIN} <name> destroy' before reinstalling."
Expand Down
60 changes: 60 additions & 0 deletions src/lib/actions/maintenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const mocks = vi.hoisted(() => ({
}));

vi.mock("../state/registry", () => ({
isRouteOnlySandboxReservation: (entry: { pendingRouteReservation?: true; createdAt?: string }) =>
entry.pendingRouteReservation === true && entry.createdAt === undefined,
listSandboxes: mocks.listSandboxes,
}));
vi.mock("../state/sandbox", () => ({
Expand Down Expand Up @@ -85,6 +87,64 @@ describe("backupAll", () => {
logSpy.mockRestore();
});

it("returns before gateway preflight when the registry has only a route reservation (#6500)", async () => {
mocks.listSandboxes.mockReturnValue({
sandboxes: [{ name: "tm", pendingRouteReservation: true }],
defaultSandbox: null,
});
process.env.NEMOCLAW_REQUIRE_ALL_SANDBOX_BACKUPS = "1";
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`exit:${code}`);
}) as never);

await backupAll();

expect(mocks.captureSandboxListWithGatewayPreflightOrExit).not.toHaveBeenCalled();
expect(mocks.backupSandboxState).not.toHaveBeenCalled();
expect(mocks.startStoppedSandboxContainerForBackup).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
expect(logSpy.mock.calls.flat().join("\n")).toContain("No sandboxes registered");
});

it("backs up real sandboxes while ignoring a route-only reservation (#6500)", async () => {
mocks.listSandboxes.mockReturnValue({
sandboxes: [
{ name: "tm", pendingRouteReservation: true },
{ name: "alpha" },
{
name: "beta",
pendingRouteReservation: true,
createdAt: "2026-07-13T00:00:00.000Z",
},
],
defaultSandbox: "alpha",
});
mocks.parseReadySandboxNames.mockReturnValue(new Set(["alpha", "beta"]));
mocks.backupSandboxState.mockImplementation((name: string) => ({
success: true,
backedUpDirs: ["workspace"],
failedDirs: [],
backedUpFiles: [],
failedFiles: [],
manifest: { backupPath: `/backups/${name}/timestamp` },
}));
process.env.NEMOCLAW_REQUIRE_ALL_SANDBOX_BACKUPS = "1";
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`exit:${code}`);
}) as never);

await backupAll();

expect(mocks.backupSandboxState.mock.calls.map(([name]) => name)).toEqual(["alpha", "beta"]);
expect(mocks.startStoppedSandboxContainerForBackup).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
expect(logSpy.mock.calls.flat().join("\n")).toContain(
"Pre-upgrade backup: 2 backed up, 0 failed, 0 skipped",
);
});

it("passes the backup action context to gateway preflight", async () => {
mocks.listSandboxes.mockReturnValue({
sandboxes: [{ name: "sb-good" }],
Expand Down
4 changes: 3 additions & 1 deletion src/lib/actions/maintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ function notRunningBackupSkipMessage(name: string): string {
}

export async function backupAll(): Promise<void> {
const { sandboxes } = registry.listSandboxes();
const sandboxes = registry
.listSandboxes()
.sandboxes.filter((sandbox) => !registry.isRouteOnlySandboxReservation(sandbox));
if (sandboxes.length === 0) {
console.log(" No sandboxes registered. Nothing to back up.");
return;
Expand Down
6 changes: 5 additions & 1 deletion src/lib/actions/upgrade-sandboxes-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ vi.mock("../runtime-recovery", () => ({
parseReadySandboxNames: mocks.parseReadySandboxNames,
}));
vi.mock("../sandbox/version", () => ({ checkAgentVersion: mocks.checkAgentVersion }));
vi.mock("../state/registry", () => ({ listSandboxes: mocks.listSandboxes }));
vi.mock("../state/registry", () => ({
isRouteOnlySandboxReservation: (entry: { pendingRouteReservation?: true; createdAt?: string }) =>
entry.pendingRouteReservation === true && entry.createdAt === undefined,
listSandboxes: mocks.listSandboxes,
}));
vi.mock("../state/sandbox", () => ({ getLatestBackup: mocks.getLatestBackup }));

import { upgradeSandboxes, upgradeSandboxesDependencies } from "./upgrade-sandboxes";
Expand Down
40 changes: 40 additions & 0 deletions src/lib/actions/upgrade-sandboxes-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ function createRecoveryHarness(
Partial<{
agent: "openclaw" | "hermes" | "langchain-deepagents-code" | null;
agentVersion: string | null;
createdAt: string;
nemoclawVersion: string | null;
fromDockerfile: string | null;
pendingRouteReservation: true;
}>
>;
confirmedLegacyManagedNames?: string[] | string;
Expand Down Expand Up @@ -136,6 +138,44 @@ afterEach(() => {
});

describe("upgrade-sandboxes prepared backup recovery (#6114)", () => {
it("returns before gateway preflight for a route-only reservation (#6500)", async () => {
const harness = createRecoveryHarness(["tm"], {
registryOverrides: {
tm: { pendingRouteReservation: true },
},
});

await expect(harness.upgradeSandboxes({ auto: true })).resolves.toBeUndefined();

expect(harness.liveListSpy).not.toHaveBeenCalled();
expect(harness.latestBackupSpy).not.toHaveBeenCalled();
expect(harness.rebuildSpy).not.toHaveBeenCalled();
expect(console.log).toHaveBeenCalledWith(" No sandboxes found in the registry.");
});

it("recovers real sandboxes while ignoring a route-only reservation (#6500)", async () => {
const harness = createRecoveryHarness(["tm", "alpha", "beta"], {
registryOverrides: {
tm: { pendingRouteReservation: true },
beta: {
pendingRouteReservation: true,
createdAt: "2026-07-13T00:00:00.000Z",
},
},
});

await expect(harness.upgradeSandboxes({ auto: true })).resolves.toBeUndefined();

expect(harness.latestBackupSpy.mock.calls.map((call: unknown[]) => call[0])).toEqual([
"alpha",
"beta",
]);
expect(harness.rebuildSpy.mock.calls.map((call: unknown[]) => call[0])).toEqual([
"alpha",
"beta",
]);
});

it("passes every non-Ready sandbox's validated manifest into rebuild", async () => {
const harness = createRecoveryHarness(["alpha", "beta"]);

Expand Down
4 changes: 3 additions & 1 deletion src/lib/actions/upgrade-sandboxes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,9 @@ export async function upgradeSandboxes(
const checkOnly = normalized.check === true;
const skipConfirm = shouldSkipUpgradeConfirmation(normalized);

const sandboxes = registry.listSandboxes().sandboxes;
const sandboxes = registry
.listSandboxes()
.sandboxes.filter((sandbox) => !registry.isRouteOnlySandboxReservation(sandbox));
if (sandboxes.length === 0) {
console.log(" No sandboxes found in the registry.");
return;
Expand Down
14 changes: 12 additions & 2 deletions src/lib/state/registry-route-reservation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ describe("sandbox inference route reservation", () => {
},
],
});
const reservation = registry.getSandbox("alpha");
expect(reservation).not.toBeNull();
const reservedEntry = reservation as NonNullable<typeof reservation>;
expect(reservedEntry.createdAt).toBeUndefined();
expect(registry.isRouteOnlySandboxReservation(reservedEntry)).toBe(true);
expect(registry.getDefault()).toBeNull();
expect(registry.setDefault("alpha")).toBe(false);
} finally {
Expand Down Expand Up @@ -74,13 +79,18 @@ describe("sandbox inference route reservation", () => {
gatewayName: "nemoclaw-9090",
});

expect(registry.getSandbox("alpha")).toMatchObject({
const retargeted = registry.getSandbox("alpha");
expect(retargeted).not.toBeNull();
const retargetedEntry = retargeted as NonNullable<typeof retargeted>;
expect(retargetedEntry).toMatchObject({
gatewayName: "nemoclaw-9090",
provider: "anthropic-prod",
model: "model-b",
pendingRouteReservation: true,
});
expect(registry.getSandbox("alpha")?.gatewayPort).toBeUndefined();
expect(retargetedEntry.createdAt).toEqual(expect.any(String));
expect(retargetedEntry.gatewayPort).toBeUndefined();
expect(registry.isRouteOnlySandboxReservation(retargetedEntry)).toBe(false);
} finally {
await fs.rm(home, { recursive: true, force: true });
}
Expand Down
5 changes: 5 additions & 0 deletions src/lib/state/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,11 @@ export function reserveSandboxInferenceRoute(
});
}

/** True only for an inference route reserved before sandbox registration. */
export function isRouteOnlySandboxReservation(entry: SandboxEntry): boolean {
return entry.pendingRouteReservation === true && entry.createdAt === undefined;
}

export function isPendingReservationForSession(
entry: SandboxEntry | null,
sessionId: string | null | undefined,
Expand Down
43 changes: 43 additions & 0 deletions test/install-openshell-upgrade-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,49 @@ describe("install.sh OpenShell gateway upgrade guard", () => {
expect(openshellLog).toBe("");
});

it("ignores a route-only reservation during pre-upgrade backup (#6500)", () => {
const { result, cliLog, openshellLog } = runPreinstallUpgradeGuard(
{
NON_INTERACTIVE: "1",
NEMOCLAW_SINGLE_SESSION: "1",
},
{
registryJson:
'{"sandboxes":{"tm":{"name":"tm","pendingRouteReservation":true,"provider":"nvidia-prod","model":"nemotron"}}}',
},
);

expect(result.status).toBe(0);
expect(result.stdout).toContain("RESTORE=");
expect(result.stdout).toContain("CONFIRMED_NAMES=");
expect(result.stdout + result.stderr).not.toContain("managed-image");
expect(cliLog).toBe("");
expect(openshellLog).toBe("");
});

it("backs up only real sandboxes in a mixed reservation registry (#6500)", () => {
const { result, cliLog, openshellLog } = runPreinstallUpgradeGuard(
{
NON_INTERACTIVE: "1",
NEMOCLAW_CONFIRM_LEGACY_MANAGED_RECREATE: '["alpha","beta"]',
},
{
hasOldCli: false,
openshellVersion: "0.0.44",
registryJson:
'{"sandboxes":{"tm":{"name":"tm","pendingRouteReservation":true},"alpha":{"name":"alpha"},"beta":{"name":"beta","pendingRouteReservation":true,"createdAt":"2026-07-13T00:00:00.000Z"}}}',
},
);

expect(result.status).toBe(0);
expect(result.stdout).toContain("Backing up 2 sandbox(es)");
expect(result.stdout).toContain('CONFIRMED_NAMES=["alpha","beta"]');
expect(result.stdout + result.stderr).not.toContain('"tm"');
expect(cliLog.split(/\r?\n/)).toContain("current:backup-all");
expect(cliLog).toContain("require-all-env=1");
expect(openshellLog).toBe("");
});

it("continues after the user manually prepared the old gateway state", () => {
const { result, cliLog, openshellLog } = runPreinstallUpgradeGuard(
{
Expand Down
22 changes: 21 additions & 1 deletion test/install-preexisting-sandbox-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,18 @@ const INSTALLER_PAYLOAD = path.join(import.meta.dirname, "..", "scripts", "insta
function runRecoveryBeforeOnboard(
preexistingCount: number,
recoveryExitCode: number,
options: { registryJson?: string; singleSession?: boolean } = {},
): { status: number | null; calls: string[]; output: string } {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-recovery-order-"));
const cli = path.join(tmp, "nemoclaw");
const callLog = path.join(tmp, "calls.log");
const payloadDir = path.join(tmp, "payload");
fs.mkdirSync(payloadDir);
fs.mkdirSync(path.join(tmp, ".nemoclaw"));
fs.writeFileSync(
path.join(tmp, ".nemoclaw", "sandboxes.json"),
options.registryJson ?? '{"sandboxes":{}}',
);
fs.writeFileSync(path.join(payloadDir, "setup-jetson.sh"), "#!/usr/bin/env bash\nexit 0\n", {
mode: 0o755,
});
Expand Down Expand Up @@ -67,14 +73,17 @@ exit 0
print_done() { printf 'PRINT_DONE\n'; }
main --non-interactive --yes-i-accept-third-party-software
`;
const childEnv = { ...process.env };
delete childEnv.NEMOCLAW_SINGLE_SESSION;
const result = spawnSync("bash", ["-c", snippet], {
encoding: "utf-8",
env: {
...process.env,
...childEnv,
BASH_ENV: "",
ENV: "",
HOME: tmp,
NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE: "1",
...(options.singleSession ? { NEMOCLAW_SINGLE_SESSION: "1" } : {}),
},
});
const calls = fs.existsSync(callLog)
Expand Down Expand Up @@ -114,4 +123,15 @@ describe("install.sh pre-existing sandbox recovery ordering (#6114)", () => {
expect(result.status, result.output).toBe(0);
expect(result.calls).toEqual(["restore=1 confirmed= argv=onboard"]);
});

it("does not treat a route-only reservation as an existing session (#6500)", () => {
const result = runRecoveryBeforeOnboard(0, 7, {
registryJson: '{"sandboxes":{"tm":{"name":"tm","pendingRouteReservation":true}}}',
singleSession: true,
});

expect(result.status, result.output).toBe(0);
expect(result.calls).toEqual(["restore=1 confirmed= argv=onboard"]);
expect(result.output).not.toContain("Existing sandbox sessions detected");
});
});
Loading