diff --git a/src/lib/actions/sandbox/rebuild-agent-base-image-preflight.test.ts b/src/lib/actions/sandbox/rebuild-agent-base-image-preflight.test.ts index bd593c97039..95cb6519a4c 100644 --- a/src/lib/actions/sandbox/rebuild-agent-base-image-preflight.test.ts +++ b/src/lib/actions/sandbox/rebuild-agent-base-image-preflight.test.ts @@ -18,6 +18,13 @@ const agentDefsPath = "../../agent/defs.js"; const agentOnboardPath = "../../agent/onboard.js"; const dockerImagePath = "../../adapters/docker/image.js"; const overrideEnvVar = "NEMOCLAW_HERMES_SANDBOX_BASE_IMAGE_REF"; +const cachedRemoteRef = `ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:${"a".repeat(64)}`; +const refreshedRemoteRef = `ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:${"b".repeat(64)}`; +const rebuiltLocalRef = `nemoclaw-hermes-sandbox-base-local:image-${"c".repeat(64)}`; +const rebuiltLocalTrust = { + ref: rebuiltLocalRef, + provenance: `${"d".repeat(64)}.${"e".repeat(64)}`, +}; function loadRebuildFlowHelpers(): RebuildFlowHelpersModule { delete require.cache[requireDist.resolve(rebuildFlowHelpersPath)]; @@ -67,17 +74,24 @@ describe("ensureRebuildAgentBaseImage", () => { vi.spyOn(loadAgentDefs(), "loadAgent").mockReturnValue(agent); const ensureAgentBaseImage = vi .spyOn(loadAgentOnboard(), "ensureAgentBaseImage") - .mockImplementation((_agent, options = {}) => ({ - imageTag: options.forceBaseImageRefresh - ? "hermes:refreshed" - : options.resolutionHint - ? "hermes:cached" - : "hermes:rebuilt", - built: !options.resolutionHint, - })); + .mockImplementation((_agent, options = {}) => + options.forceBaseImageRebuild + ? { + imageTag: rebuiltLocalRef, + built: true, + trustedLocalOverride: rebuiltLocalTrust, + } + : { + imageTag: options.forceBaseImageRefresh ? refreshedRemoteRef : cachedRemoteRef, + built: false, + }, + ); const bindLocalAgentBaseImageToPinnedProvenance = vi .spyOn(loadAgentOnboard(), "bindLocalAgentBaseImageToPinnedProvenance") .mockReturnValue(null); + const bindLocalAgentBaseImageHandoffToResolution = vi + .spyOn(loadAgentOnboard(), "bindLocalAgentBaseImageHandoffToResolution") + .mockReturnValue(null); const pinAgentSandboxBaseImageRef = vi .spyOn(loadAgentOnboard(), "pinAgentSandboxBaseImageRef") .mockImplementation((_agentName, imageRef) => imageRef); @@ -92,6 +106,7 @@ describe("ensureRebuildAgentBaseImage", () => { agent, ensureAgentBaseImage, bindLocalAgentBaseImageToPinnedProvenance, + bindLocalAgentBaseImageHandoffToResolution, pinAgentSandboxBaseImageRef, pinTrustedAgentRemoteBaseImageOverrideForOperation, restoreTrustedRemoteOverride, @@ -105,7 +120,7 @@ describe("ensureRebuildAgentBaseImage", () => { expect(ensureRebuildAgentBaseImage("hermes", makeBail(), { resolutionHint: hint })).toEqual({ ok: true, - imageRef: "hermes:cached", + imageRef: cachedRemoteRef, overrideEnvVar, }); expect(ensureAgentBaseImage).toHaveBeenCalledWith(agent, { @@ -120,8 +135,9 @@ describe("ensureRebuildAgentBaseImage", () => { expect(ensureRebuildAgentBaseImage("hermes", makeBail())).toEqual({ ok: true, - imageRef: "hermes:rebuilt", + imageRef: rebuiltLocalRef, overrideEnvVar, + trustedLocalOverride: rebuiltLocalTrust, }); expect(ensureAgentBaseImage).toHaveBeenCalledWith(agent, { forceBaseImageRebuild: true, @@ -179,7 +195,7 @@ describe("ensureRebuildAgentBaseImage", () => { }), ).toEqual({ ok: true, - imageRef: "hermes:refreshed", + imageRef: refreshedRemoteRef, overrideEnvVar, }); expect(ensureAgentBaseImage).toHaveBeenCalledWith(agent, { @@ -271,11 +287,32 @@ describe("ensureRebuildAgentBaseImage", () => { }); it("retains exit cleanup until a failed temporary removal succeeds (#7144)", () => { - const { ensureAgentBaseImage, pinAgentSandboxBaseImageRef, dockerRmi } = setup(); + const { + ensureAgentBaseImage, + bindLocalAgentBaseImageHandoffToResolution, + pinAgentSandboxBaseImageRef, + dockerRmi, + } = setup(); const platformRef = "hermes:mutable-override"; const localRef = `nemoclaw-hermes-sandbox-base-local:rebuild-123-${"b".repeat(16)}-image-${"c".repeat(64)}`; - ensureAgentBaseImage.mockReturnValue({ imageTag: platformRef, built: false }); + const resolutionMetadata = { + ref: platformRef, + digest: null, + source: "local", + imageId: `sha256:${"c".repeat(64)}`, + } as SandboxBaseImageResolutionMetadata; + const handoffTrust = { + ref: localRef, + provenance: `${"d".repeat(64)}.${"e".repeat(64)}`, + }; + ensureAgentBaseImage.mockReturnValue({ + imageTag: platformRef, + built: false, + resolutionMetadata, + reusedResolutionHint: resolutionMetadata, + }); pinAgentSandboxBaseImageRef.mockReturnValue(localRef); + bindLocalAgentBaseImageHandoffToResolution.mockReturnValue(handoffTrust); dockerRmi .mockReturnValueOnce({ status: 23 } as never) .mockReturnValueOnce({ status: 0 } as never); 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 7b10122e8c4..135b0303a59 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 @@ -15,7 +15,7 @@ import { const overrideEnvName = "NEMOCLAW_LANGCHAIN_DEEPAGENTS_CODE_SANDBOX_BASE_IMAGE_REF"; const trustedLocalOverride = { - ref: "nemoclaw-langchain-deepagents-code-base:test", + ref: `nemoclaw-langchain-deepagents-code-sandbox-base-local:image-${"a".repeat(64)}`, provenance: `${"b".repeat(64)}.${"c".repeat(64)}`, }; diff --git a/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts b/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts index c6d89e44f67..1740d1bbfa8 100644 --- a/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts @@ -6,6 +6,7 @@ import * as dockerImage from "../../adapters/docker/image"; import * as agentDefs from "../../agent/defs"; import * as agentOnboard from "../../agent/onboard"; import * as gatewayRuntime from "../../gateway-runtime-action"; +import type { SandboxBaseImageResolutionMetadata } from "../../sandbox-base-image"; import * as sandboxState from "../../state/sandbox"; import * as userManagedFilesProbe from "../../state/user-managed-files-probe"; import { @@ -135,7 +136,9 @@ describe("rebuild agent base image preflight", () => { }); function mockBaseImagePreflight(imageRef: string) { - vi.spyOn(agentDefs, "loadAgent").mockReturnValue({ name: "hermes" } as never); + const loadAgent = vi + .spyOn(agentDefs, "loadAgent") + .mockReturnValue({ name: "hermes", displayName: "Hermes Agent" } as never); const ensureAgentBaseImage = vi .spyOn(agentOnboard, "ensureAgentBaseImage") .mockReturnValue({ imageTag: imageRef, built: true }); @@ -145,39 +148,57 @@ describe("rebuild agent base image preflight", () => { const pinAgentSandboxBaseImageRef = vi .spyOn(agentOnboard, "pinAgentSandboxBaseImageRef") .mockImplementation((_agentName, ref) => String(ref)); + const bindLocalAgentBaseImageHandoffToResolution = vi + .spyOn(agentOnboard, "bindLocalAgentBaseImageHandoffToResolution") + .mockReturnValue(null); const dockerRmi = vi.spyOn(dockerImage, "dockerRmi").mockReturnValue({ status: 0 } as never); return { + loadAgent, ensureAgentBaseImage, bindLocalAgentBaseImageToPinnedProvenance, + bindLocalAgentBaseImageHandoffToResolution, pinAgentSandboxBaseImageRef, dockerRmi, }; } it("forces a repository-local build and returns its exact ref when no override exists", () => { - const imageRef = "nemoclaw-hermes-sandbox-base-local:12345678"; + const imageRef = `nemoclaw-hermes-sandbox-base-local:image-${"a".repeat(64)}`; + const trustedLocalOverride = { + ref: imageRef, + provenance: `${"b".repeat(64)}.${"c".repeat(64)}`, + }; const { ensureAgentBaseImage } = mockBaseImagePreflight(imageRef); + ensureAgentBaseImage.mockReturnValue({ + imageTag: imageRef, + built: true, + trustedLocalOverride, + }); const result = ensureRebuildAgentBaseImage("hermes", makeBail()); expect(ensureAgentBaseImage).toHaveBeenCalledWith(expect.objectContaining({ name: "hermes" }), { forceBaseImageRebuild: true, }); - expect(result).toEqual({ ok: true, imageRef, overrideEnvVar }); + expect(result).toEqual({ + ok: true, + imageRef, + overrideEnvVar, + trustedLocalOverride, + }); }); - it("resolves an explicit caller override instead of replacing it during preflight", () => { + it("fails closed when an explicit local result lacks validated outer metadata", () => { process.env[overrideEnvVar] = "nemoclaw-hermes-sandbox-base-local:caller"; const mutableRef = "nemoclaw-hermes-sandbox-base-local:resolved"; - const immutableRef = `nemoclaw-hermes-sandbox-base-local:image-${"a".repeat(64)}`; - const { - ensureAgentBaseImage, - bindLocalAgentBaseImageToPinnedProvenance, - pinAgentSandboxBaseImageRef, - } = mockBaseImagePreflight(mutableRef); - pinAgentSandboxBaseImageRef.mockReturnValue(immutableRef); + const rebuildRef = `nemoclaw-hermes-sandbox-base-local:rebuild-343338-${"b".repeat(16)}-image-${"a".repeat(64)}`; + const { ensureAgentBaseImage, pinAgentSandboxBaseImageRef, dockerRmi } = + mockBaseImagePreflight(mutableRef); + pinAgentSandboxBaseImageRef.mockReturnValue(rebuildRef); - const result = ensureRebuildAgentBaseImage("hermes", makeBail()); + expect(() => ensureRebuildAgentBaseImage("hermes", makeBail())).toThrow( + "could not be bound to its rebuild handoff", + ); expect(ensureAgentBaseImage).toHaveBeenCalledWith(expect.objectContaining({ name: "hermes" }), { forceBaseImageRebuild: false, @@ -186,17 +207,10 @@ describe("rebuild agent base image preflight", () => { forceLocal: true, temporary: true, }); - expect(bindLocalAgentBaseImageToPinnedProvenance).toHaveBeenCalledWith( - expect.objectContaining({ name: "hermes" }), - immutableRef, - ); - expect(result).toEqual({ - ok: true, - imageRef: immutableRef, - overrideEnvVar, - disposeImageRef: expect.any(Function), + expect(dockerRmi).toHaveBeenCalledWith(rebuildRef, { + ignoreError: true, + suppressOutput: true, }); - expect(disposeRebuildAgentBaseImagePreflight(result)).toBe(true); }); it("proves a caller alias before resolving it as the pinned remote image (#7144)", () => { @@ -252,6 +266,158 @@ describe("rebuild agent base image preflight", () => { }); }); + it("leases a temporary local handoff only from the stable outer resolution", () => { + const inputHashRef = "nemoclaw-hermes-sandbox-base-local:3ef2ca87"; + const rebuildRef = `nemoclaw-hermes-sandbox-base-local:rebuild-343338-${"c".repeat(16)}-image-${"d".repeat(64)}`; + const provenance = `${"e".repeat(64)}.${"f".repeat(64)}`; + const resolutionMetadata = { + ref: inputHashRef, + digest: null, + source: "local", + imageId: `sha256:${"d".repeat(64)}`, + } as SandboxBaseImageResolutionMetadata; + const mocks = mockBaseImagePreflight(inputHashRef); + const { + bindLocalAgentBaseImageHandoffToResolution, + ensureAgentBaseImage, + pinAgentSandboxBaseImageRef, + } = mocks; + pinAgentSandboxBaseImageRef.mockReturnValue(rebuildRef); + ensureAgentBaseImage.mockReturnValue({ + imageTag: inputHashRef, + built: false, + resolutionMetadata, + reusedResolutionHint: resolutionMetadata, + }); + bindLocalAgentBaseImageHandoffToResolution.mockReturnValue({ + ref: rebuildRef, + provenance, + }); + + try { + const result = ensureRebuildAgentBaseImage("hermes", makeBail(), { + resolutionHint: resolutionMetadata, + }); + + expect(bindLocalAgentBaseImageHandoffToResolution).toHaveBeenCalledWith( + expect.objectContaining({ name: "hermes" }), + inputHashRef, + rebuildRef, + resolutionMetadata, + resolutionMetadata, + ); + expect(result).toMatchObject({ + imageRef: rebuildRef, + resolutionMetadata, + trustedLocalOverride: { ref: rebuildRef, provenance }, + }); + } finally { + for (const mock of Object.values(mocks)) mock.mockRestore(); + } + }); + + it("force-rebuilds instead of trusting fresh fallback metadata after a local tag moved", () => { + const sourceRef = "nemoclaw-hermes-sandbox-base-local:3ef2ca87"; + const imageId = `sha256:${"d".repeat(64)}`; + const canonicalRef = `nemoclaw-hermes-sandbox-base-local:image-${"d".repeat(64)}`; + const persistedHint = { + ref: sourceRef, + digest: null, + source: "local", + imageId: `sha256:${"a".repeat(64)}`, + } as SandboxBaseImageResolutionMetadata; + const freshFallbackMetadata = { + ...persistedHint, + imageId, + }; + const forcedMetadata = { + ...freshFallbackMetadata, + ref: canonicalRef, + }; + const forcedTrust = { + ref: canonicalRef, + provenance: `${"e".repeat(64)}.${"f".repeat(64)}`, + }; + const { + bindLocalAgentBaseImageHandoffToResolution, + ensureAgentBaseImage, + pinAgentSandboxBaseImageRef, + } = mockBaseImagePreflight(sourceRef); + ensureAgentBaseImage + .mockReturnValueOnce({ + imageTag: sourceRef, + built: false, + resolutionMetadata: freshFallbackMetadata, + }) + .mockReturnValueOnce({ + imageTag: canonicalRef, + built: true, + resolutionMetadata: forcedMetadata, + trustedLocalOverride: forcedTrust, + }); + pinAgentSandboxBaseImageRef.mockReturnValue(canonicalRef); + + const result = ensureRebuildAgentBaseImage("hermes", makeBail(), { + resolutionHint: persistedHint, + }); + + expect(bindLocalAgentBaseImageHandoffToResolution).not.toHaveBeenCalled(); + expect(ensureAgentBaseImage).toHaveBeenCalledTimes(2); + expect(ensureAgentBaseImage).toHaveBeenNthCalledWith(2, expect.anything(), { + forceBaseImageRebuild: true, + }); + expect(result).toMatchObject({ + imageRef: canonicalRef, + resolutionMetadata: forcedMetadata, + trustedLocalOverride: forcedTrust, + }); + }); + + it("reuses the canonical forced-build metadata on the next offline rebuild", () => { + const canonicalRef = `nemoclaw-hermes-sandbox-base-local:image-${"d".repeat(64)}`; + const resolutionMetadata = { + ref: canonicalRef, + digest: null, + source: "local", + imageId: `sha256:${"d".repeat(64)}`, + } as SandboxBaseImageResolutionMetadata; + const provenance = `${"e".repeat(64)}.${"f".repeat(64)}`; + const { + bindLocalAgentBaseImageHandoffToResolution, + ensureAgentBaseImage, + pinAgentSandboxBaseImageRef, + } = mockBaseImagePreflight(canonicalRef); + ensureAgentBaseImage.mockReturnValue({ + imageTag: canonicalRef, + built: false, + resolutionMetadata, + reusedResolutionHint: resolutionMetadata, + }); + pinAgentSandboxBaseImageRef.mockReturnValue(canonicalRef); + bindLocalAgentBaseImageHandoffToResolution.mockReturnValue({ + ref: canonicalRef, + provenance, + }); + + const result = ensureRebuildAgentBaseImage("hermes", makeBail(), { + resolutionHint: resolutionMetadata, + }); + + expect(ensureAgentBaseImage).toHaveBeenCalledOnce(); + expect(ensureAgentBaseImage).toHaveBeenCalledWith(expect.anything(), { + forceBaseImageRebuild: false, + resolutionHint: resolutionMetadata, + }); + expect(bindLocalAgentBaseImageHandoffToResolution).toHaveBeenCalledWith( + expect.anything(), + canonicalRef, + canonicalRef, + resolutionMetadata, + resolutionMetadata, + ); + expect(result.trustedLocalOverride).toEqual({ ref: canonicalRef, provenance }); + }); + it("disposes a temporary recreate handoff at most once (#7144)", () => { const disposeImageRef = vi.fn(() => true); const preflight = { diff --git a/src/lib/actions/sandbox/rebuild-flow-helpers.ts b/src/lib/actions/sandbox/rebuild-flow-helpers.ts index 63a7666f8a8..ed2d68fbe4e 100644 --- a/src/lib/actions/sandbox/rebuild-flow-helpers.ts +++ b/src/lib/actions/sandbox/rebuild-flow-helpers.ts @@ -8,6 +8,7 @@ import { } from "../../adapters/openshell/gateway-drift"; import { loadAgent } from "../../agent/defs"; import { + bindLocalAgentBaseImageHandoffToResolution, bindLocalAgentBaseImageToPinnedProvenance, ensureAgentBaseImage, getAgentSandboxBaseImageEnvVar, @@ -32,7 +33,11 @@ import { printSandboxListFailureWithRecoveryContext, } from "../../openshell-sandbox-list"; import { parseLiveSandboxNames } from "../../runtime-recovery"; -import type { SandboxBaseImageResolutionMetadata } from "../../sandbox-base-image"; +import { + parseContentAddressedSandboxBaseImageId, + type SandboxBaseImageResolutionMetadata, + type TrustedLocalBaseImageOverride, +} from "../../sandbox-base-image"; import * as shields from "../../shields"; import * as registry from "../../state/registry"; import * as sandboxState from "../../state/sandbox"; @@ -62,7 +67,7 @@ export type RebuildAgentBaseImagePreflight = { overrideEnvVar: string | null; resolutionMetadata?: SandboxBaseImageResolutionMetadata; disposeImageRef?: () => boolean; - trustedLocalOverride?: import("../../sandbox-base-image").TrustedLocalBaseImageOverride; + trustedLocalOverride?: TrustedLocalBaseImageOverride; trustedRemoteOverride?: import("../../agent/base-image").TrustedRemoteBaseImageOverride; }; @@ -299,6 +304,21 @@ export function ensureRebuildAgentBaseImage( } finally { restoreExplicitOverrideTrust(); } + const reusedLocalResolution = + result.resolutionMetadata?.source === "local" && + result.reusedResolutionHint === result.resolutionMetadata; + if ( + !hasExplicitOverride && + result.imageTag && + result.resolutionMetadata?.source === "local" && + !result.trustedLocalOverride && + !reusedLocalResolution + ) { + // A stale persisted hint may fall through to a fresh local fallback. Its + // public provenance label is not authority. Rebuild once so the build + // call returns a fresh in-memory lease bound to the canonical image ID. + result = ensureAgentBaseImage(agentDef, { forceBaseImageRebuild: true }); + } const needsTemporaryHandoff = result.imageTag !== null && !isCanonicalLocalBaseImageRef(agentDef.name, result.imageTag) && @@ -314,6 +334,38 @@ export function ensureRebuildAgentBaseImage( needsTemporaryHandoff && imageRef && imageRef !== result.imageTag ? createTemporaryBaseImageHandoffDisposer(imageRef) : undefined; + const inheritedTrustedOverride = + result.trustedLocalOverride?.ref === imageRef ? result.trustedLocalOverride : null; + let handoffTrustedOverride: TrustedLocalBaseImageOverride | null = null; + if ( + imageRef && + result.imageTag && + !inheritedTrustedOverride && + result.resolutionMetadata && + result.reusedResolutionHint === result.resolutionMetadata + ) { + handoffTrustedOverride = bindLocalAgentBaseImageHandoffToResolution( + agentDef, + result.imageTag, + imageRef, + result.resolutionMetadata, + result.reusedResolutionHint, + ); + } + const localImageName = `nemoclaw-${agentDef.name}-sandbox-base-local`; + const localHandoff = + imageRef && parseContentAddressedSandboxBaseImageId(localImageName, imageRef) !== null; + if ( + imageRef && + (needsTemporaryHandoff || localHandoff || result.resolutionMetadata?.source === "local") && + !inheritedTrustedOverride && + !handoffTrustedOverride + ) { + disposeImageRef?.(); + throw new Error( + `Resolved ${agentDef.displayName} local base image could not be bound to its rebuild handoff`, + ); + } const resolutionMetadata = result.resolutionMetadata ?? explicitOverrideResolution ?? @@ -326,7 +378,11 @@ export function ensureRebuildAgentBaseImage( overrideEnvVar, ...(resolutionMetadata ? { resolutionMetadata } : {}), ...(disposeImageRef ? { disposeImageRef } : {}), - ...(result.trustedLocalOverride ? { trustedLocalOverride: result.trustedLocalOverride } : {}), + ...(inheritedTrustedOverride + ? { trustedLocalOverride: inheritedTrustedOverride } + : handoffTrustedOverride + ? { trustedLocalOverride: handoffTrustedOverride } + : {}), ...(imageRef && resolutionMetadata && isImmutableRemoteBaseImageRef(imageRef) ? { trustedRemoteOverride: { ref: imageRef, resolutionMetadata } } : {}), diff --git a/src/lib/agent/base-image-handoff.test.ts b/src/lib/agent/base-image-handoff.test.ts new file mode 100644 index 00000000000..db78b5974b0 --- /dev/null +++ b/src/lib/agent/base-image-handoff.test.ts @@ -0,0 +1,239 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { makeAgent } from "../../../test/helpers/base-image-test-harness"; + +const dockerMocks = vi.hoisted(() => ({ + build: vi.fn(), + capture: vi.fn(), + imageInspect: vi.fn(), + imageInspectFormat: vi.fn(), + infoFormat: vi.fn(), + rmi: vi.fn(), + tag: vi.fn(), +})); + +vi.mock("../adapters/docker", () => ({ + dockerBuild: dockerMocks.build, + dockerCapture: dockerMocks.capture, + dockerImageInspect: dockerMocks.imageInspect, + dockerImageInspectFormat: dockerMocks.imageInspectFormat, + dockerInfoFormat: dockerMocks.infoFormat, + dockerRmi: dockerMocks.rmi, + dockerTag: dockerMocks.tag, +})); + +vi.mock("../sandbox-base-image/source-identity", async (importOriginal) => ({ + ...(await importOriginal()), + baseImageInputsChangedSinceMain: vi.fn(() => false), + baseImageInputsDirty: vi.fn(() => false), + buildLocalBaseTag: vi.fn((prefix: string) => `${prefix}:local`), + getNearestVersionedBaseImageTags: vi.fn(() => []), + getSourceRevisionIds: vi.fn(() => ["test-revision"]), + getSourceShortShaTags: vi.fn(() => []), + getVersionedBaseImageTags: vi.fn(() => []), +})); + +import { ROOT } from "../runner"; +import { + createSandboxBaseImageBuildProvenanceKey, + createSandboxBaseImageResolutionKey, + type LocalImageMetadata, + type ResolveBaseImageOptions, + SANDBOX_BASE_BUILD_PROVENANCE_LABEL, + type SandboxBaseImageResolutionMetadata, +} from "../sandbox-base-image"; +import { bindLocalAgentBaseImageHandoffToResolution } from "./base-image"; + +function fixture(options: { canonicalSource?: boolean } = {}) { + const agent = makeAgent(); + const dockerfile = fs.readFileSync(agent.dockerfilePath as string, "utf8"); + const pinnedRemoteRef = dockerfile.match(/^ARG BASE_IMAGE=(\S+)$/m)?.[1] as string; + const resolutionOptions: ResolveBaseImageOptions = { + imageName: "ghcr.io/nvidia/nemoclaw/hermes-sandbox-base", + dockerfilePath: agent.dockerfileBasePath as string, + localTag: "nemoclaw-hermes-sandbox-base-local:local", + envVar: "NEMOCLAW_HERMES_SANDBOX_BASE_IMAGE_REF", + label: "Hermes Agent sandbox base image", + requireOpenshellSandboxAbi: process.platform === "linux", + rootDir: ROOT, + pinnedRemoteRef, + preferPinnedRemoteRef: true, + validateImage: () => true, + validationDescription: "the required MCP Streamable HTTP runtime", + }; + const imageId = `sha256:${"a".repeat(64)}`; + const canonicalRef = `nemoclaw-hermes-sandbox-base-local:image-${"a".repeat(64)}`; + const sourceRef = options.canonicalSource ? canonicalRef : resolutionOptions.localTag; + const handoffRef = options.canonicalSource + ? canonicalRef + : `nemoclaw-hermes-sandbox-base-local:rebuild-343338-${"b".repeat(16)}-image-${"a".repeat(64)}`; + const metadata: SandboxBaseImageResolutionMetadata = { + schema: 1, + key: createSandboxBaseImageResolutionKey(resolutionOptions), + imageName: resolutionOptions.imageName, + ref: sourceRef, + digest: null, + source: "local", + imageId, + os: "linux", + architecture: "amd64", + glibcVersion: process.platform === "linux" ? "2.41" : null, + requireOpenshellSandboxAbi: process.platform === "linux", + minGlibcVersion: "2.39", + }; + const provenance = `${createSandboxBaseImageBuildProvenanceKey(resolutionOptions)}.${"c".repeat(64)}`; + return { agent, sourceRef, handoffRef, imageId, metadata, provenance }; +} + +function installInspections( + input: ReturnType, + overrides: { + source?: Partial; + handoff?: Partial; + sourceProvenance?: string | null; + handoffProvenance?: string | null; + } = {}, +): void { + dockerMocks.imageInspectFormat.mockImplementation((format: string, ref: string) => { + const handoff = ref === input.handoffRef && input.handoffRef !== input.sourceRef; + const imageOverrides = handoff ? overrides.handoff : overrides.source; + const provenance = handoff + ? overrides.handoffProvenance === undefined + ? input.provenance + : overrides.handoffProvenance + : overrides.sourceProvenance === undefined + ? input.provenance + : overrides.sourceProvenance; + return format === "{{json .}}" + ? JSON.stringify({ + Id: input.imageId, + Os: input.metadata.os, + Architecture: input.metadata.architecture, + RepoDigests: [], + Config: { + Labels: { + ...(provenance ? { [SANDBOX_BASE_BUILD_PROVENANCE_LABEL]: provenance } : {}), + }, + }, + ...imageOverrides, + }) + : input.imageId; + }); +} + +describe("agent base-image local handoff authority", () => { + beforeEach(() => { + vi.clearAllMocks(); + dockerMocks.infoFormat.mockReturnValue("linux/amd64\n"); + }); + + it.each([ + ["stable local alias", false], + ["canonical content-addressed source", true], + ])("binds an exact reused hint from a %s", (_case, canonicalSource) => { + const input = fixture({ canonicalSource }); + installInspections(input); + + expect( + bindLocalAgentBaseImageHandoffToResolution( + input.agent, + input.sourceRef, + input.handoffRef, + input.metadata, + input.metadata, + ), + ).toEqual({ ref: input.handoffRef, provenance: input.provenance }); + }); + + it.each([ + "fresh metadata object", + "wrong schema", + "wrong key", + "wrong image name", + "non-local source", + "non-null digest", + "noncanonical stable ref", + "wrong image ID", + ])("refuses %s as handoff authority", (invalidCase) => { + const input = fixture(); + installInspections(input); + const metadata: SandboxBaseImageResolutionMetadata = { + ...input.metadata, + ...(invalidCase === "wrong schema" ? { schema: 2 } : {}), + ...(invalidCase === "wrong key" ? { key: "wrong-key" } : {}), + ...(invalidCase === "wrong image name" ? { imageName: "registry.invalid/base" } : {}), + ...(invalidCase === "non-local source" ? { source: "pinned" as const } : {}), + ...(invalidCase === "non-null digest" ? { digest: `sha256:${"d".repeat(64)}` } : {}), + ...(invalidCase === "noncanonical stable ref" + ? { ref: "nemoclaw-hermes-sandbox-base-local:moved" } + : {}), + ...(invalidCase === "wrong image ID" ? { imageId: `sha256:${"d".repeat(64)}` } : {}), + }; + const sourceRef = invalidCase === "noncanonical stable ref" ? metadata.ref : input.sourceRef; + const reusedHint = invalidCase === "fresh metadata object" ? input.metadata : metadata; + + expect( + bindLocalAgentBaseImageHandoffToResolution( + input.agent, + sourceRef, + input.handoffRef, + metadata, + reusedHint, + ), + ).toBeNull(); + }); + + it.each([ + "source OS mismatch", + "source architecture mismatch", + "source image ID mismatch", + "handoff OS mismatch", + "handoff architecture mismatch", + "stale provenance", + "missing source provenance", + "missing handoff provenance", + "moved handoff image", + ])("refuses %s", (invalidCase) => { + const input = fixture(); + installInspections(input, { + source: + invalidCase === "source OS mismatch" + ? { Os: "windows" } + : invalidCase === "source architecture mismatch" + ? { Architecture: "arm64" } + : invalidCase === "source image ID mismatch" + ? { Id: `sha256:${"d".repeat(64)}` } + : undefined, + handoff: + invalidCase === "handoff OS mismatch" + ? { Os: "windows" } + : invalidCase === "handoff architecture mismatch" + ? { Architecture: "arm64" } + : invalidCase === "moved handoff image" + ? { Id: `sha256:${"d".repeat(64)}` } + : undefined, + sourceProvenance: + invalidCase === "stale provenance" + ? `${"d".repeat(64)}.${"c".repeat(64)}` + : invalidCase === "missing source provenance" + ? null + : undefined, + handoffProvenance: invalidCase === "missing handoff provenance" ? null : undefined, + }); + + expect( + bindLocalAgentBaseImageHandoffToResolution( + input.agent, + input.sourceRef, + input.handoffRef, + input.metadata, + input.metadata, + ), + ).toBeNull(); + }); +}); diff --git a/src/lib/agent/base-image.test.ts b/src/lib/agent/base-image.test.ts index 9c17fd50377..e55fcc09dd6 100644 --- a/src/lib/agent/base-image.test.ts +++ b/src/lib/agent/base-image.test.ts @@ -105,6 +105,24 @@ describe("agent base image provisioning", () => { ); }); + it("marks only an exact warm resolution-hint reuse as handoff authority", () => { + withMockedDocker(({ ensureAgentBaseImage, resolveSandboxBaseImageMock }) => { + const resolutionHint = makeResolutionMetadata(); + resolveSandboxBaseImageMock.mockReturnValue({ + ref: resolutionHint.ref, + digest: null, + source: "local", + glibcVersion: resolutionHint.glibcVersion, + metadata: resolutionHint, + }); + + expect(ensureAgentBaseImage(makeAgent(), { resolutionHint })).toMatchObject({ + resolutionMetadata: resolutionHint, + reusedResolutionHint: resolutionHint, + }); + }); + }); + it("binds an identical local Hermes alias to its tracked pinned provenance (#7144)", () => { vi.spyOn(process, "platform", "get").mockReturnValue("linux"); withMockedDocker( @@ -455,6 +473,51 @@ describe("agent base image provisioning", () => { ); }); + it.each([ + ["temporary", `rebuild-343338-${"b".repeat(16)}-image-${"a".repeat(64)}`], + ["canonical", `image-${"a".repeat(64)}`], + ])("does not return resolution metadata from a trusted %s rebuild lease", (_kind, tag) => { + withMockedDocker( + ({ + ensureAgentBaseImage, + pinTrustedAgentBaseImageOverrideForOperation, + resolveSandboxBaseImageMock, + }) => { + const overrideEnvVar = "NEMOCLAW_HERMES_SANDBOX_BASE_IMAGE_REF"; + const imageId = `sha256:${"a".repeat(64)}`; + const imageRef = `nemoclaw-hermes-sandbox-base-local:${tag}`; + const provenance = `${"c".repeat(64)}.${"d".repeat(64)}`; + const leasedMetadata = makeResolutionMetadata({ ref: imageRef, imageId }); + vi.stubEnv(overrideEnvVar, imageRef); + resolveSandboxBaseImageMock.mockReturnValue({ + ref: imageRef, + digest: null, + source: "local", + glibcVersion: leasedMetadata.glibcVersion, + metadata: leasedMetadata, + }); + const restore = pinTrustedAgentBaseImageOverrideForOperation(overrideEnvVar, { + ref: imageRef, + provenance, + }); + + try { + expect(ensureAgentBaseImage(makeAgent())).toEqual({ + imageTag: imageRef, + built: false, + }); + expect(resolveSandboxBaseImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + trustedLocalOverride: { ref: imageRef, provenance }, + }), + ); + } finally { + restore(); + } + }, + ); + }); + it("throws when a forced agent base image rebuild fails", () => { withMockedDocker(({ ensureAgentBaseImage, dockerBuildMock, resolveSandboxBaseImageMock }) => { dockerBuildMock.mockReturnValue({ status: 23 }); diff --git a/src/lib/agent/base-image.ts b/src/lib/agent/base-image.ts index e1e9ac60be7..8dc875fc714 100644 --- a/src/lib/agent/base-image.ts +++ b/src/lib/agent/base-image.ts @@ -19,14 +19,18 @@ import { SANDBOX_BUILD_CONTEXT_PREFIX } from "../sandbox/build-context"; import { buildLocalBaseTag, createSandboxBaseImageBuildProvenance, + createSandboxBaseImageBuildProvenanceKey, createSandboxBaseImageResolutionKey, createSandboxBaseImageResolutionMetadata, getImageGlibcVersion, inspectLocalImageMetadata, OPENSHELL_SANDBOX_MIN_GLIBC, + parseContentAddressedSandboxBaseImageId, + parseTemporarySandboxBaseImageId, type ResolveBaseImageOptions, resolveSandboxBaseImage, SANDBOX_BASE_BUILD_PROVENANCE_LABEL, + SANDBOX_BASE_RESOLUTION_SCHEMA, SANDBOX_BASE_TAG, type SandboxBaseImageResolution, SandboxBaseImageResolutionError, @@ -53,6 +57,7 @@ export interface EnsureAgentBaseImageResult { imageTag: string | null; built: boolean; resolutionMetadata?: SandboxBaseImageResolutionMetadata; + reusedResolutionHint?: SandboxBaseImageResolutionMetadata; trustedLocalOverride?: TrustedLocalBaseImageOverride; } @@ -342,6 +347,84 @@ export function bindLocalAgentBaseImageToPinnedProvenance( : null; } +/** + * Mint a one-operation trust lease for an exact rebuild handoff only from the + * outer resolver's already-validated local metadata. The public build label is + * supporting evidence, never authority by itself. + */ +export function bindLocalAgentBaseImageHandoffToResolution( + agent: AgentDefinition, + sourceRef: string, + handoffRef: string, + metadata: SandboxBaseImageResolutionMetadata, + reusedResolutionHint: SandboxBaseImageResolutionMetadata, +): TrustedLocalBaseImageOverride | null { + const baseDockerfile = agent.dockerfileBasePath; + const localImageName = `nemoclaw-${agent.name}-sandbox-base-local`; + const expectedImageId = parseContentAddressedSandboxBaseImageId(localImageName, handoffRef); + const temporaryHandoffImageId = parseTemporarySandboxBaseImageId(localImageName, handoffRef); + const normalizedMetadataImageId = metadata.imageId.trim().toLowerCase(); + if (!baseDockerfile || metadata !== reusedResolutionHint) return null; + const resolutionOptions = createAgentBaseImageResolutionOptions(agent, baseDockerfile, {}); + const canonicalSourceImageId = + parseTemporarySandboxBaseImageId(localImageName, sourceRef) === null + ? parseContentAddressedSandboxBaseImageId(localImageName, sourceRef) + : null; + const stableSourceHandoff = + sourceRef === resolutionOptions.localTag && + temporaryHandoffImageId === normalizedMetadataImageId; + const canonicalSourceHandoff = + canonicalSourceImageId === normalizedMetadataImageId && handoffRef === sourceRef; + if ( + metadata.schema !== SANDBOX_BASE_RESOLUTION_SCHEMA || + metadata.key !== createSandboxBaseImageResolutionKey(resolutionOptions) || + metadata.imageName !== resolutionOptions.imageName || + metadata.source !== "local" || + metadata.digest !== null || + metadata.ref !== sourceRef || + (!stableSourceHandoff && !canonicalSourceHandoff) || + !expectedImageId || + normalizedMetadataImageId !== expectedImageId + ) { + return null; + } + + const source = inspectLocalImageMetadata(sourceRef); + const handoff = handoffRef === sourceRef ? source : inspectLocalImageMetadata(handoffRef); + const sourceImageId = typeof source?.Id === "string" ? source.Id.trim().toLowerCase() : ""; + const handoffImageId = typeof handoff?.Id === "string" ? handoff.Id.trim().toLowerCase() : ""; + if ( + sourceImageId !== normalizedMetadataImageId || + source?.Os !== metadata.os || + source?.Architecture !== metadata.architecture || + handoffImageId !== normalizedMetadataImageId || + handoff?.Os !== metadata.os || + handoff?.Architecture !== metadata.architecture + ) { + return null; + } + + const expectedProvenanceKey = createSandboxBaseImageBuildProvenanceKey(resolutionOptions); + const sourceLabels = + source.Config?.Labels && typeof source.Config.Labels === "object" + ? (source.Config.Labels as Record) + : {}; + const handoffLabels = + handoff.Config?.Labels && typeof handoff.Config.Labels === "object" + ? (handoff.Config.Labels as Record) + : {}; + const provenance = sourceLabels[SANDBOX_BASE_BUILD_PROVENANCE_LABEL]; + if ( + typeof provenance !== "string" || + !new RegExp(`^${expectedProvenanceKey}\\.[0-9a-f]{64}$`).test(provenance) || + handoffLabels[SANDBOX_BASE_BUILD_PROVENANCE_LABEL] !== provenance + ) { + return null; + } + + return { ref: handoffRef, provenance }; +} + function createLocalResolutionMetadata( options: ResolveBaseImageOptions, imageTag: string, @@ -466,6 +549,9 @@ export function ensureAgentBaseImage( } const explicitOverride = process.env[overrideEnvVar]?.trim(); + const trustedLocalOverride = explicitOverride + ? trustedLocalOverrideLeases.get(overrideEnvVar) + : undefined; const trustedRemoteOverride = trustedRemoteOverrideLeases.get(overrideEnvVar); const canonicalEnv = { ...process.env }; delete canonicalEnv[overrideEnvVar]; @@ -476,7 +562,7 @@ export function ensureAgentBaseImage( env: canonicalEnv, resolutionHint: trustedRemoteOverride.resolutionMetadata, }) - : resolveExactImage(explicitOverride, trustedLocalOverrideLeases.get(overrideEnvVar)) + : resolveExactImage(explicitOverride, trustedLocalOverride) : resolveSandboxBaseImage(resolutionOptions); if (resolved) { if (!hermesFinalDockerfileAcceptsBase(agent, resolved)) { @@ -485,10 +571,24 @@ export function ensureAgentBaseImage( ); } console.log(` Using ${agent.displayName} base image: ${resolved.ref}`); + const operationScopedLocalLease = + explicitOverride && + trustedLocalOverride?.ref === explicitOverride && + resolved.ref === explicitOverride && + resolved.source === "local"; + const reusedResolutionHint = + options.forceBaseImageRefresh !== true && + options.resolutionHint && + resolved.metadata === options.resolutionHint + ? options.resolutionHint + : null; return { imageTag: resolved.ref, built: false, - ...(resolved.metadata ? { resolutionMetadata: resolved.metadata } : {}), + ...(!operationScopedLocalLease && resolved.metadata + ? { resolutionMetadata: resolved.metadata } + : {}), + ...(reusedResolutionHint ? { reusedResolutionHint } : {}), }; } if (process.platform === "linux" || resolutionOptions.validateImage) { diff --git a/src/lib/agent/onboard.ts b/src/lib/agent/onboard.ts index c8935e9d3cc..275823b6b4f 100644 --- a/src/lib/agent/onboard.ts +++ b/src/lib/agent/onboard.ts @@ -59,6 +59,22 @@ export function bindLocalAgentBaseImageToPinnedProvenance( return baseImage.bindLocalAgentBaseImageToPinnedProvenance(agent, imageRef); } +export function bindLocalAgentBaseImageHandoffToResolution( + agent: AgentDefinition, + sourceRef: string, + handoffRef: string, + metadata: import("../sandbox-base-image").SandboxBaseImageResolutionMetadata, + reusedResolutionHint: import("../sandbox-base-image").SandboxBaseImageResolutionMetadata, +): ReturnType { + return baseImage.bindLocalAgentBaseImageHandoffToResolution( + agent, + sourceRef, + handoffRef, + metadata, + reusedResolutionHint, + ); +} + export function pinTrustedAgentBaseImageOverrideForOperation( overrideEnvVar: string, override: import("../sandbox-base-image").TrustedLocalBaseImageOverride, diff --git a/src/lib/onboard/base-image-resolution-flow.test.ts b/src/lib/onboard/base-image-resolution-flow.test.ts index 11eb26346a1..71de670d77d 100644 --- a/src/lib/onboard/base-image-resolution-flow.test.ts +++ b/src/lib/onboard/base-image-resolution-flow.test.ts @@ -119,11 +119,20 @@ describe("base image resolution flow", () => { expect(context.preResolvedMetadata).toBe(resolvedMetadata); }); - it("retains a rebuild handoff when a bound local override has no new metadata (#7144)", () => { + it("retains canonical outer metadata when a bound local lease emits no metadata", () => { + const imageId = `sha256:${"a".repeat(64)}`; + const stableMetadata: SandboxBaseImageResolutionMetadata = { + ...recordedMetadata, + key: "stable-outer-key", + ref: `nemoclaw-hermes-sandbox-base-local:image-${"a".repeat(64)}`, + digest: null, + source: "local", + imageId, + }; const context = createBaseImageResolutionContext({ fresh: false, - initialHint: recordedMetadata, - initialPreResolvedMetadata: recordedMetadata, + initialHint: stableMetadata, + initialPreResolvedMetadata: stableMetadata, env: {}, }); const agent = { name: "hermes" } as AgentDefinition; @@ -139,9 +148,75 @@ describe("base image resolution flow", () => { vi.fn(() => staged), ); - expect(context.preResolvedMetadata).toBe(recordedMetadata); + expect(context.preResolvedMetadata).toBe(stableMetadata); expect(getBaseImageResolutionPatchOptions(context).preResolvedBaseImageMetadata).toBe( - recordedMetadata, + stableMetadata, + ); + expect(context.preResolvedMetadata?.key).toBe("stable-outer-key"); + }); + + it("retains stable outer metadata when inner staging uses a disposable local handoff", () => { + const imageId = `sha256:${"a".repeat(64)}`; + const stableMetadata: SandboxBaseImageResolutionMetadata = { + ...recordedMetadata, + ref: "nemoclaw-hermes-sandbox-base-local:3ef2ca87", + digest: null, + source: "local", + imageId, + }; + const disposableMetadata: SandboxBaseImageResolutionMetadata = { + ...stableMetadata, + ref: `nemoclaw-hermes-sandbox-base-local:rebuild-123-${"b".repeat(16)}-image-${"a".repeat(64)}`, + }; + const context = createBaseImageResolutionContext({ + fresh: false, + initialPreResolvedMetadata: stableMetadata, + env: {}, + }); + + createAgentSandboxWithResolution( + context, + { name: "hermes" } as AgentDefinition, + vi.fn(() => ({ + buildCtx: "/tmp/hermes-build", + stagedDockerfile: "/tmp/hermes-build/Dockerfile", + baseImageResolutionMetadata: disposableMetadata, + })), ); + + expect(context.preResolvedMetadata).toBe(stableMetadata); + expect(context.preResolvedMetadata?.ref).toBe(stableMetadata.ref); + }); + + it("rejects disposable metadata from a different outer resolution contract", () => { + const stableMetadata: SandboxBaseImageResolutionMetadata = { + ...recordedMetadata, + ref: "nemoclaw-hermes-sandbox-base-local:3ef2ca87", + digest: null, + source: "local", + imageId: `sha256:${"a".repeat(64)}`, + }; + const context = createBaseImageResolutionContext({ + fresh: false, + initialPreResolvedMetadata: stableMetadata, + env: {}, + }); + + expect(() => + createAgentSandboxWithResolution( + context, + { name: "hermes" } as AgentDefinition, + vi.fn(() => ({ + buildCtx: "/tmp/hermes-build", + stagedDockerfile: "/tmp/hermes-build/Dockerfile", + baseImageResolutionMetadata: { + ...stableMetadata, + key: "different-resolution-key", + ref: `nemoclaw-hermes-sandbox-base-local:rebuild-123-${"b".repeat(16)}-image-${"a".repeat(64)}`, + }, + })), + ), + ).toThrow("did not match the stable outer resolution"); + expect(context.preResolvedMetadata).toBe(stableMetadata); }); }); diff --git a/src/lib/onboard/base-image-resolution-flow.ts b/src/lib/onboard/base-image-resolution-flow.ts index ca97eed01ef..dda340e79be 100644 --- a/src/lib/onboard/base-image-resolution-flow.ts +++ b/src/lib/onboard/base-image-resolution-flow.ts @@ -3,6 +3,7 @@ import type { AgentDefinition } from "../agent/defs"; import { + parseTemporarySandboxBaseImageId, readSandboxBaseImageResolutionMetadata, type SandboxBaseImageResolutionMetadata, } from "../sandbox-base-image"; @@ -56,6 +57,47 @@ export function captureBaseResolution( } } +function isDisposableLocalRebuildMetadata( + agentName: string, + metadata: SandboxBaseImageResolutionMetadata, +): boolean { + if (metadata.source !== "local" || metadata.digest !== null) return false; + const imageId = metadata.imageId.match(/^sha256:([0-9a-f]{64})$/i)?.[0]?.toLowerCase(); + return ( + imageId !== undefined && + parseTemporarySandboxBaseImageId(`nemoclaw-${agentName}-sandbox-base-local`, metadata.ref) === + imageId + ); +} + +function isStableMetadataForDisposableHandoff( + agentName: string, + stable: SandboxBaseImageResolutionMetadata | null, + staged: SandboxBaseImageResolutionMetadata | null, +): boolean { + if ( + !stable || + !staged || + stable.source !== "local" || + stable.digest !== null || + isDisposableLocalRebuildMetadata(agentName, stable) || + !isDisposableLocalRebuildMetadata(agentName, staged) + ) { + return false; + } + return ( + stable.schema === staged.schema && + stable.key === staged.key && + stable.imageName === staged.imageName && + stable.imageId === staged.imageId && + stable.os === staged.os && + stable.architecture === staged.architecture && + stable.glibcVersion === staged.glibcVersion && + stable.requireOpenshellSandboxAbi === staged.requireOpenshellSandboxAbi && + stable.minGlibcVersion === staged.minGlibcVersion + ); +} + export function createAgentSandboxWithResolution( context: BaseImageResolutionContext, agent: AgentDefinition, @@ -65,7 +107,23 @@ export function createAgentSandboxWithResolution( resolutionHint: context.resolutionHint, forceBaseImageRefresh: context.forceRefresh, }); - context.preResolvedMetadata = staged.baseImageResolutionMetadata ?? context.preResolvedMetadata; + if (staged.baseImageResolutionMetadata) { + if (isDisposableLocalRebuildMetadata(agent.name, staged.baseImageResolutionMetadata)) { + if ( + !isStableMetadataForDisposableHandoff( + agent.name, + context.preResolvedMetadata, + staged.baseImageResolutionMetadata, + ) + ) { + throw new Error( + "Temporary rebuild base-image metadata did not match the stable outer resolution", + ); + } + } else { + context.preResolvedMetadata = staged.baseImageResolutionMetadata; + } + } return staged; } diff --git a/src/lib/sandbox-base-image-resolution.test.ts b/src/lib/sandbox-base-image-resolution.test.ts index 25c7bfc6a07..e05ef8aa1f7 100644 --- a/src/lib/sandbox-base-image-resolution.test.ts +++ b/src/lib/sandbox-base-image-resolution.test.ts @@ -43,12 +43,12 @@ vi.mock("./sandbox-base-image/source-identity", async (importOriginal) => ({ })); import { - createSandboxBaseImageResolutionKey, createSandboxBaseImageBuildProvenanceKey, + createSandboxBaseImageResolutionKey, OPENSHELL_SANDBOX_MIN_GLIBC, resolveSandboxBaseImage, - SandboxBaseImageResolutionError, SANDBOX_BASE_BUILD_PROVENANCE_LABEL, + SandboxBaseImageResolutionError, type SandboxBaseImageResolutionMetadata, } from "./sandbox-base-image"; @@ -326,6 +326,83 @@ describe("sandbox base-image warm resolution", () => { expect(dockerMocks.pull).not.toHaveBeenCalled(); }); + it("accepts a temporary rebuild handoff override backed by the current build proof", () => { + const options = resolutionOptions(); + const imageId = `sha256:${"c".repeat(64)}`; + const handoffRef = `nemoclaw-sandbox-base-local:rebuild-343338-${"a".repeat(16)}-image-${"c".repeat(64)}`; + const provenance = `${createSandboxBaseImageBuildProvenanceKey(options)}.${"d".repeat(64)}`; + dockerMocks.imageInspectFormat.mockImplementation((format: string) => + format === "{{.Id}}" + ? imageId + : JSON.stringify({ + Id: imageId, + RepoDigests: [], + Os: "linux", + Architecture: "amd64", + Config: { + Labels: { + [SANDBOX_BASE_BUILD_PROVENANCE_LABEL]: provenance, + }, + }, + }), + ); + + try { + const resolved = resolveSandboxBaseImage({ + ...options, + envVar: "NEMOCLAW_SANDBOX_BASE_IMAGE_REF", + env: { + ...options.env, + NEMOCLAW_SANDBOX_BASE_IMAGE_REF: handoffRef, + }, + trustedLocalOverride: { ref: handoffRef, provenance }, + }); + + expect(resolved).toMatchObject({ + ref: handoffRef, + digest: null, + source: "local", + metadata: { imageId, source: "local" }, + }); + expect(dockerMocks.pull).not.toHaveBeenCalled(); + } finally { + dockerMocks.imageInspectFormat.mockReset(); + } + }); + + it.each([ + ["zero process ID", `rebuild-0-${"a".repeat(16)}-image-${"c".repeat(64)}`], + ["leading-zero process ID", `rebuild-0343338-${"a".repeat(16)}-image-${"c".repeat(64)}`], + ["short nonce", `rebuild-343338-${"a".repeat(15)}-image-${"c".repeat(64)}`], + ["non-hex nonce", `rebuild-343338-${"z".repeat(16)}-image-${"c".repeat(64)}`], + ["extra suffix", `rebuild-343338-${"a".repeat(16)}-image-${"c".repeat(64)}-moved`], + ])("rejects a temporary handoff with a malformed %s", (_case, tag) => { + const options = resolutionOptions(); + const handoffRef = `nemoclaw-sandbox-base-local:${tag}`; + const provenance = `${createSandboxBaseImageBuildProvenanceKey(options)}.${"d".repeat(64)}`; + dockerMocks.imageInspectFormat.mockReturnValue( + JSON.stringify({ + Id: `sha256:${"c".repeat(64)}`, + RepoDigests: [], + Os: "linux", + Architecture: "amd64", + Config: { Labels: { [SANDBOX_BASE_BUILD_PROVENANCE_LABEL]: provenance } }, + }), + ); + + expect(() => + resolveSandboxBaseImage({ + ...options, + envVar: "NEMOCLAW_SANDBOX_BASE_IMAGE_REF", + env: { + ...options.env, + NEMOCLAW_SANDBOX_BASE_IMAGE_REF: handoffRef, + }, + trustedLocalOverride: { ref: handoffRef, provenance }, + }), + ).toThrow("outside the trusted repository"); + }); + it("rejects a copied provenance label without the current build proof (#5896)", () => { const options = resolutionOptions(); const imageId = `sha256:${"c".repeat(64)}`; diff --git a/src/lib/sandbox-base-image.ts b/src/lib/sandbox-base-image.ts index de933c4cee7..3348f6e664c 100644 --- a/src/lib/sandbox-base-image.ts +++ b/src/lib/sandbox-base-image.ts @@ -155,12 +155,32 @@ function isExpectedRemoteBaseImageRef(imageName: string, imageRef: string): bool ); } -function contentAddressedLocalImageId(localTag: string, imageRef: string): string | null { - const localImageName = localTag.replace(/:[^/:]+$/, ""); +export function parseTemporarySandboxBaseImageId( + localImageName: string, + imageRef: string, +): string | null { + const temporaryPrefix = `${localImageName}:rebuild-`; + if (!imageRef.startsWith(temporaryPrefix)) return null; + const match = imageRef + .slice(temporaryPrefix.length) + .match(/^[1-9][0-9]*-[0-9a-f]{16}-image-([0-9a-f]{64})$/); + return match ? `sha256:${match[1]}` : null; +} + +export function parseContentAddressedSandboxBaseImageId( + localImageName: string, + imageRef: string, +): string | null { const prefix = `${localImageName}:image-`; - if (!imageRef.startsWith(prefix)) return null; - const digest = imageRef.slice(prefix.length); - return /^[0-9a-f]{64}$/.test(digest) ? `sha256:${digest}` : null; + if (imageRef.startsWith(prefix)) { + const digest = imageRef.slice(prefix.length); + return /^[0-9a-f]{64}$/.test(digest) ? `sha256:${digest}` : null; + } + return parseTemporarySandboxBaseImageId(localImageName, imageRef); +} + +function contentAddressedLocalImageId(localTag: string, imageRef: string): string | null { + return parseContentAddressedSandboxBaseImageId(localTag.replace(/:[^/:]+$/, ""), imageRef); } function resolveContentAddressedLocalOverride( diff --git a/test/helpers/base-image-test-harness.ts b/test/helpers/base-image-test-harness.ts index 24d254f379b..f95102a3747 100644 --- a/test/helpers/base-image-test-harness.ts +++ b/test/helpers/base-image-test-harness.ts @@ -72,6 +72,7 @@ export function withMockedDocker( run: (deps: { ensureAgentBaseImage: AgentOnboardModule["ensureAgentBaseImage"]; bindLocalAgentBaseImageToPinnedProvenance: AgentOnboardModule["bindLocalAgentBaseImageToPinnedProvenance"]; + pinTrustedAgentBaseImageOverrideForOperation: AgentOnboardModule["pinTrustedAgentBaseImageOverrideForOperation"]; pinAgentSandboxBaseImageRef: AgentOnboardModule["pinAgentSandboxBaseImageRef"]; dockerBuildMock: ReturnType; dockerCaptureMock: ReturnType; @@ -148,6 +149,8 @@ export function withMockedDocker( ensureAgentBaseImage: agentOnboardModule.ensureAgentBaseImage, bindLocalAgentBaseImageToPinnedProvenance: agentOnboardModule.bindLocalAgentBaseImageToPinnedProvenance, + pinTrustedAgentBaseImageOverrideForOperation: + agentOnboardModule.pinTrustedAgentBaseImageOverrideForOperation, pinAgentSandboxBaseImageRef: agentOnboardModule.pinAgentSandboxBaseImageRef, dockerBuildMock, dockerCaptureMock, diff --git a/test/helpers/rebuild-flow-harness.ts b/test/helpers/rebuild-flow-harness.ts index 5d152a8b0da..9507b22bc3e 100644 --- a/test/helpers/rebuild-flow-harness.ts +++ b/test/helpers/rebuild-flow-harness.ts @@ -278,9 +278,17 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): const session = createRebuildFlowSession(onboardSession.MACHINE_SNAPSHOT_VERSION); const rebuildShieldsWindow = { relocked: false, wasLocked: false }; const agentName = overrides.agentName ?? "openclaw"; - const agentBaseImageRef = `nemoclaw-${agentName}-base:test`; + const agentDisplayName = + agentName === "langchain-deepagents-code" + ? "Deep Agents Code" + : agentName === "hermes" + ? "Hermes Agent" + : "OpenClaw"; + const agentBaseImageId = `sha256:${"a".repeat(64)}`; + const agentBaseImageRef = `nemoclaw-${agentName}-sandbox-base-local:image-${agentBaseImageId.slice("sha256:".length)}`; const agentDef = { name: agentName, + displayName: agentDisplayName, expectedVersion: "0.2.0", dockerfileBasePath: "/tmp/Dockerfile.base", runtime: { kind: "terminal" }, @@ -297,7 +305,6 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): ok: true, imageTag: null, }); - const agentBaseImageId = `sha256:${"a".repeat(64)}`; const imageIdsByRef = new Map([ [agentBaseImageRef, agentBaseImageId], [agentBaseImageId, agentBaseImageId], @@ -336,7 +343,7 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): const ensureAgentBaseImageSpy = vi.spyOn(agentOnboard, "ensureAgentBaseImage").mockReturnValue({ imageTag: agentBaseImageRef, built: true, - ...(agentName === "langchain-deepagents-code" ? { trustedLocalOverride } : {}), + trustedLocalOverride, }); const restoreTrustedAgentBaseImageOverrideSpy = vi.fn(); const pinTrustedAgentBaseImageOverrideForOperationSpy = vi @@ -349,13 +356,7 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): ? null : ({ name: sessionAgentName } as never), ); - vi.spyOn(agentRuntime, "getAgentDisplayName").mockReturnValue( - agentName === "langchain-deepagents-code" - ? "Deep Agents Code" - : agentName === "hermes" - ? "Hermes Agent" - : "OpenClaw", - ); + vi.spyOn(agentRuntime, "getAgentDisplayName").mockReturnValue(agentDisplayName); vi.spyOn(gatewayRuntime, "recoverNamedGatewayRuntime").mockImplementation( async (...args: unknown[]) => { const gatewayName =