Skip to content
Open
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
25 changes: 24 additions & 1 deletion src/lib/agent/base-image-hermes-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,24 @@ describe("Hermes base-image resolver integration", () => {
fs.rmSync(testRoot, { force: true, recursive: true });
});

it("stages Hermes on aarch64 with a Dockerfile-pinned platform digest produced by the resolver path (#6313)", () => {
it("stages Hermes on aarch64 when the resolved Dockerfile-pinned platform digest has the pinned inventory (#6313)", () => {
const captureByEntrypointAndPinnedInventory = new Map([
["/opt/hermes/.venv/bin/python\0false", "nemoclaw-hermes-mcp-runtime-ok"],
["/usr/bin/ldd\0false", "ldd (GNU libc) 2.41"],
["/bin/sh\0true", "nemoclaw-security-inventory-ok"],
]);
dockerMocks.capture.mockImplementation((args: string[]) => {
const entrypoint = args[args.indexOf("--entrypoint") + 1];
const requestsPinnedInventory = args.some((arg) =>
arg.includes("nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1"),
);
return (
captureByEntrypointAndPinnedInventory.get(
`${entrypoint}\0${String(requestsPinnedInventory)}`,
) ?? ""
);
});

const result = stageHermesSandbox();

expect(fs.readFileSync(result.stagedDockerfile, "utf8")).toContain(
Expand All @@ -149,6 +166,12 @@ describe("Hermes base-image resolver integration", () => {
trackedRef,
{ ignoreError: true },
);
expect(dockerMocks.capture).toHaveBeenCalledWith(
expect.arrayContaining([
expect.stringContaining("nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1"),
]),
expect.objectContaining({ ignoreError: true }),
);
}, 15_000);

it("stops before a release fallback when the tracked Hermes base fails qualification (#10826)", () => {
Expand Down
52 changes: 50 additions & 2 deletions src/lib/agent/base-image-hermes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,63 @@ describe("agent base image provisioning", () => {
const options = resolveSandboxBaseImageMock.mock.calls[0]?.[0] as {
validateImage?: (imageRef: string) => boolean;
};
dockerCaptureMock.mockReturnValueOnce("nemoclaw-hermes-mcp-runtime-ok").mockReturnValue("");

expect(options.validateImage?.("hermes-base:stale-inventory")).toBe(false);
expect(dockerCaptureMock).toHaveBeenCalledTimes(2);
});
});

it("rejects the older security inventory without pinned resolver provenance (#10947)", () => {
withMockedDocker(({ ensureAgentBaseImage, dockerCaptureMock, resolveSandboxBaseImageMock }) => {
ensureAgentBaseImage(makeAgent());
const options = resolveSandboxBaseImageMock.mock.calls[0]?.[0] as {
validateImage?: (imageRef: string, context?: { source: string }) => boolean;
};
dockerCaptureMock
.mockReturnValueOnce("nemoclaw-hermes-mcp-runtime-ok")
.mockReturnValueOnce("");
.mockReturnValueOnce("")
.mockReturnValueOnce("nemoclaw-security-inventory-ok");

expect(options.validateImage?.("hermes-base:stale-inventory")).toBe(false);
expect(options.validateImage?.("hermes-base:cached-old", { source: "local" })).toBe(false);
expect(dockerCaptureMock).toHaveBeenCalledTimes(2);
});
});

it("accepts the reviewed security inventory in the Dockerfile-pinned Hermes base", () => {
withMockedDocker(({ ensureAgentBaseImage, dockerCaptureMock, resolveSandboxBaseImageMock }) => {
ensureAgentBaseImage(makeAgent());
const options = resolveSandboxBaseImageMock.mock.calls[0]?.[0] as {
pinnedRemoteRef?: string;
validateImage?: (
imageRef: string,
context?: { source: string; pinnedRemoteRef?: string },
) => boolean;
};
dockerCaptureMock
.mockReturnValueOnce("nemoclaw-hermes-mcp-runtime-ok")
.mockReturnValueOnce("")
.mockReturnValueOnce("nemoclaw-security-inventory-ok");

expect(options.pinnedRemoteRef).toMatch(
/^ghcr\.io\/nvidia\/nemoclaw\/hermes-sandbox-base@sha256:[0-9a-f]{64}$/,
);
expect(
options.validateImage?.(options.pinnedRemoteRef!, {
source: "pinned",
pinnedRemoteRef: options.pinnedRemoteRef,
}),
).toBe(true);
const inventoryProbe = dockerCaptureMock.mock.calls[2]?.[0] as string[];
expect(inventoryProbe).toEqual(
expect.arrayContaining([
options.pinnedRemoteRef,
expect.stringContaining("nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1"),
]),
);
});
});

it("rejects a Hermes base that has MCP but lacks ACP", () => {
withMockedDocker(({ ensureAgentBaseImage, dockerCaptureMock, resolveSandboxBaseImageMock }) => {
ensureAgentBaseImage(makeAgent());
Expand Down
23 changes: 18 additions & 5 deletions src/lib/agent/base-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,16 @@ import {
SANDBOX_BASE_RESOLUTION_SCHEMA,
SANDBOX_BASE_TAG,
type SandboxBaseImageResolution,
type SandboxBaseImageValidationContext,
SandboxBaseImageResolutionError,
type SandboxBaseImageResolutionMetadata,
type TrustedLocalBaseImageOverride,
versionGte,
} from "../sandbox-base-image";
import { sandboxBaseImageHasSecurityInventory } from "../sandbox-base-image/security-inventory";
import {
hermesSandboxBaseImageHasSecurityInventory,
sandboxBaseImageHasSecurityInventory,
} from "../sandbox-base-image/security-inventory";
import { getAgentSandboxBaseImageEnvVar } from "./base-image-env";
import { createDeepAgentsCodeBaseImageResolutionOptions } from "./deep-agents-code-base-image";
import type { AgentDefinition } from "./defs";
Expand Down Expand Up @@ -328,11 +332,16 @@ function createAgentBaseImageResolutionOptions(
options: EnsureAgentBaseImageOptions,
): ResolveBaseImageOptions {
const imageName = `ghcr.io/nvidia/nemoclaw/${agent.name}-sandbox-base`;
const pinnedRemoteRef = getHermesPinnedRemoteBaseRef(agent) ?? undefined;
const validationOptions =
agent.name === "hermes"
? {
validateImage: (imageRef: string) =>
hermesBaseImageSupportsMcp(imageRef) && sandboxBaseImageHasSecurityInventory(imageRef),
validateImage: (imageRef: string, context?: SandboxBaseImageValidationContext) =>
hermesBaseImageSupportsMcp(imageRef) &&
hermesSandboxBaseImageHasSecurityInventory(
imageRef,
context?.source === "pinned" && context.pinnedRemoteRef === pinnedRemoteRef,
),
validationDescription:
"the required MCP Streamable HTTP and ACP runtimes and the immutable security package inventory",
}
Expand All @@ -342,7 +351,6 @@ function createAgentBaseImageResolutionOptions(
validationDescription: "the immutable security package inventory",
}
: createDeepAgentsCodeBaseImageResolutionOptions(agent, dockerfilePath);
const pinnedRemoteRef = getHermesPinnedRemoteBaseRef(agent) ?? undefined;
return {
imageName,
dockerfilePath,
Expand Down Expand Up @@ -417,7 +425,12 @@ export function bindLocalAgentBaseImageToPinnedProvenance(
) {
return null;
}
if (resolutionOptions.validateImage && !resolutionOptions.validateImage(imageRef)) return null;
if (
resolutionOptions.validateImage &&
!resolutionOptions.validateImage(imageRef, { source: "pinned", pinnedRemoteRef })
) {
return null;
}
const digest = resolvedRemoteRef.slice(resolvedRemoteRef.indexOf("@") + 1);
const metadata = createSandboxBaseImageResolutionMetadata(
resolutionOptions,
Expand Down
2 changes: 1 addition & 1 deletion src/lib/sandbox-base-image-agent-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ describe("agent-specific sandbox base-image resolution", () => {
ignoreError: true,
suppressOutput: true,
});
expect(validateImage).toHaveBeenCalledWith(staleRef);
expect(validateImage).toHaveBeenCalledWith(staleRef, { source: "override" });
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("lacks a required runtime capability"),
);
Expand Down
6 changes: 6 additions & 0 deletions src/lib/sandbox-base-image-platform-digest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ describe("sandbox base-image pinned platform digest resolution", () => {
});

it("returns a Dockerfile-pinned platform digest from the resolver path", () => {
const validateImage = vi.fn(() => true);
dockerMocks.imageInspect.mockImplementation((ref: string) => ({
status: ref === REF || ref === PLATFORM_REF ? 0 : 1,
}));
Expand All @@ -123,6 +124,7 @@ describe("sandbox base-image pinned platform digest resolution", () => {
...resolutionOptions(),
pinnedRemoteRef: REF,
requirePinnedRemoteRef: true,
validateImage,
});

expect(resolved).toEqual({
Expand All @@ -145,6 +147,10 @@ describe("sandbox base-image pinned platform digest resolution", () => {
expect(dockerMocks.imageInspectFormat).toHaveBeenCalledWith("{{json .RepoDigests}}", REF, {
ignoreError: true,
});
expect(validateImage).toHaveBeenCalledWith(REF, {
source: "pinned",
pinnedRemoteRef: REF,
});
expect(dockerMocks.build).not.toHaveBeenCalled();
});

Expand Down
8 changes: 6 additions & 2 deletions src/lib/sandbox-base-image-release-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,12 @@ describe("sandbox base-image release resolution", () => {
suppressOutput: true,
});
expect(dockerMocks.pull).toHaveBeenCalledTimes(1);
expect(state.validateImage).toHaveBeenNthCalledWith(1, RELEASE_REF);
expect(state.validateImage).toHaveBeenNthCalledWith(2, RELEASE_REF);
expect(state.validateImage).toHaveBeenNthCalledWith(1, RELEASE_REF, {
source: "version-tag",
});
expect(state.validateImage).toHaveBeenNthCalledWith(2, RELEASE_REF, {
source: "version-tag",
});
expect(dockerMocks.build).toHaveBeenCalledTimes(1);
});

Expand Down
19 changes: 15 additions & 4 deletions src/lib/sandbox-base-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ function resolveContentAddressedLocalOverride(
);
}
}
if (options.validateImage && !options.validateImage(imageRef)) {
if (options.validateImage && !options.validateImage(imageRef, { source: "local" })) {
throw new SandboxBaseImageResolutionError(
`${options.label || "Sandbox base image"} local override '${imageRef}' lacks ` +
`${options.validationDescription || "a required runtime capability"}.`,
Expand Down Expand Up @@ -313,7 +313,15 @@ function validatePulledCandidate(
}
}

if (options.validateImage && !options.validateImage(imageRef)) {
if (
options.validateImage &&
!options.validateImage(imageRef, {
source,
...(candidateOptions.pinnedRemoteRef
? { pinnedRemoteRef: candidateOptions.pinnedRemoteRef }
: {}),
})
) {
if (warn) {
console.warn(" Warning: sandbox base image lacks a required runtime capability.");
}
Expand Down Expand Up @@ -407,7 +415,10 @@ function resolveLocalCandidate(
const check = options.requireOpenshellSandboxAbi
? imageMeetsMinimumGlibc(imageRef, options.minGlibcVersion || OPENSHELL_SANDBOX_MIN_GLIBC)
: { ok: true, version: null };
if (check.ok && (!options.validateImage || options.validateImage(imageRef))) {
if (
check.ok &&
(!options.validateImage || options.validateImage(imageRef, { source: "local" }))
) {
addTraceEvent("nemoclaw.sandbox_base_image.local_fallback_reuse");
return { ref: imageRef, digest: null, source: "local", glibcVersion: check.version };
}
Expand Down Expand Up @@ -456,7 +467,7 @@ function resolveLocalCandidate(
return null;
}

if (options.validateImage && !options.validateImage(imageRef)) {
if (options.validateImage && !options.validateImage(imageRef, { source: "local" })) {
console.error(" Local sandbox base image lacks a required runtime capability.");
return null;
}
Expand Down
5 changes: 4 additions & 1 deletion src/lib/sandbox-base-image/resolution-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,10 @@ describe("sandbox base-image resolution metadata lifecycle", () => {
KEY,
),
).toBeNull();
expect(validateImage).toHaveBeenCalledWith(REF);
expect(validateImage).toHaveBeenCalledWith(REF, {
source: metadata.source,
...(metadata.pinnedRemoteRef ? { pinnedRemoteRef: metadata.pinnedRemoteRef } : {}),
});
expect(mocks.addTraceEvent).toHaveBeenCalledWith("nemoclaw.sandbox_base_image.cache_stale", {
reason: "custom_validation_failed",
});
Expand Down
8 changes: 7 additions & 1 deletion src/lib/sandbox-base-image/resolution-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,13 @@ export function reuseSandboxBaseImageResolutionHint(
addTraceEvent("nemoclaw.sandbox_base_image.cache_stale", { reason: validation.reason });
return null;
}
if (options.validateImage && !options.validateImage(hint.ref)) {
if (
options.validateImage &&
!options.validateImage(hint.ref, {
source: hint.source,
...(hint.pinnedRemoteRef ? { pinnedRemoteRef: hint.pinnedRemoteRef } : {}),
})
) {
addTraceEvent("nemoclaw.sandbox_base_image.cache_stale", {
reason: "custom_validation_failed",
});
Expand Down
25 changes: 24 additions & 1 deletion src/lib/sandbox-base-image/security-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
import { dockerCapture } from "../adapters/docker";

const SECURITY_INVENTORY_PROBE_OK = "nemoclaw-security-inventory-ok";
const CURRENT_PYTHON_HTMLPARSER_FIX_PACKAGE =
"nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u5+nemoclaw1";
const PINNED_HERMES_BASE_PYTHON_HTMLPARSER_FIX_PACKAGE =
"nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1";

export const SANDBOX_BASE_SECURITY_PACKAGE_INVENTORY = [
"libexpat1=2.8.3-1",
Expand All @@ -14,11 +18,18 @@ export const SANDBOX_BASE_SECURITY_PACKAGE_INVENTORY = [
"vim-tiny=2:9.2.0858-1",
"libssh2-1t64=1.11.1-1+deb13u1+nemoclaw2",
"libssl3t64=3.5.7-1~deb13u2",
"nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u5+nemoclaw1",
CURRENT_PYTHON_HTMLPARSER_FIX_PACKAGE,
"perl-base=5.44.0-1nemoclaw1",
"perl=5.44.0-1nemoclaw1",
] as const;

const PINNED_HERMES_BASE_SECURITY_PACKAGE_INVENTORY = SANDBOX_BASE_SECURITY_PACKAGE_INVENTORY.map(
(packageSpec) =>
packageSpec === CURRENT_PYTHON_HTMLPARSER_FIX_PACKAGE
? PINNED_HERMES_BASE_PYTHON_HTMLPARSER_FIX_PACKAGE
: packageSpec,
);

export const OPENCLAW_SANDBOX_BASE_SECURITY_PACKAGE_INVENTORY = [
...SANDBOX_BASE_SECURITY_PACKAGE_INVENTORY,
"libevent-core-2.1-7t64=2.1.13-stable-1",
Expand Down Expand Up @@ -70,6 +81,18 @@ export function sandboxBaseImageHasSecurityInventory(imageRef: string): boolean
return sandboxBaseImageHasPackageInventory(imageRef, SANDBOX_BASE_SECURITY_PACKAGE_INVENTORY);
}

/** Accept the reviewed older inventory only with pinned-base provenance. */
export function hermesSandboxBaseImageHasSecurityInventory(
imageRef: string,
allowPinnedInventory = false,
): boolean {
return (
sandboxBaseImageHasSecurityInventory(imageRef) ||
(allowPinnedInventory &&
sandboxBaseImageHasPackageInventory(imageRef, PINNED_HERMES_BASE_SECURITY_PACKAGE_INVENTORY))
);
}

export function openClawSandboxBaseImageHasSecurityInventory(imageRef: string): boolean {
return sandboxBaseImageHasPackageInventory(
imageRef,
Expand Down
7 changes: 6 additions & 1 deletion src/lib/sandbox-base-image/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export type ResolveBaseImageOptions = {
pinnedRemoteRef?: string;
requirePinnedRemoteRef?: boolean;
allowLocalFallback?: boolean;
validateImage?: (imageRef: string) => boolean;
validateImage?: (imageRef: string, context?: SandboxBaseImageValidationContext) => boolean;
validationDescription?: string;
resolutionHint?: SandboxBaseImageResolutionMetadata | null;
forceRefresh?: boolean;
Expand All @@ -74,6 +74,11 @@ export type SandboxBaseImageResolution = {
metadata?: SandboxBaseImageResolutionMetadata;
};

export type SandboxBaseImageValidationContext = Pick<
SandboxBaseImageResolution,
"source" | "pinnedRemoteRef"
>;

export type LocalImageMetadata = {
Id?: unknown;
RepoDigests?: unknown;
Expand Down
19 changes: 19 additions & 0 deletions test/e2e/fixtures/hermes-acp-live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { setTimeout as sleep } from "node:timers/promises";
import type { ArtifactSink } from "./artifacts.ts";
import type { SandboxClient } from "./clients/sandbox.ts";
import { type ChildProcessProgress, spawnObservedChild } from "./observed-child-process.ts";
import type { ShellProbeResult } from "./shell-probe.ts";
import { superviseChild } from "../../helpers/process-supervisor.ts";

const ACP_SCENARIO_TIMEOUT_MS = 3 * 60_000;
Expand Down Expand Up @@ -66,6 +67,24 @@ export function hermesAcpLiveHostEnv(source: NodeJS.ProcessEnv): NodeJS.ProcessE
return result;
}

export function hermesAcpGatewayStoppedPreconditionPassed(result: ShellProbeResult): boolean {
const text = `${result.stdout}\n${result.stderr}`;
if (result.exitCode === 0) {
return (
!result.timedOut &&
result.signal === null &&
/^Status:[ \t]*Disconnected[ \t]*\r?$/imu.test(result.stdout)
);
}
return (
!result.timedOut &&
result.signal === null &&
/client error \(Connect\)/iu.test(text) &&
/tcp connect error/iu.test(text) &&
/Connection refused \(os error (?:61|111)\)/iu.test(text)
);
}

export function isAcpResponse(message: unknown, id: number): message is JsonObject {
return (
typeof message === "object" &&
Expand Down
Loading
Loading