Skip to content
Merged
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
6 changes: 5 additions & 1 deletion docs/manage-sandboxes/install-plugins-hermes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ For the controller topology, trust boundary, health proof, and fail-closed behav
Put the custom Dockerfile and every file it needs to `COPY` in one directory.
`nemohermes onboard --from <Dockerfile>` sends the Dockerfile's parent directory as the Docker build context.

Add a `.dockerignore` next to the Dockerfile to keep local caches, generated artifacts, model files, or other unneeded paths out of the staged context.
The one exception is the managed Hermes Dockerfile itself: passing the `agents/hermes/Dockerfile` from the NemoClaw checkout the CLI runs from stages the repository root as the build context, exactly as the managed build does.
The managed exception applies the `.dockerignore` from the repository root, not one under `agents/hermes/`.
Use that path when you want to edit the managed Dockerfile in place (for example, to install extra Python packages) and rebuild the stock image with your changes.

For a standalone custom Dockerfile, add a `.dockerignore` next to the Dockerfile to keep local caches, generated artifacts, model files, or other unneeded paths out of the staged context.
NemoClaw still excludes credential-like paths such as `.env*`, `.ssh/`, `.aws/`, `.npmrc`, `secrets/`, `*.pem`, and `*.key`, even if `.dockerignore` tries to include them.

