diff --git a/src/lib/actions/maintenance.test.ts b/src/lib/actions/maintenance.test.ts index 1bf4fdb19d7..cba6c69c1f0 100644 --- a/src/lib/actions/maintenance.test.ts +++ b/src/lib/actions/maintenance.test.ts @@ -39,12 +39,10 @@ vi.mock("../credentials/store", () => ({ vi.mock("../domain/lifecycle/options", () => ({ normalizeGarbageCollectImagesOptions: (o: unknown) => o || {}, })); -vi.mock("../domain/maintenance/images", () => ({ - findOrphanedSandboxImages: vi.fn().mockReturnValue([]), - parseSandboxImageRows: vi.fn().mockReturnValue([]), -})); +// ../domain/maintenance/images is left unmocked so the gc tests run the real +// orphan-detection helpers and can assert on gc's actual output. -import { backupAll, shouldSkipUnreachableSandboxBackup } from "./maintenance"; +import { backupAll, garbageCollectImages, shouldSkipUnreachableSandboxBackup } from "./maintenance"; describe("backupAll", () => { beforeEach(() => { @@ -350,3 +348,40 @@ describe("shouldSkipUnreachableSandboxBackup", () => { expect(shouldSkipUnreachableSandboxBackup({})).toBe(false); }); }); + +describe("garbageCollectImages", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("surfaces a local-repo orphan while preserving a registered local image (#6301)", async () => { + // Local repo holds an orphan (gc-test-orphan-111) plus a still-registered + // image (live-222); the gateway repo holds only an in-use image. + mocks.dockerListImagesFormat.mockImplementation((repo: string) => + repo === "nemoclaw-sandbox-local" + ? "nemoclaw-sandbox-local:gc-test-orphan-111\t3GB\nnemoclaw-sandbox-local:live-222\t2GB" + : "openshell/sandbox-from:in-use\t1GB", + ); + mocks.listSandboxes.mockReturnValue({ + sandboxes: [ + { imageTag: "nemoclaw-sandbox-local:live-222" }, + { imageTag: "openshell/sandbox-from:in-use" }, + ], + defaultSandbox: null, + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + + await garbageCollectImages({ dryRun: true }); + + const out = logSpy.mock.calls.flat().join("\n"); + logSpy.mockRestore(); + + // The local orphan is reported, the still-registered local image is not, + // and both repos are scanned. + expect(out).toContain("nemoclaw-sandbox-local:gc-test-orphan-111"); + expect(out).not.toContain("nemoclaw-sandbox-local:live-222"); + const scannedRepos = mocks.dockerListImagesFormat.mock.calls.map((call) => call[0]); + expect(scannedRepos).toContain("openshell/sandbox-from"); + expect(scannedRepos).toContain("nemoclaw-sandbox-local"); + }); +}); diff --git a/src/lib/actions/maintenance.ts b/src/lib/actions/maintenance.ts index 4daa7f0fea1..d0ef891f456 100644 --- a/src/lib/actions/maintenance.ts +++ b/src/lib/actions/maintenance.ts @@ -9,6 +9,7 @@ import { normalizeGarbageCollectImagesOptions, } from "../domain/lifecycle/options"; import { findOrphanedSandboxImages, parseSandboxImageRows } from "../domain/maintenance/images"; +import { SANDBOX_IMAGE_REPOS } from "../domain/sandbox/image-tag"; import { captureSandboxListWithGatewayPreflightOrExit } from "../openshell-sandbox-list"; import { parseReadySandboxNames } from "../runtime-recovery"; import * as registry from "../state/registry"; @@ -147,10 +148,11 @@ export async function garbageCollectImages( let imagesOutput = ""; try { - imagesOutput = dockerListImagesFormat( - "openshell/sandbox-from", - "{{.Repository}}:{{.Tag}}\t{{.Size}}", - ); + // Scan every sandbox image repo, not just sandbox-from; see + // SANDBOX_IMAGE_REPOS for why local prebuilds were missed (#6301). + imagesOutput = SANDBOX_IMAGE_REPOS.map((repo) => + dockerListImagesFormat(repo, "{{.Repository}}:{{.Tag}}\t{{.Size}}"), + ).join("\n"); } catch { console.error(" Failed to query Docker images. Is Docker running?"); process.exit(1); diff --git a/src/lib/domain/maintenance/images.test.ts b/src/lib/domain/maintenance/images.test.ts index 0e4b752e19d..2c3fc8214b2 100644 --- a/src/lib/domain/maintenance/images.test.ts +++ b/src/lib/domain/maintenance/images.test.ts @@ -32,4 +32,23 @@ describe("maintenance image helpers", () => { ), ).toEqual([{ tag: "openshell/sandbox-from:two", size: "2GB" }]); }); + + it("orphans a local image while keeping a registered local image (#6301)", () => { + // A locally prebuilt sandbox left an orphan under nemoclaw-sandbox-local; + // the matcher must flag it by tag regardless of repo, and preserve the + // still-registered local image of another sandbox. + expect( + findOrphanedSandboxImages( + [ + { tag: "openshell/sandbox-from:one", size: "1GB" }, + { tag: "nemoclaw-sandbox-local:live-222", size: "2GB" }, + { tag: "nemoclaw-sandbox-local:gc-test-111", size: "3GB" }, + ], + [ + { imageTag: "openshell/sandbox-from:one" }, + { imageTag: "nemoclaw-sandbox-local:live-222" }, + ], + ), + ).toEqual([{ tag: "nemoclaw-sandbox-local:gc-test-111", size: "3GB" }]); + }); }); diff --git a/src/lib/domain/sandbox/image-tag.ts b/src/lib/domain/sandbox/image-tag.ts index 9aff3b80ca0..e32fed13019 100644 --- a/src/lib/domain/sandbox/image-tag.ts +++ b/src/lib/domain/sandbox/image-tag.ts @@ -1,6 +1,23 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +/** Gateway-built sandbox images (openshell sandbox create). */ +export const SANDBOX_FROM_IMAGE_REPO = "openshell/sandbox-from"; +/** + * Locally prebuilt sandbox images, tagged by the docker-driver-gateway path + * (Linux, or macOS on Apple Silicon — see isLinuxDockerDriverGatewayEnabled). + */ +export const LOCAL_SANDBOX_IMAGE_REPO = "nemoclaw-sandbox-local"; + +/** + * Every Docker repository that can hold a sandbox image. Any orphan sweep + * (`nemoclaw gc`) must enumerate all of them: locally prebuilt sandboxes are + * tagged under LOCAL_SANDBOX_IMAGE_REPO, not the gateway-side + * SANDBOX_FROM_IMAGE_REPO, so scanning only the latter left local orphans + * invisible to gc (#6301). + */ +export const SANDBOX_IMAGE_REPOS = [SANDBOX_FROM_IMAGE_REPO, LOCAL_SANDBOX_IMAGE_REPO] as const; + const BUILT_SANDBOX_IMAGE_RE = /Built image (openshell\/sandbox-from:\d+)/; export function resolveSandboxImageTagFromCreateOutput( @@ -16,5 +33,5 @@ export function resolveSandboxImageTagFromCreateOutput( warn( " Warning: could not parse image tag from build output; imageTag may be stale. Run 'nemoclaw gc' if destroy fails.", ); - return `openshell/sandbox-from:${buildId}`; + return `${SANDBOX_FROM_IMAGE_REPO}:${buildId}`; } diff --git a/src/lib/onboard/sandbox-prebuild.ts b/src/lib/onboard/sandbox-prebuild.ts index f4feb12d9c2..0fb2c6928c9 100644 --- a/src/lib/onboard/sandbox-prebuild.ts +++ b/src/lib/onboard/sandbox-prebuild.ts @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; import { dockerSpawn } from "../adapters/docker/exec"; +import { LOCAL_SANDBOX_IMAGE_REPO } from "../domain/sandbox/image-tag"; import { SANDBOX_BUILD_CONTEXT_PREFIX, type SandboxBuildContextOrigin, @@ -14,7 +15,7 @@ import { buildSubprocessEnv } from "../subprocess-env"; const TRUTHY_FLAG_VALUES = new Set(["1", "true", "yes", "on"]); const FALSY_FLAG_VALUES = new Set(["0", "false", "no", "off"]); -const LOCAL_IMAGE_REPO = "nemoclaw-sandbox-local"; +const LOCAL_IMAGE_REPO = LOCAL_SANDBOX_IMAGE_REPO; const DOCKER_ENV_NAMES = [ "DOCKER_API_VERSION", "DOCKER_CERT_PATH",