diff --git a/scripts/install.sh b/scripts/install.sh index eb0f6f5bb31..d49e9bd24e4 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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; @@ -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} destroy' before reinstalling." diff --git a/src/lib/actions/maintenance.test.ts b/src/lib/actions/maintenance.test.ts index b66ae250169..7d81b71cbba 100644 --- a/src/lib/actions/maintenance.test.ts +++ b/src/lib/actions/maintenance.test.ts @@ -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", () => ({ @@ -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" }], diff --git a/src/lib/actions/maintenance.ts b/src/lib/actions/maintenance.ts index 08e73a665a3..e1d8446022e 100644 --- a/src/lib/actions/maintenance.ts +++ b/src/lib/actions/maintenance.ts @@ -40,7 +40,9 @@ function notRunningBackupSkipMessage(name: string): string { } export async function backupAll(): Promise { - 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; diff --git a/src/lib/actions/upgrade-sandboxes-preflight.test.ts b/src/lib/actions/upgrade-sandboxes-preflight.test.ts index 56dbb8dfb5e..f10b6e56522 100644 --- a/src/lib/actions/upgrade-sandboxes-preflight.test.ts +++ b/src/lib/actions/upgrade-sandboxes-preflight.test.ts @@ -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"; diff --git a/src/lib/actions/upgrade-sandboxes-recovery.test.ts b/src/lib/actions/upgrade-sandboxes-recovery.test.ts index 309fbfd61ec..f7d63c708d5 100644 --- a/src/lib/actions/upgrade-sandboxes-recovery.test.ts +++ b/src/lib/actions/upgrade-sandboxes-recovery.test.ts @@ -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; @@ -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"]); diff --git a/src/lib/actions/upgrade-sandboxes.ts b/src/lib/actions/upgrade-sandboxes.ts index 30de8d955c0..e75c83458f1 100644 --- a/src/lib/actions/upgrade-sandboxes.ts +++ b/src/lib/actions/upgrade-sandboxes.ts @@ -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; diff --git a/src/lib/state/registry-route-reservation.test.ts b/src/lib/state/registry-route-reservation.test.ts index f8222999ec3..07abaa5e931 100644 --- a/src/lib/state/registry-route-reservation.test.ts +++ b/src/lib/state/registry-route-reservation.test.ts @@ -44,6 +44,11 @@ describe("sandbox inference route reservation", () => { }, ], }); + const reservation = registry.getSandbox("alpha"); + expect(reservation).not.toBeNull(); + const reservedEntry = reservation as NonNullable; + expect(reservedEntry.createdAt).toBeUndefined(); + expect(registry.isRouteOnlySandboxReservation(reservedEntry)).toBe(true); expect(registry.getDefault()).toBeNull(); expect(registry.setDefault("alpha")).toBe(false); } finally { @@ -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; + 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 }); } diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index 8f7108c69f3..8375b12b472 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -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, diff --git a/test/install-openshell-upgrade-prompt.test.ts b/test/install-openshell-upgrade-prompt.test.ts index 49ad9b05309..22567449ee1 100644 --- a/test/install-openshell-upgrade-prompt.test.ts +++ b/test/install-openshell-upgrade-prompt.test.ts @@ -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( { diff --git a/test/install-preexisting-sandbox-recovery.test.ts b/test/install-preexisting-sandbox-recovery.test.ts index 91dee293785..70b53edef40 100644 --- a/test/install-preexisting-sandbox-recovery.test.ts +++ b/test/install-preexisting-sandbox-recovery.test.ts @@ -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, }); @@ -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) @@ -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"); + }); });