NemoClaw sends user-supplied `--from` contexts to the OpenShell gateway builder and reserves its host-side local BuildKit prebuild for contexts that NemoClaw generates itself.
Expand Down
5 changes: 4 additions & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -655,7 +655,10 @@ The poll count is clamped to a minimum of `1` so the probe always runs at least
Build the sandbox image from a custom Dockerfile instead of the stock NemoClaw image.
The supplied Dockerfile defines the complete sandbox image, and NemoClaw does not layer it on top of the stock managed runtime.
The entire parent directory of the specified file is used as the Docker build context, so any files your Dockerfile references (scripts, config, etc.) must live alongside it.
If that directory contains a `.dockerignore`, onboarding applies those rules while calculating the context size and staging files for Docker.
When the supplied path is the selected agent's own managed Dockerfile (for example, `agents/hermes/Dockerfile` in the NemoClaw checkout the CLI runs from), NemoClaw applies one exception and stages the repository root as the build context, exactly as the managed build does, because that Dockerfile copies repository-root paths.
This lets you edit the managed Dockerfile in place (for example to add Python packages) and rebuild from it with `--from`.
For this managed exception, onboarding applies the `.dockerignore` from the repository root.
For every other `--from` path, onboarding applies a `.dockerignore` from the Dockerfile's parent directory while calculating the context size and staging files for Docker.
NemoClaw also applies additional secret-safety exclusions that override `.dockerignore` negation rules: credential-style files and directories such as `.env*`, `.ssh/`, `.aws/`, `.netrc`, `.npmrc`, `secrets/`, `*.pem`, and `*.key` are still skipped even if `.dockerignore` tries to include them.
Without a `.dockerignore`, onboarding still skips common large or local-only directories (`node_modules`, `.git`, `.venv`, and `__pycache__`) while staging this context.
Other build outputs such as `dist/`, `target/`, or `build/` are included unless your `.dockerignore` excludes them.
Expand Down
21 changes: 14 additions & 7 deletions src/lib/agent/base-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
dockerRmi,
dockerTag,
} from "../adapters/docker";
import { createCustomBuildContextFilter } from "../onboard/custom-build-context";
import { ROOT } from "../runner";
import { SANDBOX_BUILD_CONTEXT_PREFIX } from "../sandbox/build-context";
import {
Expand Down Expand Up @@ -53,6 +54,10 @@ export interface EnsureAgentBaseImageOptions {
forceBaseImageRefresh?: boolean;
}

export interface CreateAgentSandboxOptions extends EnsureAgentBaseImageOptions {
rootDir?: string;
}

export interface EnsureAgentBaseImageResult {
imageTag: string | null;
built: boolean;
Expand Down Expand Up @@ -636,22 +641,24 @@ export function ensureAgentBaseImage(
/** Stage build context for an agent-specific sandbox image. */
export function createAgentSandbox(
agent: AgentDefinition,
options: EnsureAgentBaseImageOptions = {},
options: CreateAgentSandboxOptions = {},
): CreateAgentSandboxResult {
const agentDockerfile = agent.dockerfilePath;

if (!agentDockerfile) {
throw new Error(`${agent.displayName} is missing a sandbox Dockerfile`);
}

const { imageTag: baseImageRef, resolutionMetadata } = ensureAgentBaseImage(agent, options);
const { rootDir = ROOT, ...baseImageOptions } = options;
const { imageTag: baseImageRef, resolutionMetadata } = ensureAgentBaseImage(
agent,
baseImageOptions,
);
const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), SANDBOX_BUILD_CONTEXT_PREFIX));
fs.cpSync(ROOT, buildCtx, {
const shouldIncludeBuildContextPath = createCustomBuildContextFilter(rootDir);
fs.cpSync(rootDir, buildCtx, {
recursive: true,
filter: (src) => {
const base = path.basename(src);
return !["node_modules", ".git", ".venv", "__pycache__", ".claude"].includes(base);
},
filter: (src) => path.basename(src) !== ".claude" && shouldIncludeBuildContextPath(src),
});
const stagedDockerfile = path.join(buildCtx, "Dockerfile");
fs.copyFileSync(agentDockerfile, stagedDockerfile);
Expand Down
168 changes: 168 additions & 0 deletions src/lib/onboard/build-context-stage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import os from "node:os";
import path from "node:path";

import { afterEach, describe, expect, it, vi } from "vitest";
import { createAgentSandbox as createManagedAgentSandbox } from "../agent/base-image";
import { stageCreateSandboxBuildContext } from "./build-context-stage";
import { CUSTOM_BUILD_CONTEXT_WARN_BYTES } from "./custom-build-context";

Expand All @@ -21,6 +22,21 @@ function throwingExit(code?: number): never {
throw new Error(`exit ${code ?? 0}`);
}

function writeFixtureFile(root: string, relativePath: string, contents: string): void {
const target = path.join(root, relativePath);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, contents);
}

function readStagedBytes(root: string): string {
return fs
.readdirSync(root, { encoding: "utf8", recursive: true })
.map((relativePath) => path.join(root, relativePath))
.filter((entryPath) => fs.statSync(entryPath).isFile())
.map((entryPath) => fs.readFileSync(entryPath, "utf8"))
.join("\n");
}

describe("stageCreateSandboxBuildContext", () => {
afterEach(() => {
vi.restoreAllMocks();
Expand Down Expand Up @@ -60,6 +76,158 @@ describe("stageCreateSandboxBuildContext", () => {
expect(fs.existsSync(result.buildCtx)).toBe(false);
});

it("stages the managed agent build context when --from targets the agent's own Dockerfile (#7205)", () => {
const repoRoot = makeTmpDir("nemoclaw-repo-root-");
const agentDir = path.join(repoRoot, "agents", "hermes");
fs.mkdirSync(agentDir, { recursive: true });
const agentDockerfile = path.join(agentDir, "Dockerfile");
fs.writeFileSync(agentDockerfile, "FROM scratch\nCOPY agents/hermes/plugin/ /opt/plugin/\n");
const agentBuild = {
buildCtx: makeTmpDir("nemoclaw-agent-staged-"),
stagedDockerfile: path.join(os.tmpdir(), "agent.Dockerfile"),
};
const createAgentSandbox = vi.fn(() => agentBuild);
const agent = { name: "hermes", displayName: "Hermes", dockerfilePath: agentDockerfile } as any;
const logs: string[] = [];

const result = stageCreateSandboxBuildContext({
root: repoRoot,
fromDockerfile: agentDockerfile,
agent,
createAgentSandbox,
log: (message) => logs.push(message),
exit: throwingExit,
});

expect(createAgentSandbox).toHaveBeenCalledWith(agent);
expect(result.buildCtx).toBe(agentBuild.buildCtx);
expect(result.origin).toBe("custom");
expect(logs).toEqual([
` Using custom Dockerfile: ${agentDockerfile}`,
" This is the managed Hermes Dockerfile; staging the repository root as the Docker build context.",
]);
});

it("filters checkout credentials from the staged managed repository-root context (#7205)", () => {
const repoRoot = makeTmpDir("nemoclaw-managed-context-security-");
const requiredFiles = [
["agents/hermes/plugin/entry.py", "required-plugin-bytes"],
["src/lib/tool-disclosure.ts", "required-tool-disclosure-bytes"],
["scripts/lib/reviewed-npm-archive.mts", "required-script-bytes"],
["nemoclaw-blueprint/blueprint.yaml", "required-blueprint-bytes"],
] as const;
const credentialFiles = [
[".env.local", "forbidden-env-canary"],
[".ssh/id_ed25519", "forbidden-ssh-canary"],
[".aws/credentials", "forbidden-aws-canary"],
[".npmrc", "forbidden-npm-canary"],
["secrets/token.txt", "forbidden-secrets-canary"],
["certs/client.pem", "forbidden-pem-canary"],
["keys/client.key", "forbidden-key-canary"],
] as const;
const agentDockerfile = path.join(repoRoot, "agents", "hermes", "Dockerfile");
writeFixtureFile(
repoRoot,
"agents/hermes/Dockerfile",
"FROM scratch\nCOPY agents/hermes/plugin/ /opt/plugin/\nCOPY src/ /src/\nCOPY scripts/ /scripts/\nCOPY nemoclaw-blueprint/ /blueprint/\n",
);
for (const [relativePath, contents] of [...requiredFiles, ...credentialFiles]) {
writeFixtureFile(repoRoot, relativePath, contents);
}
writeFixtureFile(repoRoot, "ignored-by-repo-rule.txt", "forbidden-dockerignore-canary");
writeFixtureFile(
repoRoot,
".dockerignore",
[
"ignored-by-repo-rule.txt",
"!.env.local",
"!.ssh/id_ed25519",
"!.aws/credentials",
"!.npmrc",
"!secrets/token.txt",
"!certs/client.pem",
"!keys/client.key",
].join("\n"),
);

const result = stageCreateSandboxBuildContext({
root: repoRoot,
fromDockerfile: agentDockerfile,
agent: {
name: "hermes",
displayName: "Hermes",
dockerfileBasePath: null,
dockerfilePath: agentDockerfile,
} as any,
createAgentSandbox: (agent) => createManagedAgentSandbox(agent, { rootDir: repoRoot }),
log: vi.fn(),
exit: throwingExit,
});
tmpDirs.push(result.buildCtx);

const stagedBytes = readStagedBytes(result.buildCtx);
for (const [relativePath, contents] of requiredFiles) {
expect(fs.readFileSync(path.join(result.buildCtx, relativePath), "utf8")).toBe(contents);
}
for (const [relativePath, contents] of credentialFiles) {
expect(fs.existsSync(path.join(result.buildCtx, relativePath))).toBe(false);
expect(stagedBytes).not.toContain(contents);
}
expect(stagedBytes).not.toContain("forbidden-dockerignore-canary");
});

it("stages the managed agent build context when --from reaches the agent Dockerfile through a symlink", () => {
const repoRoot = makeTmpDir("nemoclaw-repo-symlink-");
const agentDir = path.join(repoRoot, "agents", "hermes");
fs.mkdirSync(agentDir, { recursive: true });
const agentDockerfile = path.join(agentDir, "Dockerfile");
fs.writeFileSync(agentDockerfile, "FROM scratch\n");
const linkDir = makeTmpDir("nemoclaw-linked-checkout-");
const linkedDockerfile = path.join(linkDir, "Dockerfile");
fs.symlinkSync(agentDockerfile, linkedDockerfile);
const agentBuild = {
buildCtx: makeTmpDir("nemoclaw-agent-staged-link-"),
stagedDockerfile: path.join(os.tmpdir(), "agent.Dockerfile"),
};
const createAgentSandbox = vi.fn(() => agentBuild);
const agent = { name: "hermes", displayName: "Hermes", dockerfilePath: agentDockerfile } as any;

const result = stageCreateSandboxBuildContext({
root: repoRoot,
fromDockerfile: linkedDockerfile,
agent,
createAgentSandbox,
log: vi.fn(),
exit: throwingExit,
});

expect(createAgentSandbox).toHaveBeenCalledWith(agent);
expect(result.buildCtx).toBe(agentBuild.buildCtx);
});

it("keeps the parent-directory contract for a standalone Dockerfile when an agent is selected", () => {
const buildContextDir = makeTmpDir("nemoclaw-standalone-context-");
const standaloneDockerfile = path.join(buildContextDir, "Dockerfile");
fs.writeFileSync(standaloneDockerfile, "FROM scratch\n");
const otherDockerfile = path.join(makeTmpDir("nemoclaw-agent-home-"), "Dockerfile");
fs.writeFileSync(otherDockerfile, "FROM scratch\n");
const createAgentSandbox = vi.fn();

const result = stageCreateSandboxBuildContext({
root: "/unused",
fromDockerfile: standaloneDockerfile,
agent: { name: "hermes", displayName: "Hermes", dockerfilePath: otherDockerfile } as any,
createAgentSandbox,
log: vi.fn(),
exit: throwingExit,
});
tmpDirs.push(result.buildCtx);

expect(createAgentSandbox).not.toHaveBeenCalled();
expect(result.origin).toBe("custom");
expect(fs.existsSync(result.stagedDockerfile)).toBe(true);
});

it("exits when the custom Dockerfile path is missing", () => {
const errors: string[] = [];
const missingDockerfile = path.join(makeTmpDir("nemoclaw-missing-context-"), "Dockerfile");
Expand Down
25 changes: 25 additions & 0 deletions src/lib/onboard/build-context-stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ export interface PreparedSandboxBuildContext extends CreateSandboxBuildContextRe
};
}

function isSameFile(leftPath: string, rightPath: string): boolean {
try {
return fs.realpathSync(leftPath) === fs.realpathSync(rightPath);
} catch {
return path.resolve(leftPath) === path.resolve(rightPath);
}
}

function createCleanupBuildContext(buildCtx: string): () => boolean {
return () => {
try {
Expand Down Expand Up @@ -82,6 +90,23 @@ export function stageCreateSandboxBuildContext(
error(` Custom Dockerfile path is not a file: ${fromResolved}`);
exit(1);
}
// The managed agent Dockerfile copies repository-root paths (src/,
// scripts/, nemoclaw-blueprint/), so the parent-directory contract can
// never satisfy it. Stage it exactly like the managed build instead of
// failing at the first COPY (#7205).
const agentDockerfile = input.agent?.dockerfilePath ?? null;
if (input.agent && agentDockerfile && isSameFile(fromResolved, agentDockerfile)) {
log(` Using custom Dockerfile: ${fromResolved}`);
log(
` This is the managed ${input.agent.displayName} Dockerfile; staging the repository root as the Docker build context.`,
);
build = input.createAgentSandbox(input.agent);
return {
...build,
origin,
cleanupBuildCtx: createCleanupBuildContext(build.buildCtx),
};
}
const buildContextDir = path.dirname(fromResolved);
if (isInsideIgnoredCustomBuildContextPath(buildContextDir)) {
error(` Custom Dockerfile is inside an ignored build-context path: ${buildContextDir}`);
Expand Down
Loading