Skip to content
7 changes: 5 additions & 2 deletions docs/deployment/install-openclaw-plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
title: "Install OpenClaw Plugins"
sidebar-title: "Install OpenClaw Plugins"
description: "How to install OpenClaw plugins in a NemoClaw-managed sandbox today."
description-agent: "Explains the difference between OpenClaw plugins and agent skills, and shows the current Dockerfile-based workflow for baking a plugin into a NemoClaw sandbox. Use when users ask how to install, build, or configure OpenClaw plugins under NemoClaw."
keywords: ["nemoclaw plugins", "openclaw plugins", "install openclaw plugin", "nemoclaw onboard from dockerfile"]
description-agent: "Explains the difference between OpenClaw plugins and agent skills, and shows the current Dockerfile-based workflow for baking a plugin into a NemoClaw sandbox, including `.dockerignore` handling for custom build contexts. Use when users ask how to install, build, or configure OpenClaw plugins under NemoClaw."
keywords: ["nemoclaw plugins", "openclaw plugins", "install openclaw plugin", "nemoclaw onboard from dockerfile", "nemoclaw dockerignore"]
content:
type: "how_to"
skill:
Expand All @@ -24,6 +24,8 @@ The supported NemoClaw path for OpenClaw plugins is to bake the plugin into a cu

Put the Dockerfile and everything it needs to `COPY` in one directory.
`nemoclaw onboard --from <Dockerfile>` uses the Dockerfile's parent directory as the Docker build context.
Add a `.dockerignore` next to the Dockerfile to exclude local caches, generated artifacts, model files, or other paths that are not needed by the image build.
NemoClaw still applies its own secret-safety exclusions for credential-like paths such as `.env*`, `.ssh/`, `.aws/`, `.npmrc`, `secrets/`, `*.pem`, and `*.key`, even if `.dockerignore` negates them.

```text
my-plugin-sandbox/
Expand Down Expand Up @@ -78,6 +80,7 @@ These are the most common places where plugin installation gets mixed up with ot

- Do not use `nemoclaw <sandbox> skill install` for OpenClaw plugins. That command only installs `SKILL.md` agent skills.
- Do not put a Dockerfile in a broad directory such as `/tmp` unless you intend to send that whole directory as the Docker build context.
- Do not rely on `.dockerignore` to include credential-like paths; NemoClaw excludes those from staged custom build contexts for safety.
- Keep plugin dependencies in the build stage or plugin directory; avoid copying
unrelated host files into the sandbox image.

Expand Down
7 changes: 5 additions & 2 deletions docs/manage-sandboxes/install-plugins-hermes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
title: "Install Hermes Plugins"
sidebar-title: "Install Hermes Plugins"
description: "Install Hermes plugins for NemoClaw-managed sandboxes."
description-agent: "Explains how to install Hermes plugins in NemoClaw-managed sandboxes."
keywords: ["install hermes plugins", "hermes plugins nemoclaw", "nemoclaw hermes plugins"]
description-agent: "Explains how to install Hermes plugins in NemoClaw-managed sandboxes, including custom Dockerfile build-directory layout and `.dockerignore` handling. Use when users ask how to install, build, or configure Hermes plugins under NemoClaw."
keywords: ["install hermes plugins", "hermes plugins nemoclaw", "nemoclaw hermes plugins", "nemohermes dockerignore"]
content:
type: "how_to"
skill:
Expand Down Expand Up @@ -35,6 +35,8 @@ It uploads skill instructions and refreshes skill discovery, but it does not ins

Put the custom Dockerfile and everything 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.
NemoClaw still excludes credential-like paths such as `.env*`, `.ssh/`, `.aws/`, `.npmrc`, `secrets/`, `*.pem`, and `*.key`, even if `.dockerignore` tries to include them.

```text
my-hermes-plugin-sandbox/
Expand Down Expand Up @@ -116,6 +118,7 @@ These are the most common places where Hermes plugin installation gets mixed up
- Do not install Hermes plugins into `/sandbox/.openclaw/extensions`; that path is for OpenClaw plugins.
- Do not remove `/sandbox/.hermes/plugins/nemoclaw`; NemoClaw depends on that plugin for managed Hermes behavior.
- Do not put the Dockerfile in a broad directory unless you intend to send that whole directory as the Docker build context.
- Do not rely on `.dockerignore` to include credential-like paths; NemoClaw excludes those from staged custom build contexts for safety.
- Do not assume OpenShell policy allows Python package downloads during runtime by default.

