Skip to content
8 changes: 5 additions & 3 deletions src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createRebuildFlowHarness,
makePreparedRecoveryManifest,
resetRebuildFlowTestEnvironment,
restoreRebuildFlowTestEnvironment,
snapshotEnv,
} from "../../../../test/helpers/rebuild-flow-harness";

const restoreSandboxEnv = snapshotEnv(["NEMOCLAW_SANDBOX_NAME"]);

describe("prepared rebuild recovery", () => {
beforeEach(() => {
delete process.env.NEMOCLAW_SANDBOX_NAME;
resetRebuildFlowTestEnvironment();
});

afterEach(() => {
vi.restoreAllMocks();
restoreRebuildFlowTestEnvironment();
restoreSandboxEnv();
});

Expand Down
139 changes: 139 additions & 0 deletions src/lib/agent/base-image-hermes-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";

import { afterEach, 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(),
pull: vi.fn(),
rmi: vi.fn(),
tag: vi.fn(),
}));
const sourceMocks = vi.hoisted(() => ({
inputsChanged: vi.fn(),
inputsDirty: vi.fn(),
}));

vi.mock("../adapters/docker", () => ({
dockerBuild: dockerMocks.build,
dockerCapture: dockerMocks.capture,
dockerImageInspect: dockerMocks.imageInspect,
dockerImageInspectFormat: dockerMocks.imageInspectFormat,
dockerInfoFormat: dockerMocks.infoFormat,
dockerPull: dockerMocks.pull,
dockerRmi: dockerMocks.rmi,
dockerTag: dockerMocks.tag,
}));

vi.mock("../sandbox-base-image/source-identity", async (importOriginal) => ({
...(await importOriginal<typeof import("../sandbox-base-image/source-identity")>()),
baseImageInputsChangedSinceMain: sourceMocks.inputsChanged,
baseImageInputsDirty: sourceMocks.inputsDirty,
}));

import { createAgentSandbox } from "./base-image";

const platformDigest = "sha256:c0c149ed03b3e8fcd3e395558b22e871cd27c9966ea6faf04c0d2b94d0a821b9";
const platformRef = `ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@${platformDigest}`;
const imageId = `sha256:${"b".repeat(64)}`;
const createdBuildContexts: string[] = [];
let trackedRef = "";

describe("Hermes base-image resolver integration", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("NEMOCLAW_HERMES_SANDBOX_BASE_IMAGE_REF", "");
sourceMocks.inputsChanged.mockReturnValue(false);
sourceMocks.inputsDirty.mockReturnValue(false);
dockerMocks.infoFormat.mockReturnValue("linux/aarch64\n");
dockerMocks.pull.mockReturnValue({ status: 1 });

const dockerfile = fs.readFileSync(makeAgent().dockerfilePath ?? "", "utf8");
trackedRef =
dockerfile.match(
/^ARG BASE_IMAGE=(ghcr\.io\/nvidia\/nemoclaw\/hermes-sandbox-base@sha256:[0-9a-f]{64})$/m,
)?.[1] ?? "";
expect(trackedRef).toMatch(
/^ghcr\.io\/nvidia\/nemoclaw\/hermes-sandbox-base@sha256:[0-9a-f]{64}$/,
);

const inspectStatusByRef = new Map([
[trackedRef, 0],
[platformRef, 0],
]);
const inspectOutputByKey = new Map([
[`{{json .RepoDigests}}\0${trackedRef}`, JSON.stringify([platformRef])],
[
`{{json .}}\0${platformRef}`,
JSON.stringify({
Architecture: "arm64",
Id: imageId,
Os: "linux",
RepoDigests: [platformRef],
}),
],
]);
const captureByEntrypoint = new Map([
["/opt/hermes/.venv/bin/python", "nemoclaw-hermes-mcp-runtime-ok"],
["/usr/bin/ldd", "ldd (GNU libc) 2.41"],
]);

dockerMocks.imageInspect.mockImplementation((ref: string) => ({
status: inspectStatusByRef.get(ref) ?? 1,
}));
dockerMocks.imageInspectFormat.mockImplementation((format: string, ref: string) =>
(inspectOutputByKey.get(`${format}\0${ref}`) ?? "").trim(),
);
dockerMocks.capture.mockImplementation(
(args: string[]) => captureByEntrypoint.get(args[3]) ?? "",
);
});

afterEach(() => {
vi.unstubAllEnvs();
for (const buildCtx of createdBuildContexts.splice(0)) {
fs.rmSync(buildCtx, { force: true, recursive: true });
}
});

it("stages Hermes on aarch64 with a Dockerfile-pinned platform digest produced by the resolver path (#6313)", () => {
const result = createAgentSandbox(makeAgent());
createdBuildContexts.push(result.buildCtx);

expect(fs.readFileSync(result.stagedDockerfile, "utf8")).toContain(
`ARG BASE_IMAGE=${platformRef}`,
);
expect(result.baseImageResolutionMetadata).toMatchObject({
architecture: "arm64",
digest: platformDigest,
pinnedRemoteRef: trackedRef,
ref: platformRef,
source: "pinned",
});
expect(dockerMocks.imageInspect).toHaveBeenCalledWith(trackedRef, {
ignoreError: true,
suppressOutput: true,
});
expect(dockerMocks.imageInspectFormat).toHaveBeenCalledWith(
"{{json .RepoDigests}}",
trackedRef,
{ ignoreError: true },
);
}, 15_000);

it("rejects an explicit platform digest override without pinned provenance", () => {
vi.stubEnv("NEMOCLAW_HERMES_SANDBOX_BASE_IMAGE_REF", platformRef);

expect(() => createAgentSandbox(makeAgent())).toThrow(
`Hermes final image does not accept base image ref '${platformRef}'`,
);
});
});
48 changes: 48 additions & 0 deletions src/lib/agent/base-image-hermes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,54 @@ describe("agent base image provisioning", () => {
}),
);

