diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index a6af9f63a1..a454b63fdf 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -28,15 +28,11 @@ const { }: typeof import("./onboard/non-interactive-abort") = require("./onboard/non-interactive-abort"); const { stopStaleDashboardListenersForSandbox } = require("./onboard/stale-gateway-cleanup"); const extraPlaceholderKeysModule: typeof import("./onboard/extra-placeholder-keys") = require("./onboard/extra-placeholder-keys"); +const buildContextStage: typeof import("./onboard/build-context-stage") = require("./onboard/build-context-stage"); const { ensureOllamaLoopbackSystemdOverride, }: typeof import("./onboard/ollama-systemd") = require("./onboard/ollama-systemd"); const { bestEffortForwardStop } = require("./onboard/forward-cleanup"); -const { - CUSTOM_BUILD_CONTEXT_WARN_BYTES, - createCustomBuildContextFilter, - isInsideIgnoredCustomBuildContextPath, -}: typeof import("./onboard/custom-build-context") = require("./onboard/custom-build-context"); const { buildCompatibleEndpointSandboxSmokeCommand, buildCompatibleEndpointSandboxSmokeScript, @@ -172,8 +168,6 @@ const { getStableGatewayImageRef, pullAndResolveBaseImageDigest, }: typeof import("./onboard/base-image") = require("./onboard/base-image"); -const errnoUtils: typeof import("./core/errno") = require("./core/errno"); -const { isErrnoException } = errnoUtils; const { requireValue }: typeof import("./core/require-value") = require("./core/require-value"); const { logMissingNvidiaApiKeyHelp, @@ -188,10 +182,6 @@ type RunnerOptions = { openshellBinary?: string; }; -const { - collectBuildContextStats, - stageOptimizedSandboxBuildContext, -} = require("./sandbox/build-context"); const { buildSubprocessEnv } = require("./subprocess-env"); const { DASHBOARD_PORT, @@ -2945,93 +2935,21 @@ async function createSandbox( // in env args, so it must not persist in /tmp after a failed sandbox create. // run() calls process.exit() on failure (bypassing normal control flow), so // we register a process 'exit' handler to guarantee cleanup in all cases. - let buildCtx: string, stagedDockerfile: string; - if (fromDockerfile) { - const fromResolved = path.resolve(fromDockerfile); - if (!fs.existsSync(fromResolved)) { - console.error(` Custom Dockerfile not found: ${fromResolved}`); - process.exit(1); - } - if (!fs.statSync(fromResolved).isFile()) { - console.error(` Custom Dockerfile path is not a file: ${fromResolved}`); - process.exit(1); - } - const buildContextDir = path.dirname(fromResolved); - if (isInsideIgnoredCustomBuildContextPath(buildContextDir)) { - console.error( - ` Custom Dockerfile is inside an ignored build-context path: ${buildContextDir}`, - ); - console.error(" Move your Dockerfile to a dedicated directory and retry."); - process.exit(1); - } - console.log(` Using custom Dockerfile: ${fromResolved}`); - console.log(` Docker build context: ${buildContextDir}`); - 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( - ` WARN: build context contains about ${sizeMb} MB across ${buildContextStats.fileCount} files.`, - ); - console.warn( - " The --from flag sends the Dockerfile's parent directory to Docker; use a dedicated directory if this is not intentional.", - ); - } - buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-build-")); - stagedDockerfile = path.join(buildCtx, "Dockerfile"); - const cleanupCustomBuildCtx = (): void => { - try { - fs.rmSync(buildCtx, { recursive: true, force: true }); - } catch { - // Best effort cleanup; the original error is more useful to the caller. - } - }; - // Copy the entire parent directory as build context. - try { - fs.cpSync(buildContextDir, buildCtx, { - recursive: true, - filter: shouldIncludeCustomContextPath, - }); - // If the caller pointed at a file not named "Dockerfile", copy it to the - // location openshell expects (buildCtx/Dockerfile). - if (path.basename(fromResolved) !== "Dockerfile") { - fs.copyFileSync(fromResolved, stagedDockerfile); - } - } catch (err) { - cleanupCustomBuildCtx(); - const errorObject = typeof err === "object" && err !== null ? err : null; - if (isErrnoException(errorObject) && errorObject.code === "EACCES") { - console.error(` Permission denied while copying build context from: ${buildContextDir}`); - console.error( - " The --from flag uses the Dockerfile's parent directory as the Docker build context.", - ); - console.error(" Move your Dockerfile to a dedicated directory and retry."); - process.exit(1); - } - throw err; - } - } else if (agent) { - const agentBuild = agentOnboard.createAgentSandbox(agent); - buildCtx = agentBuild.buildCtx; - stagedDockerfile = agentBuild.stagedDockerfile; - } else { - ({ buildCtx, stagedDockerfile } = stageOptimizedSandboxBuildContext(ROOT)); - } + const { buildCtx, stagedDockerfile, cleanupBuildCtx } = + buildContextStage.stageCreateSandboxBuildContext({ + root: ROOT, + fromDockerfile, + agent, + createAgentSandbox: agentOnboard.createAgentSandbox, + log: console.log, + warn: console.warn, + error: console.error, + exit: process.exit, + }); // Returns true if the build context was fully removed, false otherwise. // The caller uses this to decide whether the process 'exit' safety net // can be deregistered — if inline cleanup fails, we leave the handler // armed so the temp dir is still removed on process exit. - const cleanupBuildCtx = (): boolean => { - try { - fs.rmSync(buildCtx, { recursive: true, force: true }); - return true; - } catch { - return false; - } - }; process.on("exit", cleanupBuildCtx); const defaultPolicyPath = path.join( diff --git a/src/lib/onboard/build-context-stage.test.ts b/src/lib/onboard/build-context-stage.test.ts new file mode 100644 index 0000000000..25e782a9af --- /dev/null +++ b/src/lib/onboard/build-context-stage.test.ts @@ -0,0 +1,216 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { CUSTOM_BUILD_CONTEXT_WARN_BYTES } from "../../../dist/lib/onboard/custom-build-context"; +import { stageCreateSandboxBuildContext } from "../../../dist/lib/onboard/build-context-stage"; + +const tmpDirs: string[] = []; + +function makeTmpDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tmpDirs.push(dir); + return dir; +} + +function throwingExit(code?: number): never { + throw new Error(`exit ${code ?? 0}`); +} + +describe("stageCreateSandboxBuildContext", () => { + afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tmpDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("stages a custom Dockerfile context, filters ignored entries, and returns cleanup", () => { + const buildContextDir = makeTmpDir("nemoclaw-custom-context-"); + const customDockerfile = path.join(buildContextDir, "Containerfile"); + fs.writeFileSync(customDockerfile, "FROM scratch\n"); + fs.writeFileSync(path.join(buildContextDir, "extra.txt"), "included\n"); + fs.mkdirSync(path.join(buildContextDir, ".ssh")); + fs.writeFileSync(path.join(buildContextDir, ".ssh", "id_rsa"), "secret\n"); + const logs: string[] = []; + + const result = stageCreateSandboxBuildContext({ + root: "/unused", + fromDockerfile: customDockerfile, + agent: null, + createAgentSandbox: vi.fn(), + log: (message) => logs.push(message), + exit: throwingExit, + }); + tmpDirs.push(result.buildCtx); + + expect(logs).toEqual([ + ` Using custom Dockerfile: ${customDockerfile}`, + ` Docker build context: ${buildContextDir}`, + ]); + expect(fs.readFileSync(result.stagedDockerfile, "utf-8")).toBe("FROM scratch\n"); + expect(fs.existsSync(path.join(result.buildCtx, "extra.txt"))).toBe(true); + expect(fs.existsSync(path.join(result.buildCtx, ".ssh"))).toBe(false); + expect(result.cleanupBuildCtx()).toBe(true); + expect(fs.existsSync(result.buildCtx)).toBe(false); + }); + + it("exits when the custom Dockerfile path is missing", () => { + const errors: string[] = []; + const missingDockerfile = path.join(makeTmpDir("nemoclaw-missing-context-"), "Dockerfile"); + + expect(() => + stageCreateSandboxBuildContext({ + root: "/unused", + fromDockerfile: missingDockerfile, + agent: null, + createAgentSandbox: vi.fn(), + error: (message) => errors.push(message), + exit: throwingExit, + }), + ).toThrow("exit 1"); + + expect(errors).toEqual([` Custom Dockerfile not found: ${missingDockerfile}`]); + }); + + it("exits when the custom Dockerfile path is a directory", () => { + const errors: string[] = []; + const dockerfileDir = path.join(makeTmpDir("nemoclaw-dir-context-"), "Dockerfile"); + fs.mkdirSync(dockerfileDir); + + expect(() => + stageCreateSandboxBuildContext({ + root: "/unused", + fromDockerfile: dockerfileDir, + agent: null, + createAgentSandbox: vi.fn(), + error: (message) => errors.push(message), + exit: throwingExit, + }), + ).toThrow("exit 1"); + + expect(errors).toEqual([` Custom Dockerfile path is not a file: ${dockerfileDir}`]); + }); + + it("exits when the custom Dockerfile is inside an ignored build-context path", () => { + const errors: string[] = []; + const ignoredContextDir = path.join(makeTmpDir("nemoclaw-ignored-context-"), ".ssh"); + fs.mkdirSync(ignoredContextDir); + const ignoredDockerfile = path.join(ignoredContextDir, "Dockerfile"); + fs.writeFileSync(ignoredDockerfile, "FROM scratch\n"); + + expect(() => + stageCreateSandboxBuildContext({ + root: "/unused", + fromDockerfile: ignoredDockerfile, + agent: null, + createAgentSandbox: vi.fn(), + error: (message) => errors.push(message), + exit: throwingExit, + }), + ).toThrow("exit 1"); + + expect(errors).toEqual([ + ` Custom Dockerfile is inside an ignored build-context path: ${ignoredContextDir}`, + " Move your Dockerfile to a dedicated directory and retry.", + ]); + }); + + it("warns when the custom Dockerfile build context is large", () => { + const buildContextDir = makeTmpDir("nemoclaw-large-context-"); + const dockerfile = path.join(buildContextDir, "Dockerfile"); + const largeFile = path.join(buildContextDir, "large.bin"); + fs.writeFileSync(dockerfile, "FROM scratch\n"); + fs.closeSync(fs.openSync(largeFile, "w")); + fs.truncateSync(largeFile, CUSTOM_BUILD_CONTEXT_WARN_BYTES + 1); + const warnings: string[] = []; + + const result = stageCreateSandboxBuildContext({ + root: "/unused", + fromDockerfile: dockerfile, + agent: null, + createAgentSandbox: vi.fn(), + log: vi.fn(), + warn: (message) => warnings.push(message), + exit: throwingExit, + }); + tmpDirs.push(result.buildCtx); + + expect(warnings).toEqual([ + " WARN: build context contains about 100.0 MB across 2 files.", + " The --from flag sends the Dockerfile's parent directory to Docker; use a dedicated directory if this is not intentional.", + ]); + }); + + it("cleans up the temporary build context when copying fails with EACCES", () => { + const buildContextDir = makeTmpDir("nemoclaw-eacces-context-"); + const dockerfile = path.join(buildContextDir, "Dockerfile"); + fs.writeFileSync(dockerfile, "FROM scratch\n"); + const stagedBuildCtx = makeTmpDir("nemoclaw-staged-eacces-"); + const errors: string[] = []; + vi.spyOn(fs, "mkdtempSync").mockReturnValueOnce(stagedBuildCtx); + vi.spyOn(fs, "cpSync").mockImplementationOnce(() => { + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + }); + + expect(() => + stageCreateSandboxBuildContext({ + root: "/unused", + fromDockerfile: dockerfile, + agent: null, + createAgentSandbox: vi.fn(), + log: vi.fn(), + error: (message) => errors.push(message), + exit: throwingExit, + }), + ).toThrow("exit 1"); + + expect(errors).toEqual([ + ` Permission denied while copying build context from: ${buildContextDir}`, + " The --from flag uses the Dockerfile's parent directory as the Docker build context.", + " Move your Dockerfile to a dedicated directory and retry.", + ]); + expect(fs.existsSync(stagedBuildCtx)).toBe(false); + }); + + it("delegates to agent or default build-context staging when no custom Dockerfile is supplied", () => { + const agentBuild = { + buildCtx: makeTmpDir("nemoclaw-agent-build-"), + stagedDockerfile: path.join(os.tmpdir(), "agent.Dockerfile"), + }; + const defaultBuild = { + buildCtx: makeTmpDir("nemoclaw-default-build-"), + stagedDockerfile: path.join(os.tmpdir(), "default.Dockerfile"), + }; + const createAgentSandbox = vi.fn(() => agentBuild); + const stageDefaultSandboxBuildContext = vi.fn(() => defaultBuild); + + const agentResult = stageCreateSandboxBuildContext({ + root: "/repo", + fromDockerfile: null, + agent: { name: "hermes" } as any, + createAgentSandbox, + stageDefaultSandboxBuildContext, + }); + + expect(agentResult.buildCtx).toBe(agentBuild.buildCtx); + expect(createAgentSandbox).toHaveBeenCalledWith({ name: "hermes" }); + expect(stageDefaultSandboxBuildContext).not.toHaveBeenCalled(); + + const defaultResult = stageCreateSandboxBuildContext({ + root: "/repo", + fromDockerfile: null, + agent: null, + createAgentSandbox, + stageDefaultSandboxBuildContext, + }); + + expect(defaultResult.buildCtx).toBe(defaultBuild.buildCtx); + expect(stageDefaultSandboxBuildContext).toHaveBeenCalledWith("/repo"); + }); +}); diff --git a/src/lib/onboard/build-context-stage.ts b/src/lib/onboard/build-context-stage.ts new file mode 100644 index 0000000000..3a250d5aa8 --- /dev/null +++ b/src/lib/onboard/build-context-stage.ts @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import type { AgentDefinition } from "../agent/defs"; +import { isErrnoException } from "../core/errno"; +import { + collectBuildContextStats, + type StagedBuildContext, + stageOptimizedSandboxBuildContext, +} from "../sandbox/build-context"; +import { + createCustomBuildContextFilter, + CUSTOM_BUILD_CONTEXT_WARN_BYTES, + isInsideIgnoredCustomBuildContextPath, +} from "./custom-build-context"; + +export interface CreateSandboxBuildContextInput { + root: string; + fromDockerfile: string | null; + agent: AgentDefinition | null | undefined; + createAgentSandbox(agent: AgentDefinition): StagedBuildContext; + log?(message: string): void; + warn?(message: string): void; + error?(message: string): void; + exit?(code?: number): never; + stageDefaultSandboxBuildContext?(rootDir: string): StagedBuildContext; +} + +export interface CreateSandboxBuildContextResult extends StagedBuildContext { + cleanupBuildCtx(): boolean; +} + +function createCleanupBuildContext(buildCtx: string): () => boolean { + return () => { + try { + fs.rmSync(buildCtx, { recursive: true, force: true }); + return true; + } catch { + return false; + } + }; +} + +export function stageCreateSandboxBuildContext( + input: CreateSandboxBuildContextInput, +): CreateSandboxBuildContextResult { + const log = input.log ?? console.log; + const warn = input.warn ?? console.warn; + const error = input.error ?? console.error; + const exit = input.exit ?? ((code?: number): never => process.exit(code)); + + let build: StagedBuildContext; + + if (input.fromDockerfile) { + const fromResolved = path.resolve(input.fromDockerfile); + if (!fs.existsSync(fromResolved)) { + error(` Custom Dockerfile not found: ${fromResolved}`); + exit(1); + } + if (!fs.statSync(fromResolved).isFile()) { + error(` Custom Dockerfile path is not a file: ${fromResolved}`); + exit(1); + } + const buildContextDir = path.dirname(fromResolved); + if (isInsideIgnoredCustomBuildContextPath(buildContextDir)) { + error(` Custom Dockerfile is inside an ignored build-context path: ${buildContextDir}`); + error(" Move your Dockerfile to a dedicated directory and retry."); + exit(1); + } + log(` Using custom Dockerfile: ${fromResolved}`); + log(` Docker build context: ${buildContextDir}`); + 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); + warn( + ` WARN: build context contains about ${sizeMb} MB across ${buildContextStats.fileCount} files.`, + ); + warn( + " The --from flag sends the Dockerfile's parent directory to Docker; use a dedicated directory if this is not intentional.", + ); + } + const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-build-")); + const stagedDockerfile = path.join(buildCtx, "Dockerfile"); + const cleanupCustomBuildCtx = (): void => { + try { + fs.rmSync(buildCtx, { recursive: true, force: true }); + } catch { + // Best effort cleanup; the original error is more useful to the caller. + } + }; + try { + fs.cpSync(buildContextDir, buildCtx, { + recursive: true, + filter: shouldIncludeCustomContextPath, + }); + if (path.basename(fromResolved) !== "Dockerfile") { + fs.copyFileSync(fromResolved, stagedDockerfile); + } + } catch (err) { + cleanupCustomBuildCtx(); + const errorObject = typeof err === "object" && err !== null ? err : null; + if (isErrnoException(errorObject) && errorObject.code === "EACCES") { + error(` Permission denied while copying build context from: ${buildContextDir}`); + error( + " The --from flag uses the Dockerfile's parent directory as the Docker build context.", + ); + error(" Move your Dockerfile to a dedicated directory and retry."); + exit(1); + } + throw err; + } + build = { buildCtx, stagedDockerfile }; + } else if (input.agent) { + build = input.createAgentSandbox(input.agent); + } else { + build = (input.stageDefaultSandboxBuildContext ?? stageOptimizedSandboxBuildContext)( + input.root, + ); + } + + return { + ...build, + cleanupBuildCtx: createCleanupBuildContext(build.buildCtx), + }; +}