Skip to content

Commit 3082b7c

Browse files
committed
fix(onboard): document custom Dockerfile build context
Fixes #2541 Signed-off-by: Deepak Jain <deepujain@gmail.com>
1 parent 0b49851 commit 3082b7c

5 files changed

Lines changed: 106 additions & 65 deletions

File tree

docs/reference/commands.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,13 +157,23 @@ If the installed OpenShell version falls outside this range, onboarding exits wi
157157

158158
Build the sandbox image from a custom Dockerfile instead of the stock NemoClaw image.
159159
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.
160+
Onboarding skips common large directories (`node_modules`, `.git`, `.venv`, and `__pycache__`) while staging this context, but other build outputs such as `dist/`, `target/`, or `build/` are still included.
161+
If the staged context is larger than 100 MB, onboarding prints a warning before the Docker build starts.
160162
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.
161163

162164
```console
163165
$ nemoclaw onboard --from path/to/Dockerfile
164166
```
165167

166168
The file can have any name; if it is not already named `Dockerfile`, onboard copies it to `Dockerfile` inside the staged build context automatically.
169+
For the safest workflow, create a dedicated directory that contains only the Dockerfile and the files it needs:
170+
171+
```text
172+
build-dir/
173+
├── Dockerfile
174+
└── files-used-by-COPY/
175+
```
176+
167177
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.
168178

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

src/lib/onboard-command.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ describe("onboard command", () => {
105105
expect(runOnboard).not.toHaveBeenCalled();
106106
expect(lines.join("\n")).toContain("Usage: nemoclaw onboard");
107107
expect(lines.join("\n")).toContain("--from <Dockerfile>");
108+
expect(lines.join("\n")).toContain("Dockerfile's parent directory");
109+
expect(lines.join("\n")).toContain("node_modules, .git, .venv, __pycache__");
108110
expect(lines.join("\n")).toContain("--agent <name>");
109111
expect(lines.join("\n")).toContain("--dangerously-skip-permissions");
110112
});

src/lib/onboard-command.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ function onboardUsageLines(noticeAcceptFlag: string): string[] {
4040
return [
4141
` Usage: nemoclaw onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--from <Dockerfile>] [--agent <name>] [--dangerously-skip-permissions] [${noticeAcceptFlag}]`,
4242
"",
43+
" --from <Dockerfile> uses the Dockerfile's parent directory as the Docker build context.",
44+
" Put files referenced by COPY/ADD next to that Dockerfile, or move the Dockerfile into",
45+
" a dedicated build directory to avoid sending unrelated files to Docker.",
46+
" Common large directories are skipped: node_modules, .git, .venv, __pycache__.",
47+
"",
4348
];
4449
}
4550

src/lib/onboard.ts

Lines changed: 80 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ function requireValue<T>(value: T | null | undefined, message: string): T {
4949
}
5050
return value;
5151
}
52-
const { stageOptimizedSandboxBuildContext } = require("./sandbox-build-context");
52+
const {
53+
collectBuildContextStats,
54+
stageOptimizedSandboxBuildContext,
55+
} = require("./sandbox-build-context");
5356
const { buildSubprocessEnv } = require("./subprocess-env");
5457
const {
5558
DASHBOARD_PORT,
@@ -75,6 +78,8 @@ const { DEFAULT_CLOUD_MODEL, getProviderSelectionConfig, parseGatewayInference }
7578

7679
const onboardProviders = require("./onboard-providers");
7780

81+
const CUSTOM_BUILD_CONTEXT_WARN_BYTES = 100 * 1024 * 1024;
82+
7883
type RemoteProviderConfigEntry = {
7984
label: string;
8085
providerName: string;
@@ -112,8 +117,16 @@ const {
112117
getEffectiveProviderName: (key: string | null | undefined) => string | null;
113118
getNonInteractiveProvider: () => string | null;
114119
getNonInteractiveModel: (providerKey: string) => string | null;
115-
getSandboxInferenceConfig: (model: string, provider?: string | null, preferredInferenceApi?: string | null) => {
116-
providerKey: string; primaryModelRef: string; inferenceBaseUrl: string; inferenceApi: string; inferenceCompat: LooseObject | null;
120+
getSandboxInferenceConfig: (
121+
model: string,
122+
provider?: string | null,
123+
preferredInferenceApi?: string | null,
124+
) => {
125+
providerKey: string;
126+
primaryModelRef: string;
127+
inferenceBaseUrl: string;
128+
inferenceApi: string;
129+
inferenceCompat: LooseObject | null;
117130
};
118131
};
119132
const { sleepSeconds } = require("./wait");
@@ -253,7 +266,11 @@ const BRAVE_SEARCH_HELP_URL = "https://brave.com/search/api/";
253266

254267
// Re-export shared JSON types under the names used throughout this module.
255268
// See src/lib/json-types.ts for the canonical definitions.
256-
import type { JsonScalar as LooseScalar, JsonValue as LooseValue, JsonObject as LooseObject } from "./json-types";
269+
import type {
270+
JsonScalar as LooseScalar,
271+
JsonValue as LooseValue,
272+
JsonObject as LooseObject,
273+
} from "./json-types";
257274

258275
type OnboardOptions = {
259276
nonInteractive?: boolean;
@@ -837,7 +854,13 @@ async function promptValidationRecovery(
837854

838855
// Provider CRUD — thin wrappers that inject runOpenshell to avoid circular deps.
839856
const { buildProviderArgs } = onboardProviders;
840-
function upsertProvider(name: string, type: string, credentialEnv: string, baseUrl: string | null, env: NodeJS.ProcessEnv = {}) {
857+
function upsertProvider(
858+
name: string,
859+
type: string,
860+
credentialEnv: string,
861+
baseUrl: string | null,
862+
env: NodeJS.ProcessEnv = {},
863+
) {
841864
return onboardProviders.upsertProvider(name, type, credentialEnv, baseUrl, env, runOpenshell);
842865
}
843866

@@ -1460,7 +1483,6 @@ const {
14601483
// nvcfFunctionNotFoundMessage — see validation import above. They live in
14611484
// src/lib/validation.ts so they can be unit-tested independently.
14621485

1463-
14641486
async function validateOpenAiLikeSelection(
14651487
label: string,
14661488
endpointUrl: string,
@@ -1641,7 +1663,6 @@ function getRequestedProviderHint(nonInteractive = isNonInteractive()) {
16411663
}
16421664
function getRequestedModelHint(nonInteractive = isNonInteractive()) {
16431665
return onboardProviders.getRequestedModelHint(nonInteractive);
1644-
16451666
}
16461667

16471668
function getResumeConfigConflicts(
@@ -2091,25 +2112,19 @@ async function preflight(): Promise<ReturnType<typeof nim.detectGpu>> {
20912112
console.warn(
20922113
" ⚠ Container DNS probe inconclusive: docker couldn't pull the busybox test image.",
20932114
);
2094-
console.warn(
2095-
" This usually means the docker daemon itself can't reach Docker Hub,",
2096-
);
2115+
console.warn(" This usually means the docker daemon itself can't reach Docker Hub,");
20972116
console.warn(
20982117
" but doesn't prove container DNS is broken — the sandbox build may still succeed.",
20992118
);
21002119
} else {
2101-
console.warn(
2102-
` ⚠ Container DNS probe inconclusive (reason: ${dns.reason ?? "unknown"}).`,
2103-
);
2120+
console.warn(` ⚠ Container DNS probe inconclusive (reason: ${dns.reason ?? "unknown"}).`);
21042121
}
21052122
if (dns.details) {
21062123
for (const line of String(dns.details).split("\n").slice(-3)) {
21072124
if (line.trim()) console.warn(` ${line.trim()}`);
21082125
}
21092126
}
2110-
console.warn(
2111-
" Proceeding. If the sandbox build later hangs at `npm ci`, see issue #2101.",
2112-
);
2127+
console.warn(" Proceeding. If the sandbox build later hangs at `npm ci`, see issue #2101.");
21132128
} else {
21142129
console.error(" ✗ DNS resolution from inside a docker container failed.");
21152130
if (dns.details) {
@@ -2119,18 +2134,10 @@ async function preflight(): Promise<ReturnType<typeof nim.detectGpu>> {
21192134
}
21202135
console.error("");
21212136
{
2122-
console.error(
2123-
" The sandbox build runs `npm ci` inside a container and needs to resolve",
2124-
);
2125-
console.error(
2126-
" registry.npmjs.org. On networks that block outbound UDP:53 to public DNS",
2127-
);
2128-
console.error(
2129-
" (common in corporate environments that force DNS-over-TLS on the host),",
2130-
);
2131-
console.error(
2132-
" the build appears to hang for ~15 minutes and then prints the cryptic",
2133-
);
2137+
console.error(" The sandbox build runs `npm ci` inside a container and needs to resolve");
2138+
console.error(" registry.npmjs.org. On networks that block outbound UDP:53 to public DNS");
2139+
console.error(" (common in corporate environments that force DNS-over-TLS on the host),");
2140+
console.error(" the build appears to hang for ~15 minutes and then prints the cryptic");
21342141
console.error(" `npm error Exit handler never called`. See issue #2101.");
21352142
console.error("");
21362143
console.error(" Fix options:");
@@ -2179,9 +2186,7 @@ async function preflight(): Promise<ReturnType<typeof nim.detectGpu>> {
21792186
console.error(" 1. Make systemd-resolved reachable from containers (recommended):");
21802187
printLinuxFix(bridgeIp, bridgeNote);
21812188
console.error("");
2182-
console.error(
2183-
" 2. Configure an explicit UDP:53-capable DNS in /etc/docker/daemon.json",
2184-
);
2189+
console.error(" 2. Configure an explicit UDP:53-capable DNS in /etc/docker/daemon.json");
21852190
console.error(" (ask your IT team for an internal DNS server IP).");
21862191
} else if (host.platform === "darwin") {
21872192
// On macOS, branch by the detected runtime (host.runtime) so users get
@@ -2190,9 +2195,7 @@ async function preflight(): Promise<ReturnType<typeof nim.detectGpu>> {
21902195
console.error(" Configure Colima's DNS (macOS):");
21912196
console.error(" colima stop");
21922197
console.error(" colima start --dns <corp-dns-ip>");
2193-
console.error(
2194-
" (or edit ~/.colima/default/colima.yaml and `colima restart`)",
2195-
);
2198+
console.error(" (or edit ~/.colima/default/colima.yaml and `colima restart`)");
21962199
} else if (host.runtime === "docker-desktop" || host.runtime === "docker") {
21972200
console.error(" Configure Docker Desktop's DNS (macOS):");
21982201
console.error(
@@ -2210,21 +2213,17 @@ async function preflight(): Promise<ReturnType<typeof nim.detectGpu>> {
22102213
console.error(" Configure your container runtime's DNS (macOS):");
22112214
console.error(" - Docker Desktop:");
22122215
console.error(
2213-
" { jq '. + {\"dns\":[\"<corp-dns-ip>\"]}' ~/.docker/daemon.json 2>/dev/null || echo '{\"dns\":[\"<corp-dns-ip>\"]}'; } > ~/.docker/daemon.json.new && mv ~/.docker/daemon.json.new ~/.docker/daemon.json",
2216+
' { jq \'. + {"dns":["<corp-dns-ip>"]}\' ~/.docker/daemon.json 2>/dev/null || echo \'{"dns":["<corp-dns-ip>"]}\'; } > ~/.docker/daemon.json.new && mv ~/.docker/daemon.json.new ~/.docker/daemon.json',
22142217
);
22152218
console.error(" osascript -e 'quit app \"Docker\"' && sleep 3 && open -a Docker");
22162219
console.error(" - Colima:");
22172220
console.error(" colima stop && colima start --dns <corp-dns-ip>");
22182221
console.error(" - Rancher Desktop / Podman: edit the runtime's DNS config");
22192222
console.error(" and restart it.");
22202223
}
2221-
console.error(
2222-
" Ask your IT team for an internal DNS server IP that accepts UDP:53.",
2223-
);
2224+
console.error(" Ask your IT team for an internal DNS server IP that accepts UDP:53.");
22242225
} else if (host.platform === "win32" || host.isWsl) {
2225-
console.error(
2226-
" 1. Configure Docker Desktop's DNS (Windows / WSL via Docker Desktop):",
2227-
);
2226+
console.error(" 1. Configure Docker Desktop's DNS (Windows / WSL via Docker Desktop):");
22282227
console.error(
22292228
" Docker Desktop for Windows → Settings → Docker Engine — edit the JSON to add:",
22302229
);
@@ -2249,9 +2248,7 @@ async function preflight(): Promise<ReturnType<typeof nim.detectGpu>> {
22492248
}
22502249
printLinuxFix(wslBridgeIp || "172.17.0.1", wslBridgeNote);
22512250
} else {
2252-
console.error(
2253-
" Configure your docker daemon to use a DNS server that accepts UDP:53.",
2254-
);
2251+
console.error(" Configure your docker daemon to use a DNS server that accepts UDP:53.");
22552252
console.error(
22562253
' Add { "dns": ["<corp-dns-ip>"] } to your docker daemon.json and restart the daemon.',
22572254
);
@@ -3424,6 +3421,17 @@ async function createSandbox(
34243421
fs.copyFileSync(fromResolved, stagedDockerfile);
34253422
}
34263423
console.log(` Using custom Dockerfile: ${fromResolved}`);
3424+
console.log(` Docker build context: ${path.dirname(fromResolved)}`);
3425+
const buildContextStats = collectBuildContextStats(buildCtx);
3426+
if (buildContextStats.totalBytes > CUSTOM_BUILD_CONTEXT_WARN_BYTES) {
3427+
const sizeMb = Math.round(buildContextStats.totalBytes / (1024 * 1024));
3428+
console.warn(
3429+
` WARN: build context contains about ${sizeMb} MB across ${buildContextStats.fileCount} files.`,
3430+
);
3431+
console.warn(
3432+
" The --from flag sends the Dockerfile's parent directory to Docker; use a dedicated directory if this is not intentional.",
3433+
);
3434+
}
34273435
} else if (agent) {
34283436
const agentBuild = agentOnboard.createAgentSandbox(agent);
34293437
buildCtx = agentBuild.buildCtx;
@@ -3757,7 +3765,14 @@ async function createSandbox(
37573765
const openshellBin = getOpenshellBinary();
37583766
for (let i = 0; i < 15; i++) {
37593767
const readyMatch = runCaptureOpenshell(
3760-
["sandbox", "exec", sandboxName, "curl", "-sf", `http://localhost:${effectiveDashboardPort}/`],
3768+
[
3769+
"sandbox",
3770+
"exec",
3771+
sandboxName,
3772+
"curl",
3773+
"-sf",
3774+
`http://localhost:${effectiveDashboardPort}/`,
3775+
],
37613776
{ ignoreError: true },
37623777
);
37633778
if (readyMatch) {
@@ -5044,7 +5059,9 @@ async function setupMessagingChannels(): Promise<string[]> {
50445059
console.log(` ${ch.help}`);
50455060
const token = normalizeCredentialValue(await prompt(` ${ch.label}: `, { secret: true }));
50465061
if (token && ch.tokenFormat && !ch.tokenFormat.test(token)) {
5047-
console.log(` ✗ Invalid format. ${ch.tokenFormatHint || "Check the token and try again."}`);
5062+
console.log(
5063+
` ✗ Invalid format. ${ch.tokenFormatHint || "Check the token and try again."}`,
5064+
);
50485065
console.log(` Skipped ${ch.name} (invalid token format)`);
50495066
enabled.delete(ch.name);
50505067
continue;
@@ -5898,7 +5915,9 @@ async function setupPoliciesWithSelection(
58985915
// the sandbox with no presets. Warn, optionally suggest the intended
58995916
// variable, and fall through to the tier-derived suggestions list.
59005917
console.warn(` Unsupported NEMOCLAW_POLICY_MODE: ${policyMode}`);
5901-
console.warn(" Valid values: suggested, custom, skip (aliases: default/auto, list, none/no).");
5918+
console.warn(
5919+
" Valid values: suggested, custom, skip (aliases: default/auto, list, none/no).",
5920+
);
59025921
if (tiers.getTier(policyMode)) {
59035922
console.warn(
59045923
` '${policyMode}' is a policy tier — did you mean NEMOCLAW_POLICY_TIER=${policyMode}?`,
@@ -6228,10 +6247,12 @@ function getWslHostAddress(
62286247
}
62296248
const runCaptureFn = options.runCapture || runCapture;
62306249
const output = runCaptureFn(["hostname", "-I"], { ignoreError: true });
6231-
return String(output || "")
6232-
.trim()
6233-
.split(/\s+/)
6234-
.filter(Boolean)[0] || null;
6250+
return (
6251+
String(output || "")
6252+
.trim()
6253+
.split(/\s+/)
6254+
.filter(Boolean)[0] || null
6255+
);
62356256
}
62366257

62376258
function getDashboardAccessInfo(
@@ -6316,17 +6337,21 @@ function printDashboard(
63166337
const chain = buildChain({ chatUiUrl, isWsl: isWsl(), wslHostAddress: wslAddr });
63176338

63186339
// Build access info inline — uses chain instead of re-deriving from env
6319-
const dashboardAccess = buildControlUiUrls(token, chain.port, chain.accessUrl).map(
6320-
(url, i) => ({ label: i === 0 ? "Dashboard" : `Alt ${i}`, url }),
6321-
);
6340+
const dashboardAccess = buildControlUiUrls(token, chain.port, chain.accessUrl).map((url, i) => ({
6341+
label: i === 0 ? "Dashboard" : `Alt ${i}`,
6342+
url,
6343+
}));
63226344
if (wslAddr) {
63236345
const wslUrl = `http://${wslAddr}:${chain.port}/${token ? `#token=${encodeURIComponent(token)}` : ""}`;
63246346
const existing = dashboardAccess.find((a) => a.url === wslUrl);
63256347
if (existing) existing.label = "VS Code/WSL";
63266348
else dashboardAccess.push({ label: "VS Code/WSL", url: wslUrl });
63276349
}
63286350
const guidanceLines = [`Port ${chain.port} must be forwarded before opening these URLs.`];
6329-
if (isWsl()) guidanceLines.push("WSL detected: if localhost fails in Windows, use the WSL host IP shown by `hostname -I`.");
6351+
if (isWsl())
6352+
guidanceLines.push(
6353+
"WSL detected: if localhost fails in Windows, use the WSL host IP shown by `hostname -I`.",
6354+
);
63306355
if (dashboardAccess.length === 0) guidanceLines.push("No dashboard URLs were generated.");
63316356

63326357
console.log("");

test/onboard.test.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -593,7 +593,9 @@ describe("onboard helpers", () => {
593593
// Forward target via buildChain replaces resolveDashboardForwardTarget
594594
expect(buildChain({ chatUiUrl: "http://127.0.0.1:18789" }).forwardTarget).toBe("18789");
595595
expect(buildChain({ chatUiUrl: "http://[::1]:18789" }).forwardTarget).toBe("18789");
596-
expect(buildChain({ chatUiUrl: "https://chat.example.com:18789" }).forwardTarget).toBe("0.0.0.0:18789");
596+
expect(buildChain({ chatUiUrl: "https://chat.example.com:18789" }).forwardTarget).toBe(
597+
"0.0.0.0:18789",
598+
);
597599
expect(buildChain({ chatUiUrl: "http://10.0.0.25:18789" }).forwardTarget).toBe("0.0.0.0:18789");
598600
});
599601

@@ -5102,9 +5104,7 @@ const { setupMessagingChannels } = require(${onboardPath});
51025104
const scriptPath = path.join(tmpDir, "slack-format-reject.js");
51035105
const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js"));
51045106
const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js"));
5105-
const credentialsPath = JSON.stringify(
5106-
path.join(repoRoot, "dist", "lib", "credentials.js"),
5107-
);
5107+
const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials.js"));
51085108

51095109
fs.mkdirSync(fakeBin, { recursive: true });
51105110
fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", {
@@ -5215,9 +5215,7 @@ const { setupMessagingChannels, MESSAGING_CHANNELS } = require(${onboardPath});
52155215
const scriptPath = path.join(tmpDir, "slack-app-format-reject.js");
52165216
const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js"));
52175217
const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js"));
5218-
const credentialsPath = JSON.stringify(
5219-
path.join(repoRoot, "dist", "lib", "credentials.js"),
5220-
);
5218+
const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials.js"));
52215219

52225220
fs.mkdirSync(fakeBin, { recursive: true });
52235221
fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", {
@@ -5277,9 +5275,7 @@ const { setupMessagingChannels, MESSAGING_CHANNELS } = require(${onboardPath});
52775275
input: "\n",
52785276
});
52795277
assert.equal(introspect.status, 0, introspect.stderr);
5280-
const slackIdx = JSON.parse(
5281-
introspect.stdout.trim().split("\n").pop()!,
5282-
).slackIndex1Based;
5278+
const slackIdx = JSON.parse(introspect.stdout.trim().split("\n").pop()!).slackIndex1Based;
52835279
assert.ok(slackIdx >= 1, `unexpected slack index: ${slackIdx}`);
52845280

52855281
// Real run: toggle Slack on, exit UI, bot prompt returns valid, app
@@ -5528,6 +5524,9 @@ const { createSandbox } = require(${onboardPath});
55285524
assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`);
55295525
const payload = JSON.parse(payloadLine);
55305526
assert.equal(payload.sandboxName, "my-assistant");
5527+
assert.match(result.stdout, /Using custom Dockerfile:/);
5528+
assert.match(result.stdout, /Docker build context:/);
5529+
assert.match(result.stdout, /custom-image/);
55315530
assert.equal(
55325531
payload.hasExtraFile,
55335532
true,

0 commit comments

Comments
 (0)