Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
57 changes: 55 additions & 2 deletions .github/workflows/base-image.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

# Build and push the sandbox base image to GHCR.
# Build and push the sandbox base images to GHCR.
#
# Triggers:
# - Push to main when Dockerfile.base or the blueprint minimum version changes
# - Push to main when a base-image workflow input changes
# - Manual dispatch for ad-hoc rebuilds
#
# The base image contains the expensive, rarely-changing layers (apt, gosu,
Expand All @@ -19,8 +19,13 @@ on:
tags:
- "v*"
paths:
# Re-run when this workflow gains or changes a publisher so the new path
# takes effect immediately after merge instead of waiting for another tag.
- ".github/workflows/base-image.yaml"
- "Dockerfile.base"
- "agents/hermes/Dockerfile.base"
- "agents/langchain-deepagents-code/Dockerfile.base"
- "agents/langchain-deepagents-code/requirements.lock"
# Dockerfile.base validates min_openclaw_version from this file at build time.
- "nemoclaw-blueprint/blueprint.yaml"
workflow_dispatch:
Expand Down Expand Up @@ -161,3 +166,51 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

build-and-push-langchain-deepagents-code:
if: github.repository == 'NVIDIA/NemoClaw'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

- name: Set up QEMU (arm64 emulation)
uses: docker/setup-qemu-action@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4

- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@v6
env:
DOCKER_METADATA_SHORT_SHA_LENGTH: 8
with:
images: ${{ env.REGISTRY }}/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=ref,event=tag
type=sha,prefix=,format=short

- name: Validate Deep Agents Code production Docker build args
run: scripts/check-production-build-args.sh

- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: agents/langchain-deepagents-code/Dockerfile.base
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
59 changes: 59 additions & 0 deletions src/lib/agent/base-image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,65 @@ describe("agent base image provisioning", () => {
);
});

it("rejects Deep Agents Code base images that drift from the manifest version (#6456)", () => {
withMockedDocker(({ ensureAgentBaseImage, dockerCaptureMock, resolveSandboxBaseImageMock }) => {
ensureAgentBaseImage(
makeAgent({
name: "langchain-deepagents-code",
displayName: "LangChain Deep Agents Code",
expectedVersion: "0.1.34",
dockerfileBasePath: "/test/root/agents/langchain-deepagents-code/Dockerfile.base",
dockerfilePath: "/test/root/agents/langchain-deepagents-code/Dockerfile",
}),
);
const options = resolveSandboxBaseImageMock.mock.calls[0]?.[0] as {
validateImage?: (imageRef: string) => boolean;
};

dockerCaptureMock.mockReturnValue("0.1.34");
expect(options.validateImage?.("dcode-base:current")).toBe(true);
expect(dockerCaptureMock).toHaveBeenLastCalledWith(
[
"run",
"--rm",
"--entrypoint",
"/opt/venv/bin/python3",
"dcode-base:current",
"-I",
"-c",
'import importlib.metadata; print(importlib.metadata.version("deepagents-code"))',
],
{ ignoreError: true, timeout: 20_000 },
);

dockerCaptureMock.mockReturnValue("0.1.12");
expect(options.validateImage?.("dcode-base:stale")).toBe(false);
expect(resolveSandboxBaseImageMock).toHaveBeenCalledWith(
expect.objectContaining({
inputPaths: ["/test/root/agents/langchain-deepagents-code/requirements.lock"],
validateImage: expect.any(Function),
validationDescription: "deepagents-code==0.1.34",
}),
);
});
});

it("fails closed when the Deep Agents Code manifest omits its base-image version", () => {
withMockedDocker(({ ensureAgentBaseImage, resolveSandboxBaseImageMock }) => {
expect(() =>
ensureAgentBaseImage(
makeAgent({
name: "langchain-deepagents-code",
displayName: "LangChain Deep Agents Code",
expectedVersion: null,
dockerfileBasePath: "/test/root/agents/langchain-deepagents-code/Dockerfile.base",
}),
),
).toThrow("LangChain Deep Agents Code manifest is missing expected_version");
expect(resolveSandboxBaseImageMock).not.toHaveBeenCalled();
});
});

it("rebuilds an agent base image when rebuild flow forces local Dockerfile.base refresh", () => {
withMockedDocker(
({
Expand Down
47 changes: 45 additions & 2 deletions src/lib/agent/base-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
import type { AgentDefinition } from "./defs";

const HERMES_MCP_RUNTIME_PROBE_OK = "nemoclaw-hermes-mcp-runtime-ok";
const DEEPAGENTS_CODE_DISTRIBUTION = "deepagents-code";
// Matches the official Hermes base repository for both Dockerfile manifest-list
// pins and Docker-normalized platform manifest digests.
const HERMES_OFFICIAL_BASE_DIGEST_REF =
Expand Down Expand Up @@ -157,17 +158,55 @@ export function hermesBaseImageSupportsMcp(imageRef: string): boolean {
return output.trim() === HERMES_MCP_RUNTIME_PROBE_OK;
}

/**
* Reject a published or cached Deep Agents Code base whose installed package
* does not match the active manifest. The final image patchers intentionally
* require this exact source pairing, so accepting a merely runnable older base
* only defers the failure until the expensive final-image build (#6456).
*/
function deepAgentsCodeBaseImageMatchesVersion(imageRef: string, expectedVersion: string): boolean {
const output = dockerCapture(
[
"run",
"--rm",
"--entrypoint",
"/opt/venv/bin/python3",
imageRef,
"-I",
"-c",
`import importlib.metadata; print(importlib.metadata.version("${DEEPAGENTS_CODE_DISTRIBUTION}"))`,
],
{ ignoreError: true, timeout: 20_000 },
);
return output.trim() === expectedVersion;
}

function createAgentBaseImageResolutionOptions(
agent: AgentDefinition,
dockerfilePath: string,
options: EnsureAgentBaseImageOptions,
): ResolveBaseImageOptions {
const imageName = `ghcr.io/nvidia/nemoclaw/${agent.name}-sandbox-base`;
const validateImage = agent.name === "hermes" ? hermesBaseImageSupportsMcp : undefined;
const deepAgentsCodeExpectedVersion =
agent.name === "langchain-deepagents-code" ? agent.expectedVersion : null;
if (agent.name === "langchain-deepagents-code" && !deepAgentsCodeExpectedVersion) {
throw new Error("LangChain Deep Agents Code manifest is missing expected_version");
}
const validateImage =
agent.name === "hermes"
? hermesBaseImageSupportsMcp
: deepAgentsCodeExpectedVersion
? (imageRef: string) =>
deepAgentsCodeBaseImageMatchesVersion(imageRef, deepAgentsCodeExpectedVersion)
: undefined;
const pinnedRemoteRef = getHermesPinnedRemoteBaseRef(agent) ?? undefined;
return {
imageName,
dockerfilePath,
inputPaths:
agent.name === "langchain-deepagents-code"
? [path.join(path.dirname(dockerfilePath), "requirements.lock")]
: undefined,
localTag: buildLocalBaseTag(`nemoclaw-${agent.name}-sandbox-base-local`, ROOT),
envVar: getAgentSandboxBaseImageEnvVar(agent.name),
label: `${agent.displayName} sandbox base image`,
Expand All @@ -179,7 +218,11 @@ function createAgentBaseImageResolutionOptions(
preferPinnedRemoteRef: agent.name === "hermes" && pinnedRemoteRef !== undefined,
validateImage,
validationDescription:
agent.name === "hermes" ? "the required MCP Streamable HTTP runtime" : undefined,
agent.name === "hermes"
? "the required MCP Streamable HTTP runtime"
: deepAgentsCodeExpectedVersion
? `${DEEPAGENTS_CODE_DISTRIBUTION}==${deepAgentsCodeExpectedVersion}`
: undefined,
};
}

Expand Down
31 changes: 31 additions & 0 deletions src/lib/sandbox-base-image-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,37 @@ describe("sandbox base-image warm resolution", () => {
});
});

it("tracks agent dependency locks in dirty and main-divergence checks (#6456)", () => {
dockerMocks.imageInspect.mockReturnValue({ status: 1 });
dockerMocks.pull.mockReturnValue({ status: 1 });
const options = resolutionOptions();
const lockfile = path.join(
process.cwd(),
"agents",
"langchain-deepagents-code",
"requirements.lock",
);

expect(
resolveSandboxBaseImage({
...options,
inputPaths: [lockfile],
env: {
...options.env,
NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD: "0",
},
}),
).toBeNull();
expect(sourceMocks.inputsDirty).toHaveBeenCalledWith(process.cwd(), expect.any(Object), [
options.dockerfilePath,
lockfile,
]);
expect(sourceMocks.inputsChanged).toHaveBeenCalledWith(process.cwd(), expect.any(Object), [
options.dockerfilePath,
lockfile,
]);
});

it("fails closed instead of trusting an existing local tag when base inputs are dirty (#4680)", () => {
sourceMocks.inputsDirty.mockReturnValue(true);
dockerMocks.imageInspect.mockReturnValue({ status: 0 });
Expand Down
2 changes: 1 addition & 1 deletion src/lib/sandbox-base-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ export function resolveSandboxBaseImage(
if (!options.requireOpenshellSandboxAbi && !options.validateImage) return null;
} else {
const rootDir = options.rootDir || ROOT;
const inputPaths = [options.dockerfilePath];
const inputPaths = [options.dockerfilePath, ...(options.inputPaths ?? [])];
const preferPinnedRemoteRef = options.preferPinnedRemoteRef === true;
if (baseImageInputsDirty(rootDir, env, inputPaths)) return resolveChangedInputs();

Expand Down
13 changes: 13 additions & 0 deletions src/lib/sandbox-base-image/resolution-key.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ describe("sandbox base-image resolution key", () => {
expect(createSandboxBaseImageResolutionKey(options(root))).not.toBe(before);
});

it("changes when an agent-specific dependency lock changes (#6456)", () => {
const root = fixture();
const lockfile = path.join(root, "agents", "langchain-deepagents-code", "requirements.lock");
fs.mkdirSync(path.dirname(lockfile), { recursive: true });
fs.writeFileSync(lockfile, "deepagents-code==0.1.34\n");
const keyedOptions = { ...options(root), inputPaths: [lockfile] };
const before = createSandboxBaseImageResolutionKey(keyedOptions);

fs.writeFileSync(lockfile, "deepagents-code==0.1.34\ntransitive-dependency==2.0.0\n");

expect(createSandboxBaseImageResolutionKey(keyedOptions)).not.toBe(before);
});

it("isolates explicit base-image overrides (#4680)", () => {
const root = fixture();
const base = { ...options(root), envVar: "NEMOCLAW_SANDBOX_BASE_IMAGE_REF" };
Expand Down
10 changes: 7 additions & 3 deletions src/lib/sandbox-base-image/resolution-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@ import {
SANDBOX_BASE_RESOLUTION_SCHEMA,
} from "./types";

function hashBaseImageInputs(rootDir: string, dockerfilePath: string): string {
function hashBaseImageInputs(
rootDir: string,
dockerfilePath: string,
inputPaths: string[] = [],
): string {
const hash = crypto.createHash("sha256");
const paths = normalizeBaseImageInputPaths(rootDir, [dockerfilePath]).sort();
const paths = normalizeBaseImageInputPaths(rootDir, [dockerfilePath, ...inputPaths]).sort();
for (const relativePath of paths) {
hash.update(relativePath);
hash.update("\0");
Expand Down Expand Up @@ -55,7 +59,7 @@ export function createSandboxBaseImageResolutionKey(options: ResolveBaseImageOpt
versionTags: getVersionedBaseImageTags(rootDir, env),
sourceTags: getSourceShortShaTags(rootDir, env),
localTag: options.localTag,
inputFingerprint: hashBaseImageInputs(rootDir, options.dockerfilePath),
inputFingerprint: hashBaseImageInputs(rootDir, options.dockerfilePath, options.inputPaths),
platform: dockerPlatform(),
requireOpenshellSandboxAbi: options.requireOpenshellSandboxAbi === true,
minGlibcVersion: options.minGlibcVersion || OPENSHELL_SANDBOX_MIN_GLIBC,
Expand Down
1 change: 1 addition & 0 deletions src/lib/sandbox-base-image/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export type SandboxBaseImageResolutionMetadata = {
export type ResolveBaseImageOptions = {
imageName: string;
dockerfilePath: string;
inputPaths?: string[];
localTag: string;
envVar?: string;
label?: string;
Expand Down
Loading