const platformDigest =
"sha256:c0c149ed03b3e8fcd3e395558b22e871cd27c9966ea6faf04c0d2b94d0a821b9";
const platformDigestRef = `ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@${platformDigest}`;
resolveSandboxBaseImageMock.mockReturnValue({
ref: platformDigestRef,
digest: platformDigest,
source: "pinned",
pinnedRemoteRef: trackedRef?.[1],
glibcVersion: "2.41",
});
expect(ensureAgentBaseImage(makeAgent({ dockerfilePath }))).toEqual({
imageTag: platformDigestRef,
built: false,
});

const wrongNamespaceRef = `ghcr.io/nvidia/nemoclaw/other-hermes-base@${platformDigest}`;
resolveSandboxBaseImageMock.mockReturnValue({
ref: wrongNamespaceRef,
digest: platformDigest,
source: "pinned",
pinnedRemoteRef: trackedRef?.[1],
glibcVersion: "2.41",
});
expect(() => ensureAgentBaseImage(makeAgent({ dockerfilePath }))).toThrow(
"Hermes final image does not accept base image ref",
);

resolveSandboxBaseImageMock.mockReturnValue({
ref: platformDigestRef,
digest: platformDigest,
source: "latest",
glibcVersion: "2.41",
});
expect(() => ensureAgentBaseImage(makeAgent({ dockerfilePath }))).toThrow(
"Hermes final image does not accept base image ref",
);

resolveSandboxBaseImageMock.mockReturnValue({
ref: platformDigestRef,
digest: platformDigest,
source: "pinned",
pinnedRemoteRef: `ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:${"2".repeat(64)}`,
glibcVersion: "2.41",
});
expect(() => ensureAgentBaseImage(makeAgent({ dockerfilePath }))).toThrow(
"Hermes final image does not accept base image ref",
);

const differentRef = `ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:${"0".repeat(64)}`;
resolveSandboxBaseImageMock.mockReturnValue({
ref: differentRef,
Expand Down
31 changes: 25 additions & 6 deletions src/lib/agent/base-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,16 @@ import {
type ResolveBaseImageOptions,
resolveSandboxBaseImage,
SANDBOX_BASE_TAG,
type SandboxBaseImageResolution,
type SandboxBaseImageResolutionMetadata,
} from "../sandbox-base-image";
import type { AgentDefinition } from "./defs";

const HERMES_MCP_RUNTIME_PROBE_OK = "nemoclaw-hermes-mcp-runtime-ok";
// Matches the official Hermes base repository for both Dockerfile manifest-list
// pins and Docker-normalized platform manifest digests.
const HERMES_OFFICIAL_BASE_DIGEST_REF =
/^ghcr\.io\/nvidia\/nemoclaw\/hermes-sandbox-base@sha256:[0-9a-f]{64}$/;

export interface EnsureAgentBaseImageOptions {
forceBaseImageRebuild?: boolean;
Expand Down Expand Up @@ -93,19 +98,25 @@ function getHermesPinnedRemoteBaseRef(agent: AgentDefinition): string | null {
(match) => match[1],
);
const pinnedRef = declarations.length === 1 ? declarations[0] : null;
if (
!pinnedRef ||
!/^ghcr\.io\/nvidia\/nemoclaw\/hermes-sandbox-base@sha256:[0-9a-f]{64}$/.test(pinnedRef)
) {
if (!pinnedRef || !HERMES_OFFICIAL_BASE_DIGEST_REF.test(pinnedRef)) {
throw new Error(
"Hermes final Dockerfile must declare exactly one immutable official sandbox base image",
);
}
return pinnedRef;
}

function hermesFinalDockerfileAcceptsBase(agent: AgentDefinition, imageRef: string): boolean {
/**
* Accept only trusted resolver output here. Pinned platform digests are valid
* only when the resolver records the current Dockerfile-pinned ref as their
* provenance; string callers and explicit overrides stay exact-match only.
*/
function hermesFinalDockerfileAcceptsBase(
agent: AgentDefinition,
image: string | SandboxBaseImageResolution,
): boolean {
if (agent.name !== "hermes") return true;
const imageRef = typeof image === "string" ? image : image.ref;
if (
imageRef === "nemoclaw-hermes-base-local" ||
/^nemoclaw-hermes-(?:root-entrypoint-base|sandbox-base-local|secret-boundary-base|stale-openclaw-dir-base|stale-openclaw-link-base):[^\s]+$/.test(
Expand All @@ -114,6 +125,14 @@ function hermesFinalDockerfileAcceptsBase(agent: AgentDefinition, imageRef: stri
) {
return true;
}
if (
typeof image !== "string" &&
image.source === "pinned" &&
image.pinnedRemoteRef === getHermesPinnedRemoteBaseRef(agent) &&
HERMES_OFFICIAL_BASE_DIGEST_REF.test(imageRef)
) {
return true;
}
return imageRef === getHermesPinnedRemoteBaseRef(agent);
}

Expand Down Expand Up @@ -263,7 +282,7 @@ export function ensureAgentBaseImage(
? resolveExactImage(explicitOverride)
: resolveSandboxBaseImage(resolutionOptions);
if (resolved) {
if (!hermesFinalDockerfileAcceptsBase(agent, resolved.ref)) {
if (!hermesFinalDockerfileAcceptsBase(agent, resolved)) {
throw new Error(
`Hermes final image does not accept base image ref '${resolved.ref}'; use the tracked official digest or a repository-built local base`,
);
Expand Down
Loading