Skip to content

Commit e89c65e

Browse files
committed
fix(cua): isolate caller image rebuilds
Signed-off-by: Julie Yaunches <jyaunches@nvidia.com>
1 parent 2d465d8 commit e89c65e

4 files changed

Lines changed: 119 additions & 15 deletions

File tree

src/lib/actions/sandbox/rebuild-flow-helpers.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
import fs from "node:fs";
5+
import os from "node:os";
6+
import path from "node:path";
7+
48
import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest";
9+
510
import * as dockerImage from "../../adapters/docker/image";
611
import * as agentDefs from "../../agent/defs";
712
import * as agentOnboard from "../../agent/onboard";
@@ -189,6 +194,91 @@ describe("rebuild agent base image preflight", () => {
189194
});
190195
});
191196

197+
it("hands a pinned NemoCUA image alias to the inner sandbox create (#9649)", () => {
198+
const cuaOverrideEnvVar = "NEMOCLAW_CUA_SANDBOX_IMAGE_REF";
199+
const mutableRef = "nemocua-scenario:mutable";
200+
const pinnedRef = `nemoclaw-nemocua-sandbox-base-local:rebuild-1-${"a".repeat(16)}-image-${"b".repeat(64)}`;
201+
const agent = agentDefs.loadAgent("nemocua", { NEMOCLAW_CUA_ENABLED: "1" });
202+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cua-rebuild-test-"));
203+
let buildContext = root;
204+
vi.stubEnv("NEMOCLAW_CUA_ENABLED", "1");
205+
vi.stubEnv(cuaOverrideEnvVar, mutableRef);
206+
vi.spyOn(agentDefs, "loadAgent").mockReturnValue(agent);
207+
vi.spyOn(agentOnboard, "ensureAgentBaseImage").mockReturnValue({
208+
imageTag: mutableRef,
209+
built: false,
210+
});
211+
const pinImage = vi
212+
.spyOn(agentOnboard, "pinAgentSandboxBaseImageRef")
213+
.mockReturnValue(pinnedRef);
214+
const dockerRmi = vi.spyOn(dockerImage, "dockerRmi").mockReturnValue({ status: 0 } as never);
215+
216+
try {
217+
const preflight = ensureRebuildAgentBaseImage("nemocua", makeBail());
218+
expect(preflight).toMatchObject({
219+
ok: true,
220+
imageRef: pinnedRef,
221+
overrideEnvVar: cuaOverrideEnvVar,
222+
});
223+
expect(pinImage).toHaveBeenCalledWith("nemocua", mutableRef, {
224+
forceLocal: true,
225+
temporary: true,
226+
});
227+
228+
const restore = pinRebuildAgentBaseImageForRecreate(preflight);
229+
try {
230+
const inner = agentOnboard.createAgentSandbox(agent, { rootDir: root });
231+
buildContext = inner.buildCtx;
232+
const dockerfile = fs.readFileSync(inner.stagedDockerfile, "utf8");
233+
expect(dockerfile).toContain(`ARG BASE_IMAGE=${pinnedRef}`);
234+
expect(dockerfile).not.toContain(mutableRef);
235+
} finally {
236+
restore();
237+
}
238+
239+
expect(process.env[cuaOverrideEnvVar]).toBe(mutableRef);
240+
expect(disposeRebuildAgentBaseImagePreflight(preflight)).toBe(true);
241+
expect(dockerRmi).toHaveBeenCalledWith(pinnedRef, {
242+
ignoreError: true,
243+
suppressOutput: true,
244+
});
245+
} finally {
246+
fs.rmSync(buildContext, { recursive: true, force: true });
247+
fs.rmSync(root, { recursive: true, force: true });
248+
vi.unstubAllEnvs();
249+
}
250+
});
251+
252+
it("passes an immutable NemoCUA digest through without a local alias (#9649)", () => {
253+
const cuaOverrideEnvVar = "NEMOCLAW_CUA_SANDBOX_IMAGE_REF";
254+
const digestRef = `registry.example/nemocua@sha256:${"a".repeat(64)}`;
255+
const agent = agentDefs.loadAgent("nemocua", { NEMOCLAW_CUA_ENABLED: "1" });
256+
vi.stubEnv("NEMOCLAW_CUA_ENABLED", "1");
257+
vi.stubEnv(cuaOverrideEnvVar, digestRef);
258+
vi.spyOn(agentDefs, "loadAgent").mockReturnValue(agent);
259+
vi.spyOn(agentOnboard, "ensureAgentBaseImage").mockReturnValue({
260+
imageTag: digestRef,
261+
built: false,
262+
});
263+
const pinImage = vi.spyOn(agentOnboard, "pinAgentSandboxBaseImageRef");
264+
const dockerRmi = vi.spyOn(dockerImage, "dockerRmi");
265+
266+
try {
267+
const preflight = ensureRebuildAgentBaseImage("nemocua", makeBail());
268+
269+
expect(preflight).toEqual({
270+
ok: true,
271+
imageRef: digestRef,
272+
overrideEnvVar: cuaOverrideEnvVar,
273+
});
274+
expect(pinImage).not.toHaveBeenCalled();
275+
expect(disposeRebuildAgentBaseImagePreflight(preflight)).toBe(true);
276+
expect(dockerRmi).not.toHaveBeenCalled();
277+
} finally {
278+
vi.unstubAllEnvs();
279+
}
280+
});
281+
192282
it("fails closed when an explicit local result lacks validated outer metadata", () => {
193283
process.env[overrideEnvVar] = "nemoclaw-hermes-sandbox-base-local:caller";
194284
const mutableRef = "nemoclaw-hermes-sandbox-base-local:resolved";

src/lib/actions/sandbox/rebuild-flow-helpers.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,22 @@ export function ensureRebuildAgentBaseImage(
315315
} finally {
316316
restoreExplicitOverrideTrust();
317317
}
318+
if (agentDef.name === "nemocua") {
319+
if (!result.imageTag) throw new Error("NemoCUA caller image resolution returned no image");
320+
if (isImmutableRemoteBaseImageRef(result.imageTag)) {
321+
return { ok: true, imageRef: result.imageTag, overrideEnvVar };
322+
}
323+
const imageRef = pinAgentSandboxBaseImageRef(agentDef.name, result.imageTag, {
324+
forceLocal: true,
325+
temporary: true,
326+
});
327+
return {
328+
ok: true,
329+
imageRef,
330+
overrideEnvVar,
331+
disposeImageRef: createTemporaryBaseImageHandoffDisposer(imageRef),
332+
};
333+
}
318334
const reusedLocalResolution =
319335
result.resolutionMetadata?.source === "local" &&
320336
result.reusedResolutionHint === result.resolutionMetadata;

src/lib/agent/base-image.test.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -121,25 +121,20 @@ describe("agent base image provisioning", () => {
121121
});
122122
});
123123

