Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
106 changes: 12 additions & 94 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -188,10 +182,6 @@ type RunnerOptions = {
openshellBinary?: string;
};

const {
collectBuildContextStats,
stageOptimizedSandboxBuildContext,
} = require("./sandbox/build-context");
const { buildSubprocessEnv } = require("./subprocess-env");
const {
DASHBOARD_PORT,
Expand Down Expand Up @@ -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(
Expand Down
216 changes: 216 additions & 0 deletions src/lib/onboard/build-context-stage.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading