Skip to content
48 changes: 39 additions & 9 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -1324,6 +1324,16 @@ function getResumeConfigConflicts(session, opts = {}) {
});
}

const requestedFrom = opts.fromDockerfile ? path.resolve(opts.fromDockerfile) : null;
const recordedFrom = session?.metadata?.fromDockerfile ? path.resolve(session.metadata.fromDockerfile) : null;
if (requestedFrom && recordedFrom && requestedFrom !== recordedFrom) {
conflicts.push({
field: "fromDockerfile",
requested: requestedFrom,
recorded: recordedFrom,
});
}

return conflicts;
}

Expand Down Expand Up @@ -1729,7 +1739,7 @@ async function promptValidatedSandboxName() {
}

// eslint-disable-next-line complexity
async function createSandbox(gpu, model, provider, preferredInferenceApi = null, sandboxNameOverride = null) {
async function createSandbox(gpu, model, provider, preferredInferenceApi = null, sandboxNameOverride = null, fromDockerfile = null) {
step(5, 7, "Creating sandbox");

const sandboxName = sandboxNameOverride || (await promptValidatedSandboxName());
Expand Down Expand Up @@ -1763,10 +1773,27 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null,
// Stage build context
const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-build-"));
const stagedDockerfile = path.join(buildCtx, "Dockerfile");
fs.copyFileSync(path.join(ROOT, "Dockerfile"), stagedDockerfile);
copyBuildContextDir(path.join(ROOT, "nemoclaw"), path.join(buildCtx, "nemoclaw"));
copyBuildContextDir(path.join(ROOT, "nemoclaw-blueprint"), path.join(buildCtx, "nemoclaw-blueprint"));
copyBuildContextDir(path.join(ROOT, "scripts"), path.join(buildCtx, "scripts"));
if (fromDockerfile) {
const fromResolved = path.resolve(fromDockerfile);
if (!fs.existsSync(fromResolved)) {
console.error(` Custom Dockerfile not found: ${fromResolved}`);
process.exit(1);
}
// Copy the entire parent directory as build context. copyBuildContextDir
// already filters out node_modules, .git, .venv, __pycache__, etc.
copyBuildContextDir(path.dirname(fromResolved), buildCtx);
// 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);
}
Comment on lines +1776 to +1789
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Validate that --from points to a regular file.

The new check only guards existence. Passing a directory here falls through to fs.copyFileSync() and throws an uncaught EISDIR, which skips the friendly CLI error path and leaves the temp build context behind.

🛠 Suggested validation
     if (!fs.existsSync(fromResolved)) {
       console.error(`  Custom Dockerfile not found: ${fromResolved}`);
       process.exit(1);
     }
+    if (!fs.statSync(fromResolved).isFile()) {
+      console.error(`  Custom Dockerfile must be a file: ${fromResolved}`);
+      process.exit(1);
+    }
     // Copy the entire parent directory as build context. copyBuildContextDir
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (fromDockerfile) {
const fromResolved = path.resolve(fromDockerfile);
if (!fs.existsSync(fromResolved)) {
console.error(` Custom Dockerfile not found: ${fromResolved}`);
process.exit(1);
}
// Copy the entire parent directory as build context. copyBuildContextDir
// already filters out node_modules, .git, .venv, __pycache__, etc.
copyBuildContextDir(path.dirname(fromResolved), buildCtx);
// 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);
}
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 must be a file: ${fromResolved}`);
process.exit(1);
}
// Copy the entire parent directory as build context. copyBuildContextDir
// already filters out node_modules, .git, .venv, __pycache__, etc.
copyBuildContextDir(path.dirname(fromResolved), buildCtx);
// 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);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bin/lib/onboard.js` around lines 1776 - 1789, The current check only verifies
existence so passing a directory for --from leads to an uncaught EISDIR in
fs.copyFileSync; update the fromDockerfile handling (the fromResolved branch
used with copyBuildContextDir, stagedDockerfile and fs.copyFileSync) to verify
that fromResolved is a regular file (use fs.statSync or fs.lstatSync and
isFile()) before attempting to copy; if it is not a file, print a clear error
like "Custom Dockerfile is not a file: <path>" and exit(1) so the friendly CLI
error path runs and temp build context is cleaned up.

console.log(` Using custom Dockerfile: ${fromResolved}`);
} else {
fs.copyFileSync(path.join(ROOT, "Dockerfile"), stagedDockerfile);
copyBuildContextDir(path.join(ROOT, "nemoclaw"), path.join(buildCtx, "nemoclaw"));
copyBuildContextDir(path.join(ROOT, "nemoclaw-blueprint"), path.join(buildCtx, "nemoclaw-blueprint"));
copyBuildContextDir(path.join(ROOT, "scripts"), path.join(buildCtx, "scripts"));
}

// Create sandbox (use -- echo to avoid dropping into interactive shell)
// Pass the base policy so sandbox starts in proxy mode (required for policy updates later)
Expand Down Expand Up @@ -2777,8 +2804,11 @@ async function onboard(opts = {}) {
NON_INTERACTIVE = opts.nonInteractive || process.env.NEMOCLAW_NON_INTERACTIVE === "1";
delete process.env.OPENSHELL_GATEWAY;
const resume = opts.resume === true;
// In non-interactive mode also accept the env var so CI pipelines can set it.
const fromDockerfile =
opts.fromDockerfile || (isNonInteractive() ? (process.env.NEMOCLAW_FROM_DOCKERFILE || null) : null);
const lockResult = onboardSession.acquireOnboardLock(
`nemoclaw onboard${resume ? " --resume" : ""}${isNonInteractive() ? " --non-interactive" : ""}`
`nemoclaw onboard${resume ? " --resume" : ""}${isNonInteractive() ? " --non-interactive" : ""}${fromDockerfile ? ` --from ${fromDockerfile}` : ""}`
);
if (!lockResult.acquired) {
console.error(" Another NemoClaw onboarding run is already in progress.");
Expand Down Expand Up @@ -2810,7 +2840,7 @@ async function onboard(opts = {}) {
console.error(" Run: nemoclaw onboard");
process.exit(1);
}
const resumeConflicts = getResumeConfigConflicts(session, { nonInteractive: isNonInteractive() });
const resumeConflicts = getResumeConfigConflicts(session, { nonInteractive: isNonInteractive(), fromDockerfile });
if (resumeConflicts.length > 0) {
for (const conflict of resumeConflicts) {
if (conflict.field === "sandbox") {
Expand Down Expand Up @@ -2838,7 +2868,7 @@ async function onboard(opts = {}) {
session = onboardSession.saveSession(
onboardSession.createSession({
mode: isNonInteractive() ? "non-interactive" : "interactive",
metadata: { gatewayName: "nemoclaw" },
metadata: { gatewayName: "nemoclaw", fromDockerfile: fromDockerfile || null },
})
);
}
Expand Down Expand Up @@ -2973,7 +3003,7 @@ async function onboard(opts = {}) {
}
sandboxName = sandboxName || (await promptValidatedSandboxName());
startRecordedStep("sandbox", { sandboxName, provider, model });
sandboxName = await createSandbox(gpu, model, provider, preferredInferenceApi, sandboxName);
sandboxName = await createSandbox(gpu, model, provider, preferredInferenceApi, sandboxName, fromDockerfile);
onboardSession.markStepComplete("sandbox", { sandboxName, provider, model, nimContainer });
}

Expand Down
20 changes: 18 additions & 2 deletions bin/nemoclaw.js
Original file line number Diff line number Diff line change
Expand Up @@ -334,16 +334,31 @@ function exitWithSpawnResult(result) {

async function onboard(args) {
const { onboard: runOnboard } = require("./lib/onboard");

// Extract --from <path> before the unknown-arg validator: it takes a value
// so the set-based check would reject the value token as an unknown flag.
let fromDockerfile = null;
const fromIdx = args.indexOf("--from");
if (fromIdx !== -1) {
fromDockerfile = args[fromIdx + 1];
if (!fromDockerfile || fromDockerfile.startsWith("--")) {
console.error(" --from requires a path to a Dockerfile");
console.error(" Usage: nemoclaw onboard [--non-interactive] [--resume] [--from <Dockerfile>]");
process.exit(1);
}
args = [...args.slice(0, fromIdx), ...args.slice(fromIdx + 2)];
}

const allowedArgs = new Set(["--non-interactive", "--resume"]);
const unknownArgs = args.filter((arg) => !allowedArgs.has(arg));
if (unknownArgs.length > 0) {
console.error(` Unknown onboard option(s): ${unknownArgs.join(", ")}`);
console.error(" Usage: nemoclaw onboard [--non-interactive] [--resume]");
console.error(" Usage: nemoclaw onboard [--non-interactive] [--resume] [--from <Dockerfile>]");
process.exit(1);
}
const nonInteractive = args.includes("--non-interactive");
const resume = args.includes("--resume");
await runOnboard({ nonInteractive, resume });
await runOnboard({ nonInteractive, resume, fromDockerfile });
}

async function setup() {
Expand Down Expand Up @@ -716,6 +731,7 @@ function help() {

${G}Getting Started:${R}
${B}nemoclaw onboard${R} Configure inference endpoint and credentials
nemoclaw onboard ${D}--from <Dockerfile>${R} Use a custom Dockerfile for the sandbox image
nemoclaw setup-spark Set up on DGX Spark ${D}(fixes cgroup v2 + Docker)${R}

${G}Sandbox Management:${R}
Expand Down
22 changes: 21 additions & 1 deletion docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ The wizard creates an OpenShell gateway, registers inference providers, builds t
Use this command for new installs and for recreating a sandbox after changes to policy or configuration.

```console
$ nemoclaw onboard
$ nemoclaw onboard [--non-interactive] [--resume] [--from <Dockerfile>]
```

The wizard prompts for a provider first, then collects the provider credential if needed.
Expand All @@ -55,6 +55,26 @@ Uppercase letters are automatically lowercased.
Before creating the gateway, the wizard runs preflight checks.
On systems with cgroup v2 (Ubuntu 24.04, DGX Spark, WSL2), it verifies that Docker is configured with `"default-cgroupns-mode": "host"` and provides fix instructions if the setting is missing.

#### `--from <Dockerfile>`

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.

```console
$ nemoclaw onboard --from path/to/Dockerfile
```

The Dockerfile must be named `Dockerfile`.
All NemoClaw build arguments (`NEMOCLAW_MODEL`, `NEMOCLAW_PROVIDER_KEY`, `NEMOCLAW_INFERENCE_BASE_URL`, etc.) are injected as `ARG` overrides at build time, so declare them in your Dockerfile if you need to reference them.

In non-interactive mode, the path can also be supplied via the `NEMOCLAW_FROM_DOCKERFILE` environment variable:

```console
$ NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_FROM_DOCKERFILE=path/to/Dockerfile nemoclaw onboard
```

If a `--resume` is attempted with a different `--from` path than the original session, onboarding exits with a conflict error rather than silently building from the wrong image.

### `nemoclaw list`

List all registered sandboxes with their model, provider, and policy presets.
Expand Down
18 changes: 17 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading