diff --git a/src/lib/actions/sandbox/rebuild-dcode-artifact-drift.test.ts b/src/lib/actions/sandbox/rebuild-dcode-artifact-drift.test.ts index f6f9906a97c..2822310e4a4 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-artifact-drift.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-artifact-drift.test.ts @@ -44,7 +44,11 @@ describe("rebuildSandbox DCode flow: prepared artifact drift", () => { agentName: "langchain-deepagents-code", sandboxEntry: makeDcodeSandboxEntry(), dcodeRouteResults: [{ ok: true }, { ok: true }, { ok: true }], - dcodeBaseImageIds: ["sha256:dcode-base", "sha256:dcode-base", "sha256:changed"], + dcodeBaseImageIds: [ + `sha256:${"a".repeat(64)}`, + `sha256:${"a".repeat(64)}`, + "sha256:changed", + ], }); configureDcodeSession(harness); diff --git a/src/lib/actions/sandbox/rebuild-dcode-base-image-lease.test.ts b/src/lib/actions/sandbox/rebuild-dcode-base-image-lease.test.ts index 955aec3f8fd..eb2775c03a2 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-base-image-lease.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-base-image-lease.test.ts @@ -18,6 +18,34 @@ const trustedLocalOverride = { provenance: `${"b".repeat(64)}.${"c".repeat(64)}`, }; const trustedRemoteRef = `ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base@sha256:${"d".repeat(64)}`; +const trustedLocalResolutionMetadata = { + schema: 1, + key: "trusted-local-dcode-base", + imageName: "ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", + ref: trustedLocalOverride.ref, + digest: null, + source: "local", + imageId: `sha256:${"a".repeat(64)}`, + os: "linux", + architecture: "amd64", + glibcVersion: "2.41", + requireOpenshellSandboxAbi: true, + minGlibcVersion: "2.39", +}; +const trustedRemoteResolutionMetadata = { + schema: 1, + key: "trusted-remote-dcode-base", + imageName: "ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", + ref: trustedRemoteRef, + digest: `sha256:${"d".repeat(64)}`, + source: "source-sha", + imageId: "sha256:dcode-base", + os: "linux", + architecture: "amd64", + glibcVersion: "2.41", + requireOpenshellSandboxAbi: true, + minGlibcVersion: "2.39", +}; describe("rebuildSandbox DCode flow: base-image trust lease", () => { installRebuildFlowTestHooks({ acceptThirdPartySoftware: true }); @@ -36,9 +64,13 @@ describe("rebuildSandbox DCode flow: base-image trust lease", () => { leaseActive = true; return harness.restoreTrustedAgentBaseImageOverrideSpy; }); - harness.prepareManagedDcodeRebuildImageSpy.mockImplementation(async () => { + harness.prepareManagedDcodeRebuildImageSpy.mockImplementation(async (input) => { expect(leaseActive).toBe(true); expect(process.env[overrideEnvName]).toBe(trustedLocalOverride.ref); + expect(input.preResolvedBaseImageMetadata).toMatchObject({ + ref: trustedLocalOverride.ref, + imageId: `sha256:${"a".repeat(64)}`, + }); return { ok: true, prepared: harness.preparedDcodeBuildContext }; }); @@ -60,10 +92,7 @@ describe("rebuildSandbox DCode flow: base-image trust lease", () => { sandboxEntry: makeDcodeSandboxEntry(), }); configureDcodeSession(harness); - const resolutionMetadata = { - ref: trustedRemoteRef, - source: "source-sha", - }; + const resolutionMetadata = trustedRemoteResolutionMetadata; harness.ensureAgentBaseImageSpy.mockReturnValue({ imageTag: trustedRemoteRef, built: false, @@ -77,9 +106,10 @@ describe("rebuildSandbox DCode flow: base-image trust lease", () => { leaseActive = true; return harness.restoreTrustedAgentRemoteBaseImageOverrideSpy; }); - harness.prepareManagedDcodeRebuildImageSpy.mockImplementation(async () => { + harness.prepareManagedDcodeRebuildImageSpy.mockImplementation(async (input) => { expect(leaseActive).toBe(true); expect(process.env[overrideEnvName]).toBe(trustedRemoteRef); + expect(input.preResolvedBaseImageMetadata).toBe(resolutionMetadata); return { ok: true, prepared: harness.preparedDcodeBuildContext }; }); @@ -120,6 +150,7 @@ describe("rebuildSandbox DCode flow: base-image trust lease", () => { imageTag: trustedLocalOverride.ref, built: true, trustedLocalOverride, + resolutionMetadata: trustedLocalResolutionMetadata, }); let leaseActive = false; harness.restoreTrustedAgentBaseImageOverrideSpy.mockImplementation(() => { @@ -129,9 +160,10 @@ describe("rebuildSandbox DCode flow: base-image trust lease", () => { leaseActive = true; return harness.restoreTrustedAgentBaseImageOverrideSpy; }); - harness.prepareManagedDcodeRebuildImageSpy.mockImplementation(async () => { + harness.prepareManagedDcodeRebuildImageSpy.mockImplementation(async (input) => { expect(leaseActive).toBe(true); expect(process.env[overrideEnvName]).toBe(trustedLocalOverride.ref); + expect(input.preResolvedBaseImageMetadata).toBe(trustedLocalResolutionMetadata); return { ok: true, prepared: harness.preparedDcodeBuildContext }; }); @@ -166,10 +198,7 @@ describe("rebuildSandbox DCode flow: base-image trust lease", () => { sandboxEntry: makeDcodeSandboxEntry(), }); configureDcodeSession(harness); - const resolutionMetadata = { - ref: trustedRemoteRef, - source: "source-sha", - }; + const resolutionMetadata = trustedRemoteResolutionMetadata; harness.ensureAgentBaseImageSpy.mockReturnValue({ imageTag: trustedRemoteRef, built: false, @@ -246,4 +275,31 @@ describe("rebuildSandbox DCode flow: base-image trust lease", () => { restoreEnv(); } }); + + it("rejects base-image resolution metadata for a different local image (#9386)", async () => { + const harness = createRebuildFlowHarness({ + agentName: "langchain-deepagents-code", + sandboxEntry: makeDcodeSandboxEntry(), + }); + configureDcodeSession(harness); + harness.ensureAgentBaseImageSpy.mockReturnValue({ + imageTag: trustedLocalOverride.ref, + built: true, + trustedLocalOverride, + resolutionMetadata: { + ...trustedLocalResolutionMetadata, + imageId: `sha256:${"e".repeat(64)}`, + }, + }); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("DCode base-image resolution metadata does not match the pinned image"); + + expect(harness.prepareManagedDcodeRebuildImageSpy).not.toHaveBeenCalled(); + expect(harness.dockerRmiSpy).toHaveBeenCalledWith(trustedLocalOverride.ref, { + ignoreError: true, + suppressOutput: true, + }); + }); }); diff --git a/src/lib/actions/sandbox/rebuild-dcode-preflight.ts b/src/lib/actions/sandbox/rebuild-dcode-preflight.ts index cfdedaf41ab..518bb239101 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-preflight.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-preflight.ts @@ -21,7 +21,10 @@ import { getResumeSandboxGpuOverrides, resolveSandboxGpuConfig, } from "../../onboard/sandbox-gpu-mode"; -import type { TrustedLocalBaseImageOverride } from "../../sandbox-base-image"; +import type { + SandboxBaseImageResolutionMetadata, + TrustedLocalBaseImageOverride, +} from "../../sandbox-base-image"; import { redact } from "../../security/redact"; import * as onboardSession from "../../state/onboard-session"; import * as registry from "../../state/registry"; @@ -46,6 +49,7 @@ export type DcodeRebuildPreflightBail = (message: string, code?: number) => neve type PinnedDcodeBaseImage = { readonly imageRef: string; + readonly resolutionMetadata: SandboxBaseImageResolutionMetadata; readonly trustedLocalOverride?: TrustedLocalBaseImageOverride; readonly trustedRemoteOverride?: TrustedRemoteBaseImageOverride; dispose(): boolean; @@ -335,6 +339,21 @@ function resolvePinnedDcodeBaseImage(bail: DcodeRebuildPreflightBail): PinnedDco } fail("DCode base image identity could not be verified", bail); } + const resolutionMetadata = result.resolutionMetadata; + if ( + !resolutionMetadata || + resolutionMetadata.ref !== imageRef || + resolutionMetadata.imageId !== imageId + ) { + if (trustedLocalOverride) { + try { + dockerRmi(imageRef, { ignoreError: true, suppressOutput: true }); + } catch { + // Report the metadata mismatch instead of the Docker cleanup failure. + } + } + fail("DCode base-image resolution metadata does not match the pinned image", bail); + } let disposed = trustedRemoteOverride !== undefined; let warned = false; @@ -359,6 +378,7 @@ function resolvePinnedDcodeBaseImage(bail: DcodeRebuildPreflightBail): PinnedDco if (trustedLocalOverride) process.on("exit", dispose); return { imageRef, + resolutionMetadata, trustedLocalOverride, trustedRemoteOverride, dispose, @@ -433,7 +453,8 @@ export async function prepareDcodeReplacementBeforeMutation( pinnedBase = resolvePinnedDcodeBaseImage(bail); const sandboxGpuConfig = getRecordedGpuConfig(sandboxName, entry, session); if (sandboxGpuConfig.errors.length > 0) fail(sandboxGpuConfig.errors.join(" "), bail); - const imageResult = await withPinnedBaseImage(pinnedBase, () => + const pinnedBaseForPreparation = pinnedBase; + const imageResult = await withPinnedBaseImage(pinnedBaseForPreparation, () => prepareManagedDcodeRebuildImage({ agent: loadAgent(DCODE_AGENT_NAME), provider: target.provider, @@ -444,6 +465,7 @@ export async function prepareDcodeReplacementBeforeMutation( webSearchConfig, toolDisclosure: input.toolDisclosure, dcodeAutoApprovalMode: input.dcodeAutoApprovalMode, + preResolvedBaseImageMetadata: pinnedBaseForPreparation.resolutionMetadata, sandboxGpuConfig, gatewayPort, }), diff --git a/src/lib/actions/sandbox/rebuild-managed-image-configuration.test.ts b/src/lib/actions/sandbox/rebuild-managed-image-configuration.test.ts index f195815a921..c155db63fe4 100644 --- a/src/lib/actions/sandbox/rebuild-managed-image-configuration.test.ts +++ b/src/lib/actions/sandbox/rebuild-managed-image-configuration.test.ts @@ -11,17 +11,19 @@ import { restoreEnv } from "../../../../test/helpers/env-test-helpers"; import { dcodeInput, expectPreparedImage, + writeDcodeRebuildDockerfile, } from "../../../../test/helpers/rebuild-managed-image-preflight-harness"; import { disposePreparedDcodeRebuildImage, prepareManagedDcodeRebuildImage, } from "./rebuild-managed-image-preflight"; +import type { SandboxBaseImageResolutionMetadata } from "../../sandbox-base-image"; describe("managed DCode rebuild image configuration", () => { it("pins recorded reasoning and web search while restoring ambient state (#6195)", async () => { const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-rebuild-fidelity-")); const stagedDockerfile = path.join(testRoot, "Dockerfile"); - fs.writeFileSync(stagedDockerfile, "FROM scratch\n"); + writeDcodeRebuildDockerfile(stagedDockerfile); const previousReasoning = process.env.NEMOCLAW_REASONING; process.env.NEMOCLAW_REASONING = "false"; let reasoningDuringPatch: string | undefined; @@ -74,7 +76,7 @@ describe("managed DCode rebuild image configuration", () => { it("binds DCode auto-approval mode into the prepared image configuration (#6478)", async () => { const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-rebuild-auto-approval-")); const stagedDockerfile = path.join(testRoot, "Dockerfile"); - fs.writeFileSync(stagedDockerfile, "FROM scratch\n"); + writeDcodeRebuildDockerfile(stagedDockerfile); const prepareDockerfilePatch = vi.fn(async () => ({ buildId: "dcode-auto-approval", dashboardRemoteBindPrepared: false, @@ -111,10 +113,111 @@ describe("managed DCode rebuild image configuration", () => { } }); + it("passes pinned base-image resolution metadata to DCode Dockerfile patching (#9386)", async () => { + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-rebuild-base-resolution-")); + const stagedDockerfile = path.join(testRoot, "Dockerfile"); + const digest = `sha256:${"a".repeat(64)}`; + const metadata = { + schema: 1, + key: "dcode-platform-base", + imageName: "ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", + ref: `ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base@${digest}`, + digest, + source: "override", + imageId: `sha256:${"b".repeat(64)}`, + os: "linux", + architecture: "amd64", + glibcVersion: "2.41", + requireOpenshellSandboxAbi: true, + minGlibcVersion: "2.39", + } satisfies SandboxBaseImageResolutionMetadata; + writeDcodeRebuildDockerfile(stagedDockerfile, metadata); + const prepareDockerfilePatch = vi.fn(async () => ({ + buildId: "dcode-base-resolution", + dashboardRemoteBindPrepared: false, + resolvedBaseImage: null, + })); + + try { + const result = await prepareManagedDcodeRebuildImage( + dcodeInput({ + dcodeAutoApprovalMode: "thread-opt-in", + preResolvedBaseImageMetadata: metadata, + }), + { + stageBuildContext: () => ({ + buildCtx: testRoot, + stagedDockerfile, + origin: "generated" as const, + cleanupBuildCtx: () => { + fs.rmSync(testRoot, { recursive: true, force: true }); + return true; + }, + }), + prepareDockerfilePatch, + buildImage: () => ({ status: 0 }) as never, + removeImage: () => ({ status: 0 }) as never, + }, + ); + + expect(result.ok).toBe(true); + expect(prepareDockerfilePatch).toHaveBeenCalledWith( + expect.objectContaining({ + dcodeAutoApprovalMode: "thread-opt-in", + preResolvedBaseImageMetadata: metadata, + }), + ); + disposePreparedDcodeRebuildImage(expectPreparedImage(result)); + } finally { + fs.rmSync(testRoot, { recursive: true, force: true }); + } + }); + + it("rejects a staged DCode base image that differs from pinned base-image resolution metadata (#9386)", async () => { + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-rebuild-base-mismatch-")); + const stagedDockerfile = path.join(testRoot, "Dockerfile"); + const input = dcodeInput(); + writeDcodeRebuildDockerfile(stagedDockerfile, { + ...input.preResolvedBaseImageMetadata, + ref: `ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base@sha256:${"c".repeat(64)}`, + }); + const cleanupBuildCtx = vi.fn(() => { + fs.rmSync(testRoot, { recursive: true, force: true }); + return true; + }); + const prepareDockerfilePatch = vi.fn(); + const buildImage = vi.fn(); + + try { + await expect( + prepareManagedDcodeRebuildImage(input, { + stageBuildContext: () => ({ + buildCtx: testRoot, + stagedDockerfile, + origin: "generated" as const, + cleanupBuildCtx, + }), + prepareDockerfilePatch, + buildImage, + removeImage: () => ({ status: 0 }) as never, + }), + ).resolves.toEqual({ + ok: false, + detail: + "managed DCode staged base image does not match pinned base-image resolution metadata", + }); + expect(prepareDockerfilePatch).not.toHaveBeenCalled(); + expect(buildImage).not.toHaveBeenCalled(); + expect(cleanupBuildCtx).toHaveBeenCalledOnce(); + } finally { + fs.rmSync(testRoot, { recursive: true, force: true }); + } + }); + it("preserves remote dashboard bind preparation from the managed Dockerfile patch (#6024)", async () => { const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-rebuild-remote-bind-")); const stagedDockerfile = path.join(testRoot, "Dockerfile"); - fs.writeFileSync(stagedDockerfile, "FROM scratch\n"); + writeDcodeRebuildDockerfile(stagedDockerfile); try { const result = await prepareManagedDcodeRebuildImage(dcodeInput(), { @@ -147,7 +250,7 @@ describe("managed DCode rebuild image configuration", () => { it("defaults missing compatible-endpoint reasoning without borrowing ambient state (#6195)", async () => { const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-rebuild-reasoning-")); const stagedDockerfile = path.join(buildCtx, "Dockerfile"); - fs.writeFileSync(stagedDockerfile, "FROM scratch\n"); + writeDcodeRebuildDockerfile(stagedDockerfile); const previousReasoning = process.env.NEMOCLAW_REASONING; process.env.NEMOCLAW_REASONING = "true"; let reasoningDuringPatch: string | undefined; diff --git a/src/lib/actions/sandbox/rebuild-managed-image-preflight.ts b/src/lib/actions/sandbox/rebuild-managed-image-preflight.ts index f06774cac2a..e86aa1241e2 100644 --- a/src/lib/actions/sandbox/rebuild-managed-image-preflight.ts +++ b/src/lib/actions/sandbox/rebuild-managed-image-preflight.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import crypto from "node:crypto"; +import fs from "node:fs"; import { dockerBuild, dockerRmi } from "../../adapters/docker"; import { fingerprintBuildContext } from "../../adapters/fs/build-context-fingerprint"; @@ -18,6 +19,7 @@ import { formatBuildFailureDiagnostics, OPENCLAW_SANDBOX_BASE_IMAGE, SANDBOX_BASE_TAG, + type SandboxBaseImageResolutionMetadata, } from "../../sandbox-base-image"; import { applyReasoningEffortEnv, @@ -44,6 +46,7 @@ export type ManagedDcodeRebuildImageInput = { webSearchConfig: WebSearchConfig | null; toolDisclosure: ToolDisclosure; dcodeAutoApprovalMode: DcodeAutoApprovalMode; + preResolvedBaseImageMetadata: SandboxBaseImageResolutionMetadata; sandboxGpuConfig: SandboxGpuConfig; gatewayPort?: number; }; @@ -86,6 +89,26 @@ function defaultImageTag(): string { return `nemoclaw-rebuild-preflight:${String(process.pid)}-${crypto.randomUUID()}`; } +function requirePinnedDcodeBaseImage( + stagedDockerfile: string, + metadata: SandboxBaseImageResolutionMetadata, +): void { + let dockerfile: string; + try { + dockerfile = fs.readFileSync(stagedDockerfile, "utf8"); + } catch { + throw new Error("managed DCode staged Dockerfile could not be read"); + } + const baseImageRefs = [...dockerfile.matchAll(/^ARG BASE_IMAGE=(\S+)$/gm)].map( + (match) => match[1], + ); + if (baseImageRefs.length !== 1 || baseImageRefs[0] !== metadata.ref) { + throw new Error( + "managed DCode staged base image does not match pinned base-image resolution metadata", + ); + } +} + /** Confirm that the retained, private build context still matches the prebuilt input. */ export function verifyPreparedDcodeRebuildImage(prepared: PreparedDcodeRebuildImage): boolean { return verifyPreparedBuildContext(prepared); @@ -146,6 +169,7 @@ export async function prepareManagedDcodeRebuildImage( }, }); cleanupBuildContext = createIdempotentBuildContextCleanup(staged.cleanupBuildCtx); + requirePinnedDcodeBaseImage(staged.stagedDockerfile, input.preResolvedBaseImageMetadata); const { buildId, dashboardRemoteBindPrepared } = await preparePatch({ agent: input.agent, @@ -160,6 +184,7 @@ export async function prepareManagedDcodeRebuildImage( webSearchConfig: input.webSearchConfig, toolDisclosure: input.toolDisclosure, dcodeAutoApprovalMode: input.dcodeAutoApprovalMode, + preResolvedBaseImageMetadata: input.preResolvedBaseImageMetadata, hermesToolGateways: [], sandboxGpuConfig: input.sandboxGpuConfig, gatewayPort: input.gatewayPort ?? GATEWAY_PORT, diff --git a/src/lib/actions/sandbox/rebuild-managed-image-preparation.test.ts b/src/lib/actions/sandbox/rebuild-managed-image-preparation.test.ts index 165e5e51719..b9f0fc94cdf 100644 --- a/src/lib/actions/sandbox/rebuild-managed-image-preparation.test.ts +++ b/src/lib/actions/sandbox/rebuild-managed-image-preparation.test.ts @@ -12,6 +12,7 @@ import { createPreparedDcodeImageFixture, dcodeInput, expectPreparedImage, + writeDcodeRebuildDockerfile, } from "../../../../test/helpers/rebuild-managed-image-preflight-harness"; import { ROOT } from "../../runner"; import { @@ -72,7 +73,7 @@ describe("managed DCode rebuild image preparation", () => { it("retries retained-context cleanup after a transient removal failure (#6195)", async () => { const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-rebuild-cleanup-")); const stagedDockerfile = path.join(buildCtx, "Dockerfile"); - fs.writeFileSync(stagedDockerfile, "FROM scratch\n"); + writeDcodeRebuildDockerfile(stagedDockerfile); const cleanupBuildCtx = vi .fn<() => boolean>() .mockReturnValueOnce(false) @@ -106,7 +107,7 @@ describe("managed DCode rebuild image preparation", () => { it("redacts failed build output and cleans every temporary image input (#6195)", async () => { const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-rebuild-failure-")); const stagedDockerfile = path.join(buildCtx, "Dockerfile"); - fs.writeFileSync(stagedDockerfile, "FROM scratch\n"); + writeDcodeRebuildDockerfile(stagedDockerfile); const cleanupBuildCtx = vi.fn(() => { fs.rmSync(buildCtx, { recursive: true, force: true }); return true; diff --git a/src/lib/actions/sandbox/rebuild-managed-image-verification.test.ts b/src/lib/actions/sandbox/rebuild-managed-image-verification.test.ts index a3441a8089d..4e50e3d5c42 100644 --- a/src/lib/actions/sandbox/rebuild-managed-image-verification.test.ts +++ b/src/lib/actions/sandbox/rebuild-managed-image-verification.test.ts @@ -155,6 +155,7 @@ describe("managed DCode rebuild image verification", () => { it("rejects changes through an already-open descriptor and disposes idempotently (#6195)", async () => { const fixture = await createPreparedDcodeImageFixture(); + const originalDockerfile = fs.readFileSync(fixture.stagedDockerfile, "utf8"); const mutationFd = fs.openSync( fixture.stagedDockerfile, fs.constants.O_WRONLY | fs.constants.O_APPEND, @@ -165,7 +166,7 @@ describe("managed DCode rebuild image verification", () => { fs.writeSync(mutationFd, "# temporary drift\n", null, "utf8"); expect(verifyPreparedDcodeRebuildImage(fixture.prepared)).toBe(false); fs.ftruncateSync(mutationFd, 0); - fs.writeSync(mutationFd, "FROM scratch\n", 0, "utf8"); + fs.writeSync(mutationFd, originalDockerfile, 0, "utf8"); fs.futimesSync(mutationFd, originalMutationStat.atime, originalMutationStat.mtime); fs.utimesSync(fixture.buildCtx, fixture.stableDockerfileTime, fixture.stableDockerfileTime); expect(verifyPreparedDcodeRebuildImage(fixture.prepared)).toBe(true); diff --git a/src/lib/agent/base-image-hermes-resolution.test.ts b/src/lib/agent/base-image-hermes-resolution.test.ts index 22fa469cd0a..d1997eafa9c 100644 --- a/src/lib/agent/base-image-hermes-resolution.test.ts +++ b/src/lib/agent/base-image-hermes-resolution.test.ts @@ -42,7 +42,9 @@ vi.mock("../sandbox-base-image/source-identity", async (importOriginal) => ({ })); import { + bindLocalAgentBaseImageToPinnedProvenance, createAgentSandbox, + ensureAgentBaseImage, pinTrustedAgentRemoteBaseImageOverrideForOperation, } from "./base-image"; @@ -143,6 +145,69 @@ describe("Hermes base-image resolver integration", () => { ); }); + it("reuses an explicit digest resolution only during its trusted rebuild lease (#9386)", () => { + dockerMocks.imageInspect.mockReturnValue({ status: 0 }); + const exactInspection = new Map([ + [`{{json .RepoDigests}}\0${trackedRef}`, JSON.stringify([trackedRef])], + [ + `{{json .}}\0${trackedRef}`, + JSON.stringify({ + Architecture: "arm64", + Id: imageId, + Os: "linux", + RepoDigests: [trackedRef], + }), + ], + ]); + dockerMocks.imageInspectFormat.mockImplementation((format: string, ref: string) => + (exactInspection.get(`${format}\0${ref}`) ?? "").trim(), + ); + vi.stubEnv("NEMOCLAW_HERMES_SANDBOX_BASE_IMAGE_REF", trackedRef); + + const outer = ensureAgentBaseImage(makeAgent()); + const resolutionMetadata = outer.resolutionMetadata; + expect(resolutionMetadata).toMatchObject({ ref: trackedRef, source: "override" }); + const restore = pinTrustedAgentRemoteBaseImageOverrideForOperation( + "NEMOCLAW_HERMES_SANDBOX_BASE_IMAGE_REF", + { + ref: trackedRef, + resolutionMetadata: resolutionMetadata as NonNullable, + }, + ); + + try { + const inner = ensureAgentBaseImage(makeAgent()); + expect(inner.imageTag).toBe(trackedRef); + expect(inner.resolutionMetadata).toBe(resolutionMetadata); + + const missingDigestInspection = new Map([ + [ + `{{json .}}\0${trackedRef}`, + JSON.stringify({ + Architecture: "arm64", + Id: imageId, + Os: "linux", + RepoDigests: [], + }), + ], + ]); + dockerMocks.imageInspectFormat.mockImplementation((format: string, ref: string) => + (missingDigestInspection.get(`${format}\0${ref}`) ?? "").trim(), + ); + const sparse = ensureAgentBaseImage(makeAgent()); + expect(sparse.imageTag).toBe(trackedRef); + expect(sparse.resolutionMetadata).toBe(resolutionMetadata); + expect(dockerMocks.pull).not.toHaveBeenCalled(); + } finally { + restore(); + } + + const afterRestore = ensureAgentBaseImage(makeAgent()); + expect(afterRestore.imageTag).toBe(trackedRef); + expect(afterRestore.resolutionMetadata).not.toBe(resolutionMetadata); + expect(afterRestore.resolutionMetadata).toMatchObject({ ref: trackedRef, source: "override" }); + }, 30_000); + it("reuses an outer resolver's pinned platform digest only during its rebuild lease (#7144)", () => { const outer = createAgentSandbox(makeAgent()); createdBuildContexts.push(outer.buildCtx); @@ -163,7 +228,7 @@ describe("Hermes base-image resolver integration", () => { expect(fs.readFileSync(inner.stagedDockerfile, "utf8")).toContain( `ARG BASE_IMAGE=${platformRef}`, ); - expect(inner.baseImageResolutionMetadata).toEqual(resolutionMetadata); + expect(inner.baseImageResolutionMetadata).toBe(resolutionMetadata); } finally { restore(); } @@ -172,4 +237,53 @@ describe("Hermes base-image resolver integration", () => { `Hermes final image does not accept base image ref '${platformRef}'`, ); }, 30_000); + + it("uses a proven local Hermes base-image alias only to select its remote digest during a rebuild lease (#7144)", () => { + const localAlias = "nemoclaw-hermes-sandbox-base-local:e2e-current"; + const inspectStatusByRef = new Map([ + [localAlias, 0], + [trackedRef, 0], + [platformRef, 0], + ]); + const inspectedImage = JSON.stringify({ + Architecture: "arm64", + Id: imageId, + Os: "linux", + RepoDigests: [platformRef], + }); + const inspectOutputByKey = new Map([ + [`{{json .}}\0${localAlias}`, inspectedImage], + [`{{json .}}\0${trackedRef}`, inspectedImage], + [`{{json .}}\0${platformRef}`, inspectedImage], + ]); + dockerMocks.imageInspect.mockImplementation((ref: string) => ({ + status: inspectStatusByRef.get(ref) ?? 1, + })); + dockerMocks.imageInspectFormat.mockImplementation((format: string, ref: string) => + (inspectOutputByKey.get(`${format}\0${ref}`) ?? "").trim(), + ); + const agent = makeAgent(); + const resolutionMetadata = bindLocalAgentBaseImageToPinnedProvenance(agent, localAlias); + expect(resolutionMetadata).toMatchObject({ ref: platformRef, source: "pinned" }); + vi.stubEnv("NEMOCLAW_HERMES_SANDBOX_BASE_IMAGE_REF", localAlias); + const restore = pinTrustedAgentRemoteBaseImageOverrideForOperation( + "NEMOCLAW_HERMES_SANDBOX_BASE_IMAGE_REF", + { + ref: localAlias, + resolutionMetadata: resolutionMetadata as NonNullable, + }, + ); + + try { + const inner = ensureAgentBaseImage(agent); + expect(inner.imageTag).toBe(platformRef); + expect(inner.resolutionMetadata).toBe(resolutionMetadata); + } finally { + restore(); + } + + expect(() => ensureAgentBaseImage(agent)).toThrow( + `Hermes Agent sandbox base image override '${localAlias}' is outside the trusted repository 'ghcr.io/nvidia/nemoclaw/hermes-sandbox-base'.`, + ); + }, 30_000); }); diff --git a/src/lib/agent/base-image.ts b/src/lib/agent/base-image.ts index e2ce1ca4733..efcff743e73 100644 --- a/src/lib/agent/base-image.ts +++ b/src/lib/agent/base-image.ts @@ -38,6 +38,7 @@ import { parseTemporarySandboxBaseImageId, type ResolveBaseImageOptions, resolveSandboxBaseImage, + reuseSandboxBaseImageResolutionHint, SANDBOX_BASE_BUILD_PROVENANCE_LABEL, SANDBOX_BASE_RESOLUTION_SCHEMA, SANDBOX_BASE_TAG, @@ -134,6 +135,47 @@ export function pinTrustedAgentRemoteBaseImageOverrideForOperation( }; } +function reuseTrustedAgentRemoteBaseImageOverride( + resolutionOptions: ResolveBaseImageOptions, + overrideEnvVar: string, + override: TrustedRemoteBaseImageOverride, +): SandboxBaseImageResolution { + const usesExplicitOverride = override.resolutionMetadata.source === "override"; + if (usesExplicitOverride && process.env[overrideEnvVar]?.trim() !== override.ref) { + throw new SandboxBaseImageResolutionError( + `${resolutionOptions.label || "Sandbox base image"} trust lease no longer matches its explicit override`, + ); + } + const trustedEnv = { + ...process.env, + ...(usesExplicitOverride + ? { + [overrideEnvVar]: override.ref, + NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD: "0", + } + : {}), + }; + if (!usesExplicitOverride) delete trustedEnv[overrideEnvVar]; + const trustedOptions = { + ...resolutionOptions, + ...(usesExplicitOverride ? { localTag: override.ref } : {}), + env: trustedEnv, + resolutionHint: override.resolutionMetadata, + }; + const expectedKey = createSandboxBaseImageResolutionKey(trustedOptions); + const reused = reuseSandboxBaseImageResolutionHint(trustedOptions, expectedKey); + if ( + !reused || + reused.ref !== override.resolutionMetadata.ref || + reused.metadata !== override.resolutionMetadata + ) { + throw new SandboxBaseImageResolutionError( + `${resolutionOptions.label || "Sandbox base image"} trust lease no longer matches its resolution metadata`, + ); + } + return reused; +} + export function getAgentSandboxBaseImageEnvVar(agentName: string): string { return `NEMOCLAW_${agentName.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_SANDBOX_BASE_IMAGE_REF`; } @@ -592,15 +634,13 @@ export function ensureAgentBaseImage( ? trustedLocalOverrideLeases.get(overrideEnvVar) : undefined; const trustedRemoteOverride = trustedRemoteOverrideLeases.get(overrideEnvVar); - const canonicalEnv = { ...process.env }; - delete canonicalEnv[overrideEnvVar]; const resolved = explicitOverride ? trustedRemoteOverride?.ref === explicitOverride - ? resolveSandboxBaseImage({ - ...resolutionOptions, - env: canonicalEnv, - resolutionHint: trustedRemoteOverride.resolutionMetadata, - }) + ? reuseTrustedAgentRemoteBaseImageOverride( + resolutionOptions, + overrideEnvVar, + trustedRemoteOverride, + ) : resolveExactImage(explicitOverride, trustedLocalOverride) : resolveSandboxBaseImage(resolutionOptions); if (resolved) { diff --git a/test/helpers/rebuild-flow-dcode-harness.ts b/test/helpers/rebuild-flow-dcode-harness.ts index dd32e501f68..5afb50d3ea2 100644 --- a/test/helpers/rebuild-flow-dcode-harness.ts +++ b/test/helpers/rebuild-flow-dcode-harness.ts @@ -189,6 +189,20 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): : "OpenClaw"; const agentBaseImageId = `sha256:${"a".repeat(64)}`; const agentBaseImageRef = `nemoclaw-${agentName}-sandbox-base-local:image-${agentBaseImageId.slice("sha256:".length)}`; + const agentBaseImageResolutionMetadata = { + schema: 1, + key: `${agentName}-rebuild-base`, + imageName: `ghcr.io/nvidia/nemoclaw/${agentName}-sandbox-base`, + ref: agentBaseImageRef, + digest: null, + source: "local" as const, + imageId: agentBaseImageId, + os: "linux", + architecture: "amd64", + glibcVersion: "2.41", + requireOpenshellSandboxAbi: true, + minGlibcVersion: "2.39", + }; const agentDef = { name: agentName, displayName: agentDisplayName, @@ -269,6 +283,7 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): const ensureAgentBaseImageSpy = vi.spyOn(agentOnboard, "ensureAgentBaseImage").mockReturnValue({ imageTag: agentBaseImageRef, built: true, + resolutionMetadata: agentBaseImageResolutionMetadata, trustedLocalOverride, }); const restoreTrustedAgentBaseImageOverrideSpy = vi.fn(); diff --git a/test/helpers/rebuild-managed-image-preflight-harness.ts b/test/helpers/rebuild-managed-image-preflight-harness.ts index 9bccd1c0b00..85faacae554 100644 --- a/test/helpers/rebuild-managed-image-preflight-harness.ts +++ b/test/helpers/rebuild-managed-image-preflight-harness.ts @@ -14,6 +14,30 @@ import { prepareManagedDcodeRebuildImage, } from "../../src/lib/actions/sandbox/rebuild-managed-image-preflight"; import { loadAgent } from "../../src/lib/agent/defs"; +import type { SandboxBaseImageResolutionMetadata } from "../../src/lib/sandbox-base-image"; + +const DCODE_BASE_DIGEST = `sha256:${"a".repeat(64)}`; +const DCODE_BASE_IMAGE_RESOLUTION_METADATA = { + schema: 1, + key: "dcode-preflight-base", + imageName: "ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", + ref: `ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base@${DCODE_BASE_DIGEST}`, + digest: DCODE_BASE_DIGEST, + source: "override", + imageId: `sha256:${"b".repeat(64)}`, + os: "linux", + architecture: "amd64", + glibcVersion: "2.41", + requireOpenshellSandboxAbi: true, + minGlibcVersion: "2.39", +} satisfies SandboxBaseImageResolutionMetadata; + +export function writeDcodeRebuildDockerfile( + stagedDockerfile: string, + metadata: SandboxBaseImageResolutionMetadata = DCODE_BASE_IMAGE_RESOLUTION_METADATA, +): void { + fs.writeFileSync(stagedDockerfile, `ARG BASE_IMAGE=${metadata.ref}\nFROM \${BASE_IMAGE}\n`); +} export const NO_FOLLOW_FLAG = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0; @@ -38,6 +62,7 @@ export function dcodeInput( compatibleEndpointReasoning: "false", compatibleEndpointReasoningEffort: null, dcodeAutoApprovalMode: "disabled", + preResolvedBaseImageMetadata: DCODE_BASE_IMAGE_RESOLUTION_METADATA, toolDisclosure: "progressive", webSearchConfig: null, sandboxGpuConfig: { @@ -61,7 +86,7 @@ export async function createPreparedDcodeImageFixture( const stagedDockerfile = path.join(buildCtx, "Dockerfile"); const originalDockerfile = path.join(testRoot, "Dockerfile.original"); const replacementDockerfile = path.join(testRoot, "Dockerfile.replacement"); - fs.writeFileSync(stagedDockerfile, "FROM scratch\n"); + writeDcodeRebuildDockerfile(stagedDockerfile, overrides.preResolvedBaseImageMetadata); const stableDockerfileTime = new Date("2026-01-01T00:00:00.000Z"); fs.utimesSync(stagedDockerfile, stableDockerfileTime, stableDockerfileTime); fs.writeFileSync(replacementDockerfile, "FROM attacker-controlled\n");