## Next Steps
Expand Down
7 changes: 4 additions & 3 deletions docs/reference/commands-nemohermes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,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 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.
Onboarding skips common large directories (`node_modules`, `.git`, `.venv`, and `__pycache__`) while staging this context.
It also skips credential-style files and directories such as `.env*`, `.ssh/`, `.aws/`, `.netrc`, `.npmrc`, `secrets/`, `*.pem`, and `*.key`.
Other build outputs such as `dist/`, `target/`, or `build/` are still included.
If that directory contains a `.dockerignore`, onboarding applies those rules 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.
If the staged context is larger than 100 MB, onboarding prints a warning before the Docker build starts.
If the directory contains unreadable files (for example, Windows system files visible in WSL), onboarding exits with an error suggesting you move the Dockerfile to a dedicated directory.

Expand Down
7 changes: 4 additions & 3 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -311,9 +311,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 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.
Onboarding skips common large directories (`node_modules`, `.git`, `.venv`, and `__pycache__`) while staging this context.
It also skips credential-style files and directories such as `.env*`, `.ssh/`, `.aws/`, `.netrc`, `.npmrc`, `secrets/`, `*.pem`, and `*.key`.
Other build outputs such as `dist/`, `target/`, or `build/` are still included.
If that directory contains a `.dockerignore`, onboarding applies those rules 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.
If the staged context is larger than 100 MB, onboarding prints a warning before the Docker build starts.
If the directory contains unreadable files (for example, Windows system files visible in WSL), onboarding exits with an error suggesting you move the Dockerfile to a dedicated directory.

Expand Down
10 changes: 4 additions & 6 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ const {
const { bestEffortForwardStop } = require("./onboard/forward-cleanup");
const {
CUSTOM_BUILD_CONTEXT_WARN_BYTES,
createCustomBuildContextFilter,
isInsideIgnoredCustomBuildContextPath,
shouldIncludeCustomBuildContextPath,
}: typeof import("./onboard/custom-build-context") = require("./onboard/custom-build-context");
const {
buildCompatibleEndpointSandboxSmokeCommand,
Expand Down Expand Up @@ -3273,10 +3273,8 @@ async function createSandbox(
}
console.log(` Using custom Dockerfile: ${fromResolved}`);
console.log(` Docker build context: ${buildContextDir}`);
const buildContextStats = collectBuildContextStats(
buildContextDir,
shouldIncludeCustomBuildContextPath,
);
const shouldIncludeCustomContextPath = createCustomBuildContextFilter(buildContextDir);
const buildContextStats = collectBuildContextStats(buildContextDir, shouldIncludeCustomContextPath);
if (buildContextStats.totalBytes > CUSTOM_BUILD_CONTEXT_WARN_BYTES) {
const sizeMb = (buildContextStats.totalBytes / 1_000_000).toFixed(1);
console.warn(
Expand All @@ -3299,7 +3297,7 @@ async function createSandbox(
try {
fs.cpSync(buildContextDir, buildCtx, {
recursive: true,
filter: shouldIncludeCustomBuildContextPath,
filter: shouldIncludeCustomContextPath,
});
// If the caller pointed at a file not named "Dockerfile", copy it to the
// location openshell expects (buildCtx/Dockerfile).
Expand Down
125 changes: 125 additions & 0 deletions src/lib/onboard/custom-build-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import path from "node:path";
import fs from "node:fs";

export const CUSTOM_BUILD_CONTEXT_WARN_BYTES = 100_000_000;

Expand All @@ -25,6 +26,16 @@ const CUSTOM_BUILD_CONTEXT_IGNORES = new Set([
"token.json",
]);

type CustomBuildContextFilter = (src: string) => boolean;

type DockerignoreRule = {
pattern: string;
negated: boolean;
directoryOnly: boolean;
hasSlash: boolean;
matcher: RegExp;
};

function isIgnoredCustomBuildContextName(name: string): boolean {
const lowerName = name.toLowerCase();
return (
Expand Down Expand Up @@ -57,3 +68,117 @@ export function isInsideIgnoredCustomBuildContextPath(src: string): boolean {
.filter(Boolean)
.some((part: string) => isIgnoredCustomBuildContextName(part));
}

function normalizeRelativePathForDockerignore(buildContextDir: string, src: string): string {
const relative = path.relative(buildContextDir, src);
if (!relative || relative === "") return "";
return relative.split(path.sep).filter(Boolean).join("/");
}

function escapeRegex(value: string): string {
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
}

function dockerignoreGlobToRegex(pattern: string): RegExp {
let source = "";
for (let index = 0; index < pattern.length; index += 1) {
const char = pattern[index];
if (char === "*") {
if (pattern[index + 1] === "*") {
index += 1;
if (pattern[index + 1] === "/") {
index += 1;
source += "(?:.*/)?";
} else {
source += ".*";
}
} else {
source += "[^/]*";
}
} else if (char === "?") {
source += "[^/]";
} else {
source += escapeRegex(char);
}
}
return new RegExp(`^${source}$`);
}

function parseDockerignoreRule(rawLine: string): DockerignoreRule | null {
const line = rawLine.trim();
if (!line || line === "." || line.startsWith("#")) return null;

const negated = line.startsWith("!");
let pattern = negated ? line.slice(1).trim() : line;
if (!pattern || pattern === ".") return null;

const directoryOnly = pattern.endsWith("/");
pattern = pattern.replace(/^\/+/, "").replace(/\/+$/, "");
if (!pattern) return null;

return {
pattern,
negated,
directoryOnly,
hasSlash: pattern.includes("/"),
matcher: dockerignoreGlobToRegex(pattern),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
}

function readDockerignoreRules(buildContextDir: string): DockerignoreRule[] {
const dockerignorePath = path.join(buildContextDir, ".dockerignore");
if (!fs.existsSync(dockerignorePath)) return [];
const contents = fs.readFileSync(dockerignorePath, "utf-8");
return contents
.split(/\r?\n/)
.map(parseDockerignoreRule)
.filter((rule): rule is DockerignoreRule => rule !== null);
}

function matchesDockerignoreRule(relativePath: string, rule: DockerignoreRule): boolean {
if (!relativePath) return false;

if (!rule.hasSlash) {
const segments = relativePath.split("/");
return segments.some((segment) => rule.matcher.test(segment));
}

if (rule.directoryOnly) {
const parts = relativePath.split("/");
for (let end = 1; end <= parts.length; end += 1) {
if (rule.matcher.test(parts.slice(0, end).join("/"))) return true;
}
return false;
}

return rule.matcher.test(relativePath);
}

function isExcludedByDockerignore(relativePath: string, rules: DockerignoreRule[]): boolean {
let excluded = false;
for (const rule of rules) {
if (matchesDockerignoreRule(relativePath, rule)) {
excluded = !rule.negated;
}
}
return excluded;
}

function isDeniedByCustomBuildContextSafetyFilter(relativePath: string): boolean {
return relativePath
.split("/")
.filter(Boolean)
.some((part) => isIgnoredCustomBuildContextName(part));
}

export function createCustomBuildContextFilter(buildContextDir: string): CustomBuildContextFilter {
const contextRoot = path.resolve(buildContextDir);
const dockerignoreRules = readDockerignoreRules(contextRoot);
return (src: string): boolean => {
const resolved = path.resolve(src);
const relativePath = normalizeRelativePathForDockerignore(contextRoot, resolved);
if (!relativePath) return true;
if (isExcludedByDockerignore(relativePath, dockerignoreRules)) return false;
return !isDeniedByCustomBuildContextSafetyFilter(relativePath);
};
}
Loading
Loading