From 42f27f72d7ceeff7cf4810614ddc4eee3f3a3b6a Mon Sep 17 00:00:00 2001 From: Angel Mata Date: Fri, 5 Jun 2026 13:37:38 -0700 Subject: [PATCH 1/5] perf(onboard): honor .dockerignore for custom --from build contexts Signed-off-by: Angel Mata --- src/lib/onboard.ts | 7 +- src/lib/onboard/custom-build-context.ts | 128 ++++++++++++++++++++++++ test/onboard-custom-dockerfile.test.ts | 123 ++++++++++++++++++++++- 3 files changed, 254 insertions(+), 4 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 46bd4c70e31..46b8cdb4aca 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -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, @@ -3273,9 +3273,10 @@ async function createSandbox( } console.log(` Using custom Dockerfile: ${fromResolved}`); console.log(` Docker build context: ${buildContextDir}`); + const shouldIncludeCustomContextPath = createCustomBuildContextFilter(buildContextDir); const buildContextStats = collectBuildContextStats( buildContextDir, - shouldIncludeCustomBuildContextPath, + shouldIncludeCustomContextPath, ); if (buildContextStats.totalBytes > CUSTOM_BUILD_CONTEXT_WARN_BYTES) { const sizeMb = (buildContextStats.totalBytes / 1_000_000).toFixed(1); @@ -3299,7 +3300,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). diff --git a/src/lib/onboard/custom-build-context.ts b/src/lib/onboard/custom-build-context.ts index 25c5370d77d..6bf31dacc3e 100644 --- a/src/lib/onboard/custom-build-context.ts +++ b/src/lib/onboard/custom-build-context.ts @@ -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; @@ -25,6 +26,17 @@ const CUSTOM_BUILD_CONTEXT_IGNORES = new Set([ "token.json", ]); +type CustomBuildContextFilter = (src: string) => boolean; + +type DockerignoreRule = { + pattern: string; + negated: boolean; + directoryOnly: boolean; + anchored: boolean; + hasSlash: boolean; + matcher: RegExp; +}; + function isIgnoredCustomBuildContextName(name: string): boolean { const lowerName = name.toLowerCase(); return ( @@ -57,3 +69,119 @@ 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("/"); + const anchored = pattern.startsWith("/"); + pattern = pattern.replace(/^\/+/, "").replace(/\/+$/, ""); + if (!pattern) return null; + + return { + pattern, + negated, + directoryOnly, + anchored, + hasSlash: pattern.includes("/"), + matcher: dockerignoreGlobToRegex(pattern), + }; +} + +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 && !rule.anchored) { + 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); + }; +} diff --git a/test/onboard-custom-dockerfile.test.ts b/test/onboard-custom-dockerfile.test.ts index af82a197da5..ac2c2cf6f30 100644 --- a/test/onboard-custom-dockerfile.test.ts +++ b/test/onboard-custom-dockerfile.test.ts @@ -9,6 +9,7 @@ import path from "node:path"; import { describe, it } from "vitest"; +import { createCustomBuildContextFilter } from "../dist/lib/onboard/custom-build-context.js"; import { testTimeoutOptions } from "./helpers/timeouts"; const repoRoot = path.join(import.meta.dirname, ".."); @@ -16,6 +17,96 @@ const onboardScriptMocksPath = JSON.stringify( path.join(repoRoot, "test", "helpers", "onboard-script-mocks.cjs"), ); +describe("custom Dockerfile build context filter", () => { + it("preserves existing behavior when .dockerignore is missing", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-custom-context-filter-")); + try { + const filter = createCustomBuildContextFilter(tmpDir); + + assert.equal(filter(tmpDir), true, "context root should be traversable"); + assert.equal(filter(path.join(tmpDir, "Dockerfile")), true); + assert.equal(filter(path.join(tmpDir, "src", "app.js")), true); + assert.equal(filter(path.join(tmpDir, "node_modules", "pkg", "index.js")), false); + assert.equal(filter(path.join(tmpDir, ".ssh", "id_ed25519")), false); + assert.equal(filter(path.join(tmpDir, "service-account-prod.json")), false); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("applies .dockerignore excludes, comments, blanks, and negation ordering", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-custom-context-filter-")); + try { + fs.writeFileSync( + path.join(tmpDir, ".dockerignore"), + [ + "# ignored comment", + "", + "logs/", + "*.tmp", + "!keep.tmp", + "build/*.cache", + "!build/keep.cache", + ].join("\n"), + ); + + const filter = createCustomBuildContextFilter(tmpDir); + + assert.equal(filter(path.join(tmpDir, "logs")), false); + assert.equal(filter(path.join(tmpDir, "logs", "app.log")), false); + assert.equal(filter(path.join(tmpDir, "nested", "logs", "app.log")), false); + assert.equal(filter(path.join(tmpDir, "drop.tmp")), false); + assert.equal(filter(path.join(tmpDir, "nested", "drop.tmp")), false); + assert.equal(filter(path.join(tmpDir, "keep.tmp")), true); + assert.equal(filter(path.join(tmpDir, "build", "drop.cache")), false); + assert.equal(filter(path.join(tmpDir, "build", "keep.cache")), true); + assert.equal(filter(path.join(tmpDir, "src", "app.js")), true); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("honors rooted patterns separately from same-name nested paths", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-custom-context-filter-")); + try { + fs.writeFileSync( + path.join(tmpDir, ".dockerignore"), + ["/root-only.log", "/root-cache/"].join("\n"), + ); + + const filter = createCustomBuildContextFilter(tmpDir); + + assert.equal(filter(path.join(tmpDir, "root-only.log")), false); + assert.equal(filter(path.join(tmpDir, "nested", "root-only.log")), true); + assert.equal(filter(path.join(tmpDir, "root-cache")), false); + assert.equal(filter(path.join(tmpDir, "root-cache", "data.bin")), false); + assert.equal(filter(path.join(tmpDir, "nested", "root-cache", "data.bin")), true); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("keeps NemoClaw secret exclusions stronger than .dockerignore negation", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-custom-context-filter-")); + try { + fs.writeFileSync( + path.join(tmpDir, ".dockerignore"), + ["*", "!Dockerfile", "!secrets/token.txt", "!.env.local", "!model.pem"].join("\n"), + ); + + const filter = createCustomBuildContextFilter(tmpDir); + + assert.equal(filter(path.join(tmpDir, "Dockerfile")), true); + assert.equal(filter(path.join(tmpDir, "ordinary.txt")), false); + assert.equal(filter(path.join(tmpDir, "secrets", "token.txt")), false); + assert.equal(filter(path.join(tmpDir, ".env.local")), false); + assert.equal(filter(path.join(tmpDir, "model.pem")), false); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + describe("onboard custom Dockerfile", () => { it("uses the custom Dockerfile parent directory as build context when --from is given", testTimeoutOptions(60_000), async () => { const repoRoot = path.join(import.meta.dirname, ".."); @@ -48,6 +139,23 @@ describe("onboard custom Dockerfile", () => { ); fs.writeFileSync(path.join(customBuildDir, "extra.txt"), "extra build context file"); fs.writeFileSync(path.join(customBuildDir, "large.bin"), "small file with large mocked stat"); + fs.mkdirSync(path.join(customBuildDir, "ignored-by-dockerignore"), { recursive: true }); + fs.writeFileSync( + path.join(customBuildDir, "ignored-by-dockerignore", "ignored.txt"), + "skip me via .dockerignore", + ); + fs.writeFileSync(path.join(customBuildDir, "ignored.log"), "skip me via glob"); + fs.writeFileSync(path.join(customBuildDir, "keep.log"), "keep me via negation"); + fs.writeFileSync( + path.join(customBuildDir, ".dockerignore"), + [ + "ignored-by-dockerignore/", + "*.log", + "!keep.log", + // NemoClaw's secret denylist must still win over .dockerignore negation. + "!secrets/token.txt", + ].join("\n"), + ); fs.mkdirSync(path.join(customBuildDir, "node_modules", "pkg"), { recursive: true }); fs.writeFileSync(path.join(customBuildDir, "node_modules", "pkg", "ignored.txt"), "skip me"); fs.mkdirSync(path.join(customBuildDir, ".ssh"), { recursive: true }); @@ -85,6 +193,7 @@ const path = require("node:path"); const commands = []; let hasExtraFileAtSpawn = false; let stagedIgnoredFilesAtSpawn = null; +let stagedDockerignoreFilesAtSpawn = null; const largeFilePath = ${JSON.stringify(path.join(customBuildDir, "large.bin"))}; const originalStatSync = fs.statSync; fs.statSync = (target, ...rest) => { @@ -139,6 +248,12 @@ childProcess.spawn = (...args) => { pem: fs.existsSync(path.join(stagedDir, "model.pem")), credentialsJson: fs.existsSync(path.join(stagedDir, "credentials.json")), }; + stagedDockerignoreFilesAtSpawn = { + ignoredDir: fs.existsSync(path.join(stagedDir, "ignored-by-dockerignore")), + ignoredLog: fs.existsSync(path.join(stagedDir, "ignored.log")), + keepLog: fs.existsSync(path.join(stagedDir, "keep.log")), + negatedSecret: fs.existsSync(path.join(stagedDir, "secrets", "token.txt")), + }; } process.nextTick(() => { child.stdout.emit("data", Buffer.from("Created sandbox: my-assistant\n")); @@ -152,7 +267,7 @@ const { createSandbox } = require(${onboardPath}); (async () => { process.env.OPENSHELL_GATEWAY = "nemoclaw"; const sandboxName = await createSandbox(null, "gpt-5.4", "openai-api", null, "my-assistant", null, null, ${customDockerfilePath}); - console.log(JSON.stringify({ sandboxName, hasExtraFile: hasExtraFileAtSpawn, stagedIgnoredFiles: stagedIgnoredFilesAtSpawn })); + console.log(JSON.stringify({ sandboxName, hasExtraFile: hasExtraFileAtSpawn, stagedIgnoredFiles: stagedIgnoredFilesAtSpawn, stagedDockerignoreFiles: stagedDockerignoreFilesAtSpawn })); })().catch((error) => { console.error(error); process.exit(1); @@ -200,6 +315,12 @@ const { createSandbox } = require(${onboardPath}); pem: false, credentialsJson: false, }); + assert.deepEqual(payload.stagedDockerignoreFiles, { + ignoredDir: false, + ignoredLog: false, + keepLog: true, + negatedSecret: false, + }); }); it("exits with an error when the --from Dockerfile path does not exist", async () => { From f609423224ed3cd675d957909af80d7ff81a27bd Mon Sep 17 00:00:00 2001 From: Angel Mata Date: Fri, 5 Jun 2026 13:54:29 -0700 Subject: [PATCH 2/5] docs(onboard): update documentation to reflect new dockerignore behavior for custom build contexts Signed-off-by: Angel Mata --- docs/deployment/install-openclaw-plugins.mdx | 7 +++++-- docs/manage-sandboxes/install-plugins-hermes.mdx | 7 +++++-- docs/reference/commands-nemohermes.mdx | 7 ++++--- docs/reference/commands.mdx | 7 ++++--- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/docs/deployment/install-openclaw-plugins.mdx b/docs/deployment/install-openclaw-plugins.mdx index 8e7bd525b14..32a7ce03cc2 100644 --- a/docs/deployment/install-openclaw-plugins.mdx +++ b/docs/deployment/install-openclaw-plugins.mdx @@ -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: @@ -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 ` 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/ @@ -78,6 +80,7 @@ These are the most common places where plugin installation gets mixed up with ot - Do not use `nemoclaw 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. diff --git a/docs/manage-sandboxes/install-plugins-hermes.mdx b/docs/manage-sandboxes/install-plugins-hermes.mdx index 6ec095d7979..08ece9dca02 100644 --- a/docs/manage-sandboxes/install-plugins-hermes.mdx +++ b/docs/manage-sandboxes/install-plugins-hermes.mdx @@ -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: @@ -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 ` 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/ @@ -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 diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 95bbc26e764..652b2c83868 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -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. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index d5c3ba8f9b8..6202d7353d8 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -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. From e60c0b93d964f174f8f1d6aa92ea06e545061342 Mon Sep 17 00:00:00 2001 From: Angel Mata Date: Fri, 5 Jun 2026 14:07:22 -0700 Subject: [PATCH 3/5] refactor(onboard): avoid entrypoint growth for dockerignore filter Signed-off-by: Angel Mata --- src/lib/onboard.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 46b8cdb4aca..a612d4102c1 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3274,10 +3274,7 @@ async function createSandbox( console.log(` Using custom Dockerfile: ${fromResolved}`); console.log(` Docker build context: ${buildContextDir}`); const shouldIncludeCustomContextPath = createCustomBuildContextFilter(buildContextDir); - const buildContextStats = collectBuildContextStats( - buildContextDir, - shouldIncludeCustomContextPath, - ); + const buildContextStats = collectBuildContextStats(buildContextDir, shouldIncludeCustomContextPath); if (buildContextStats.totalBytes > CUSTOM_BUILD_CONTEXT_WARN_BYTES) { const sizeMb = (buildContextStats.totalBytes / 1_000_000).toFixed(1); console.warn( From 5d842e6243dc717f83a99ed7766f3d3974afda09 Mon Sep 17 00:00:00 2001 From: Angel Mata Date: Fri, 5 Jun 2026 14:17:48 -0700 Subject: [PATCH 4/5] bug-fix(onboard): address code rabbit suggestion for leading slashes Signed-off-by: Angel Mata --- src/lib/messaging/manifest/__boundary-fs-2313073.ts | 2 ++ src/lib/onboard/custom-build-context.ts | 5 +---- test/onboard-custom-dockerfile.test.ts | 6 +++--- 3 files changed, 6 insertions(+), 7 deletions(-) create mode 100644 src/lib/messaging/manifest/__boundary-fs-2313073.ts diff --git a/src/lib/messaging/manifest/__boundary-fs-2313073.ts b/src/lib/messaging/manifest/__boundary-fs-2313073.ts new file mode 100644 index 00000000000..b14427abb04 --- /dev/null +++ b/src/lib/messaging/manifest/__boundary-fs-2313073.ts @@ -0,0 +1,2 @@ +import { readFileSync } from "node:fs"; +export const value = readFileSync; diff --git a/src/lib/onboard/custom-build-context.ts b/src/lib/onboard/custom-build-context.ts index 6bf31dacc3e..fda81f6ae23 100644 --- a/src/lib/onboard/custom-build-context.ts +++ b/src/lib/onboard/custom-build-context.ts @@ -32,7 +32,6 @@ type DockerignoreRule = { pattern: string; negated: boolean; directoryOnly: boolean; - anchored: boolean; hasSlash: boolean; matcher: RegExp; }; @@ -114,7 +113,6 @@ function parseDockerignoreRule(rawLine: string): DockerignoreRule | null { if (!pattern || pattern === ".") return null; const directoryOnly = pattern.endsWith("/"); - const anchored = pattern.startsWith("/"); pattern = pattern.replace(/^\/+/, "").replace(/\/+$/, ""); if (!pattern) return null; @@ -122,7 +120,6 @@ function parseDockerignoreRule(rawLine: string): DockerignoreRule | null { pattern, negated, directoryOnly, - anchored, hasSlash: pattern.includes("/"), matcher: dockerignoreGlobToRegex(pattern), }; @@ -141,7 +138,7 @@ function readDockerignoreRules(buildContextDir: string): DockerignoreRule[] { function matchesDockerignoreRule(relativePath: string, rule: DockerignoreRule): boolean { if (!relativePath) return false; - if (!rule.hasSlash && !rule.anchored) { + if (!rule.hasSlash) { const segments = relativePath.split("/"); return segments.some((segment) => rule.matcher.test(segment)); } diff --git a/test/onboard-custom-dockerfile.test.ts b/test/onboard-custom-dockerfile.test.ts index ac2c2cf6f30..0c059b82b48 100644 --- a/test/onboard-custom-dockerfile.test.ts +++ b/test/onboard-custom-dockerfile.test.ts @@ -66,7 +66,7 @@ describe("custom Dockerfile build context filter", () => { } }); - it("honors rooted patterns separately from same-name nested paths", () => { + it("treats leading slash patterns like equivalent unrooted dockerignore patterns", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-custom-context-filter-")); try { fs.writeFileSync( @@ -77,10 +77,10 @@ describe("custom Dockerfile build context filter", () => { const filter = createCustomBuildContextFilter(tmpDir); assert.equal(filter(path.join(tmpDir, "root-only.log")), false); - assert.equal(filter(path.join(tmpDir, "nested", "root-only.log")), true); + assert.equal(filter(path.join(tmpDir, "nested", "root-only.log")), false); assert.equal(filter(path.join(tmpDir, "root-cache")), false); assert.equal(filter(path.join(tmpDir, "root-cache", "data.bin")), false); - assert.equal(filter(path.join(tmpDir, "nested", "root-cache", "data.bin")), true); + assert.equal(filter(path.join(tmpDir, "nested", "root-cache", "data.bin")), false); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } From ecd438ab76157440c91e53b6a423c5dd65d817ec Mon Sep 17 00:00:00 2001 From: Angel Mata Date: Fri, 5 Jun 2026 14:32:07 -0700 Subject: [PATCH 5/5] remove eroneous leftover test file Signed-off-by: Angel Mata --- src/lib/messaging/manifest/__boundary-fs-2313073.ts | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 src/lib/messaging/manifest/__boundary-fs-2313073.ts diff --git a/src/lib/messaging/manifest/__boundary-fs-2313073.ts b/src/lib/messaging/manifest/__boundary-fs-2313073.ts deleted file mode 100644 index b14427abb04..00000000000 --- a/src/lib/messaging/manifest/__boundary-fs-2313073.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { readFileSync } from "node:fs"; -export const value = readFileSync;