diff --git a/src/lib/sandbox-base-image-platform-digest.test.ts b/src/lib/sandbox-base-image-platform-digest.test.ts index 476ca15dfe4..f39ee9e2433 100644 --- a/src/lib/sandbox-base-image-platform-digest.test.ts +++ b/src/lib/sandbox-base-image-platform-digest.test.ts @@ -197,13 +197,24 @@ describe("sandbox base-image pinned platform digest resolution", () => { }); }); - it("falls back to the Dockerfile-pinned digest when RepoDigests JSON is malformed", () => { + it("preserves metadata for an exact digest when RepoDigests JSON is malformed (#9386)", () => { dockerMocks.imageInspect.mockImplementation((ref: string) => ({ status: ref === REF ? 0 : 1, })); dockerMocks.imageInspectFormat.mockImplementation((format: string, ref: string) => ( - new Map([[`{{json .RepoDigests}}\0${REF}`, "{not-json"]]).get(`${format}\0${ref}`) ?? "" + new Map([ + [`{{json .RepoDigests}}\0${REF}`, "{not-json"], + [ + `{{json .}}\0${REF}`, + JSON.stringify({ + Id: IMAGE_ID, + RepoDigests: [], + Os: "linux", + Architecture: "amd64", + }), + ], + ]).get(`${format}\0${ref}`) ?? "" ).trim(), ); @@ -218,6 +229,11 @@ describe("sandbox base-image pinned platform digest resolution", () => { digest: DIGEST, source: "pinned", pinnedRemoteRef: REF, + metadata: { + ref: REF, + digest: DIGEST, + imageId: IMAGE_ID, + }, }); expect(traceMocks.add).toHaveBeenCalledWith( "nemoclaw.sandbox_base_image.repodigest_parse_failed", @@ -226,6 +242,49 @@ describe("sandbox base-image pinned platform digest resolution", () => { expect(dockerMocks.build).not.toHaveBeenCalled(); }); + it("preserves an exact override when Docker reports a different repository digest (#9386)", () => { + dockerMocks.imageInspect.mockImplementation((ref: string) => ({ + status: ref === PLATFORM_REF ? 0 : 1, + })); + dockerMocks.imageInspectFormat.mockImplementation((format: string, ref: string) => + ( + new Map([ + [`{{json .RepoDigests}}\0${PLATFORM_REF}`, JSON.stringify([REF])], + [ + `{{json .}}\0${PLATFORM_REF}`, + JSON.stringify({ + Id: IMAGE_ID, + RepoDigests: [REF], + Os: "linux", + Architecture: "amd64", + }), + ], + ]).get(`${format}\0${ref}`) ?? "" + ).trim(), + ); + + const resolved = resolveSandboxBaseImage({ + ...resolutionOptions(), + envVar: "NEMOCLAW_SANDBOX_BASE_IMAGE_REF", + env: { + ...resolutionOptions().env, + NEMOCLAW_SANDBOX_BASE_IMAGE_REF: PLATFORM_REF, + }, + }); + + expect(resolved).toMatchObject({ + ref: PLATFORM_REF, + digest: PLATFORM_DIGEST, + source: "override", + metadata: { + ref: PLATFORM_REF, + digest: PLATFORM_DIGEST, + imageId: IMAGE_ID, + }, + }); + expect(dockerMocks.build).not.toHaveBeenCalled(); + }); + it("rejects a pinned resolution hint from a stale Dockerfile pin", () => { const options = resolutionOptions(); const stalePin = `${IMAGE_NAME}@sha256:${"c".repeat(64)}`; diff --git a/src/lib/sandbox-base-image.ts b/src/lib/sandbox-base-image.ts index d628490687c..d7be1c13d1d 100644 --- a/src/lib/sandbox-base-image.ts +++ b/src/lib/sandbox-base-image.ts @@ -116,6 +116,7 @@ function hasCurrentLocalBuildProvenance( function getRepoDigest( imageName: string, imageRef: string, + preserveExactDigestRef = false, ): { digest: string; ref: string } | null { const referencesExpectedRepository = imageRef === imageName || @@ -147,6 +148,7 @@ function getRepoDigest( }); return pinnedDigest; } + if (preserveExactDigestRef && pinnedDigest) return pinnedDigest; const repoDigest = Array.isArray(repoDigests) ? repoDigests.find((entry) => String(entry).startsWith(`${imageName}@sha256:`)) : null; @@ -157,6 +159,7 @@ function getRepoDigest( type PulledCandidateOptions = { pinnedRemoteRef?: string; + preserveExactDigestRef?: boolean; refreshBeforeValidation?: boolean; refreshIfLocalInvalid?: boolean; }; @@ -299,7 +302,11 @@ function validatePulledCandidate( return null; } - const repoDigest = getRepoDigest(imageName, imageRef); + const repoDigest = getRepoDigest( + imageName, + imageRef, + candidateOptions.preserveExactDigestRef === true, + ); return { ref: repoDigest?.ref || imageRef, digest: repoDigest?.digest || null, @@ -485,6 +492,7 @@ export function resolveSandboxBaseImage( ); } const resolved = resolvePulledCandidate(options.imageName, override, "override", options, { + preserveExactDigestRef: true, refreshBeforeValidation: true, }); if (resolved?.digest) return finish(resolved); diff --git a/src/lib/sandbox-base-image/resolution-metadata.test.ts b/src/lib/sandbox-base-image/resolution-metadata.test.ts index 6f30f83192c..68b4831c77e 100644 --- a/src/lib/sandbox-base-image/resolution-metadata.test.ts +++ b/src/lib/sandbox-base-image/resolution-metadata.test.ts @@ -108,6 +108,33 @@ describe("sandbox base-image resolution metadata lifecycle", () => { ); }); + it("preserves an exact digest resolution when Docker omits RepoDigests (#9386)", () => { + mocks.dockerImageInspectFormat.mockReturnValue( + JSON.stringify({ + Id: inspected.Id, + Os: inspected.Os, + Architecture: inspected.Architecture, + }), + ); + + expect(createSandboxBaseImageResolutionMetadata(options, KEY, publishedResolution)).toEqual( + metadata, + ); + }); + + it("rejects sparse RepoDigests when the resolved reference is not the exact digest", () => { + mocks.dockerImageInspectFormat.mockReturnValue( + JSON.stringify({ ...inspected, RepoDigests: [] }), + ); + + expect( + createSandboxBaseImageResolutionMetadata(options, KEY, { + ...publishedResolution, + ref: `${IMAGE_NAME}:published`, + }), + ).toBeNull(); + }); + it("finalizes a local fallback with identity metadata and no repository digest (#4680)", () => { const localResolution: SandboxBaseImageResolution = { ref: options.localTag, diff --git a/src/lib/sandbox-base-image/resolution-metadata.ts b/src/lib/sandbox-base-image/resolution-metadata.ts index 7f8a48bb5ee..bfbd0ec39e8 100644 --- a/src/lib/sandbox-base-image/resolution-metadata.ts +++ b/src/lib/sandbox-base-image/resolution-metadata.ts @@ -25,6 +25,14 @@ export function inspectLocalImageMetadata(imageRef: string): LocalImageMetadata } } +function isExactSameRepositoryDigestRef( + imageName: string, + digest: string, + ref: string, +): boolean { + return /^sha256:[0-9a-f]{64}$/u.test(digest) && ref === `${imageName}@${digest}`; +} + export function validateSandboxBaseImageResolutionMetadata(input: { metadata: SandboxBaseImageResolutionMetadata; expectedKey: string; @@ -67,7 +75,10 @@ export function validateSandboxBaseImageResolutionMetadata(input: { if (metadata.digest) { const expectedRepoDigest = `${input.imageName}@${metadata.digest}`; const repoDigests = Array.isArray(inspected.RepoDigests) ? inspected.RepoDigests : []; - if (!repoDigests.some((entry) => String(entry) === expectedRepoDigest)) { + if ( + !isExactSameRepositoryDigestRef(input.imageName, metadata.digest, metadata.ref) && + !repoDigests.some((entry) => String(entry) === expectedRepoDigest) + ) { return { ok: false, reason: "repo_digest_missing" }; } } @@ -89,7 +100,19 @@ export function createSandboxBaseImageResolutionMetadata( if (resolution.digest) { const expectedRepoDigest = `${options.imageName}@${resolution.digest}`; const repoDigests = Array.isArray(inspected?.RepoDigests) ? inspected.RepoDigests : []; - if (!repoDigests.some((entry) => String(entry) === expectedRepoDigest)) return null; + // Docker may omit RepoDigests after resolving an exact platform manifest. + // The resolver's exact same-repository digest ref remains immutable proof. + const exactResolvedReference = isExactSameRepositoryDigestRef( + options.imageName, + resolution.digest, + resolution.ref, + ); + if ( + !exactResolvedReference && + !repoDigests.some((entry) => String(entry) === expectedRepoDigest) + ) { + return null; + } } return { @@ -115,7 +138,11 @@ export function finalizeSandboxBaseImageResolution( resolution: SandboxBaseImageResolution, ): SandboxBaseImageResolution { let locallyProvenResolution = resolution; - if (resolution.digest) { + const preserveExactOverride = + resolution.source === "override" && + resolution.digest !== null && + isExactSameRepositoryDigestRef(options.imageName, resolution.digest, resolution.ref); + if (resolution.digest && !preserveExactOverride) { const inspected = inspectLocalImageMetadata(resolution.ref); const expectedRepoDigest = `${options.imageName}@${resolution.digest}`; const matchingRepoDigests = Array.isArray(inspected?.RepoDigests) diff --git a/src/lib/sandbox-base-image/resolution-validation.test.ts b/src/lib/sandbox-base-image/resolution-validation.test.ts index 40cfc4104fa..1b974833a1b 100644 --- a/src/lib/sandbox-base-image/resolution-validation.test.ts +++ b/src/lib/sandbox-base-image/resolution-validation.test.ts @@ -58,6 +58,22 @@ describe("sandbox base-image resolution validation", () => { }); }); + it("validates an exact digest reference when Docker reports another repository digest (#9386)", () => { + const digest = `sha256:${"a".repeat(64)}`; + const exactMetadata = { + ...metadata, + ref: `${metadata.imageName}@${digest}`, + digest, + }; + + expect( + validate(exactMetadata, { + ...inspected, + RepoDigests: [`${metadata.imageName}@sha256:${"b".repeat(64)}`], + }), + ).toEqual({ ok: true }); + }); + it("validates local fallback images by identity without RepoDigests (#4680)", () => { expect( validate(