124-
it("stages NemoCUA through the ordinary agent Dockerfile path (#9649)", () => {
124+
it("stages only the NemoCUA Dockerfile in the caller-image build context (#9649)", () => {
125125
vi.stubEnv("NEMOCLAW_CUA_ENABLED", "1");
126126
vi.stubEnv("NEMOCLAW_CUA_SANDBOX_IMAGE_REF", "nemocua-scenario:staged");
127127
const agent = loadAgent("nemocua");
128128
const root = tmpDir();
129-
const stagedAgentDir = path.join(root, "agents", "nemocua");
130-
fs.mkdirSync(path.dirname(stagedAgentDir), { recursive: true });
131-
fs.cpSync(agent.agentDir, stagedAgentDir, { recursive: true });
129+
fs.writeFileSync(path.join(root, "unrelated-sentinel.txt"), "must not enter build context");
132130
let buildContext = root;
133131

134132
try {
135133
withMockedDocker(({ createAgentSandbox }) => {
136134
const result = createAgentSandbox(agent, { rootDir: root });
137135
buildContext = result.buildCtx;
138-
expect(fs.readdirSync(path.join(result.buildCtx, "agents", "nemocua")).sort()).toEqual([
139-
"Dockerfile",
140-
"manifest.yaml",
141-
"policy-additions.yaml",
142-
]);
136+
expect(fs.readdirSync(result.buildCtx)).toEqual(["Dockerfile"]);
137+
expect(fs.existsSync(path.join(result.buildCtx, "unrelated-sentinel.txt"))).toBe(false);
143138
expect(fs.readFileSync(result.stagedDockerfile, "utf8")).toContain(
144139
"ARG BASE_IMAGE=nemocua-scenario:staged",
145140
);

src/lib/agent/base-image.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
dockerRmi,
1515
dockerTag,
1616
} from "../adapters/docker";
17-
import { requireCuaSandboxImageRef } from "../cua/feature";
17+
import { CUA_SANDBOX_IMAGE_ENV, requireCuaSandboxImageRef } from "../cua/feature";
1818
import { encodeCorporateCaArg, resolveCorporateCa } from "../onboard/corporate-ca";
1919
import { createCustomBuildContextFilter } from "../onboard/custom-build-context";
2020
import { ROOT } from "../runner";
@@ -172,6 +172,7 @@ function reuseTrustedAgentRemoteBaseImageOverride(
172172
}
173173

174174
export function getAgentSandboxBaseImageEnvVar(agentName: string): string {
175+
if (agentName === "nemocua") return CUA_SANDBOX_IMAGE_ENV;
175176
return `NEMOCLAW_${agentName.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_SANDBOX_BASE_IMAGE_REF`;
176177
}
177178

@@ -724,11 +725,13 @@ export function createAgentSandbox(
724725
const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), SANDBOX_BUILD_CONTEXT_PREFIX));
725726
const stagedDockerfile = path.join(buildCtx, "Dockerfile");
726727
try {
727-
const shouldIncludeBuildContextPath = createCustomBuildContextFilter(rootDir);
728-
fs.cpSync(rootDir, buildCtx, {
729-
recursive: true,
730-
filter: (src) => path.basename(src) !== ".claude" && shouldIncludeBuildContextPath(src),
731-
});
728+
if (agent.name !== "nemocua") {
729+
const shouldIncludeBuildContextPath = createCustomBuildContextFilter(rootDir);
730+
fs.cpSync(rootDir, buildCtx, {
731+
recursive: true,
732+
filter: (src) => path.basename(src) !== ".claude" && shouldIncludeBuildContextPath(src),
733+
});
734+
}
732735
fs.copyFileSync(agentDockerfile, stagedDockerfile);
733736
if (baseImageRef) {
734737
const dockerfile = fs.readFileSync(stagedDockerfile, "utf8");

0 commit comments

Comments
 (0)