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
63 changes: 61 additions & 2 deletions src/lib/sandbox-base-image-platform-digest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
);

Expand All @@ -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",
Expand All @@ -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)}`;
Expand Down
10 changes: 9 additions & 1 deletion src/lib/sandbox-base-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ function hasCurrentLocalBuildProvenance(
function getRepoDigest(
imageName: string,
imageRef: string,
preserveExactDigestRef = false,
): { digest: string; ref: string } | null {
const referencesExpectedRepository =
imageRef === imageName ||
Expand Down Expand Up @@ -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;
Expand All @@ -157,6 +159,7 @@ function getRepoDigest(

type PulledCandidateOptions = {
pinnedRemoteRef?: string;
preserveExactDigestRef?: boolean;
refreshBeforeValidation?: boolean;
refreshIfLocalInvalid?: boolean;
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions src/lib/sandbox-base-image/resolution-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
Comment thread
ericksoa marked this conversation as resolved.

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,
Expand Down
33 changes: 30 additions & 3 deletions src/lib/sandbox-base-image/resolution-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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" };
}
}
Expand All @@ -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 {
Expand All @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions src/lib/sandbox-base-image/resolution-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading