Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5417,14 +5417,14 @@ async function createSandbox(
// Pull the base image and resolve its digest so the Dockerfile is pinned to
// exactly what we just fetched. This prevents stale :latest tags from
// silently reusing a cached old image after NemoClaw upgrades (#1904).
const resolved = pullAndResolveBaseImageDigest({
const resolved = agent && !fromDockerfile ? null : pullAndResolveBaseImageDigest({
requireOpenshellSandboxAbi: isLinuxDockerDriverGatewayEnabled(),
});
if (resolved?.digest) {
console.log(` Pinning base image to ${resolved.digest.slice(0, 19)}...`);
} else if (resolved) {
console.log(` Using sandbox base image ${resolved.ref}`);
} else {
} else if (!(agent && !fromDockerfile)) {
// Check if the image exists locally before falling back to unpinned :latest.
// On a first-time install behind a firewall with no cached image, warn early
// so the user knows the build will likely fail.
Expand Down
347 changes: 347 additions & 0 deletions test/onboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2086,6 +2086,353 @@ const { createSandbox } = require(${onboardPath});
},
);

it("skips OpenClaw sandbox-base resolution for agent-staged Dockerfiles", async () => {
const repoRoot = path.join(import.meta.dirname, "..");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-agent-base-skip-"));
const fakeBin = path.join(tmpDir, "bin");
const scriptPath = path.join(tmpDir, "agent-base-skip.js");
const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js"));
const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js"));
const registryPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "state", "registry.js"));
const preflightPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard", "preflight.js"));
const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials", "store.js"));
const agentOnboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "agent", "onboard.js"));
const sandboxBaseImagePath = JSON.stringify(
path.join(repoRoot, "dist", "lib", "sandbox-base-image.js"),
);

fs.mkdirSync(fakeBin, { recursive: true });
fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", {
mode: 0o755,
});

const script = String.raw`
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const runner = require(${runnerPath});
const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, "");
const registry = require(${registryPath});
const preflight = require(${preflightPath});
const credentials = require(${credentialsPath});
const agentOnboard = require(${agentOnboardPath});
const sandboxBaseImage = require(${sandboxBaseImagePath});
const childProcess = require("node:child_process");
const { EventEmitter } = require("node:events");

const commands = [];
const logs = [];
const warnings = [];
const baseResolutionCalls = [];
const originalLog = console.log;
const originalWarn = console.warn;
console.log = (...args) => {
logs.push(args.join(" "));
originalLog(...args);
};
console.warn = (...args) => {
warnings.push(args.join(" "));
originalWarn(...args);
};

sandboxBaseImage.resolveSandboxBaseImage = (options) => {
baseResolutionCalls.push(options);
return {
ref: "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
source: "latest",
glibcVersion: "2.39",
};
};

agentOnboard.createAgentSandbox = () => {
const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-agent-build-"));
const stagedDockerfile = path.join(buildCtx, "Dockerfile");
fs.writeFileSync(
stagedDockerfile,
[
"ARG BASE_IMAGE=nemoclaw-hermes-sandbox-base-local:test",
"FROM \${BASE_IMAGE}",
"ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b",
"ARG NEMOCLAW_PROVIDER_KEY=custom",
"ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b",
"ARG CHAT_UI_URL=http://127.0.0.1:8642",
"ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1",
"ARG NEMOCLAW_INFERENCE_API=openai-completions",
"ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=",
"ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10=",
"ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=",
"ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=",
"ARG NEMOCLAW_TELEGRAM_CONFIG_B64=e30=",
"ARG NEMOCLAW_WECHAT_CONFIG_B64=e30=",
"ARG NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=0",
"ARG NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64=W10=",
"ARG NEMOCLAW_BUILD_ID=default",
"ARG NEMOCLAW_DARWIN_VM_COMPAT=0",
"CMD [\"/bin/bash\"]",
].join("\\n"),
);
return { buildCtx, stagedDockerfile };
};

runner.run = (command, opts = {}) => {
commands.push({ command: _n(command), env: opts.env || null });
return { status: 0 };
};
runner.runFile = (file, args = [], opts = {}) => {
commands.push({ command: _n([file, ...args]), env: opts.env || null });
return { status: 0 };
};
runner.runCapture = (command) => {
if (_n(command).includes("sandbox get hermes-sandbox")) return "";
if (_n(command).includes("sandbox list")) return "hermes-sandbox Ready";
{
const sandboxExecCurl = require(${onboardScriptMocksPath}).mockSandboxExecCurl(command);
if (sandboxExecCurl !== null) return sandboxExecCurl;
}
if (_n(command).includes("forward list")) return "hermes-sandbox 127.0.0.1 8642 12345 running";
return "";
};
registry.registerSandbox = () => true;
registry.updateSandbox = () => true;
registry.setDefault = () => true;
registry.removeSandbox = () => true;
registry.getSandbox = () => null;
preflight.checkPortAvailable = async () => ({ ok: true });
credentials.prompt = async () => "";

childProcess.spawn = (...args) => {
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
commands.push({ command: _n(args[1][1]), env: args[2]?.env || null });
process.nextTick(() => {
child.stdout.emit("data", Buffer.from("Created sandbox: hermes-sandbox\\n"));
child.emit("close", 0);
});
return child;
};

const { createSandbox } = require(${onboardPath});

(async () => {
process.env.OPENSHELL_GATEWAY = "nemoclaw";
const agent = {
name: "hermes",
displayName: "Hermes Agent",
forwardPort: 8642,
expectedVersion: "2026.4.23",
policyAdditionsPath: null,
};
await createSandbox(
null,
"gpt-5.4",
"nvidia-prod",
null,
"hermes-sandbox",
null,
[],
null,
agent,
);
console.log(JSON.stringify({ commands, logs, warnings, baseResolutionCalls }));
})().catch((error) => {
console.error(error);
process.exit(1);
});
`;
fs.writeFileSync(scriptPath, script);

const result = spawnSync(process.execPath, [scriptPath], {
cwd: repoRoot,
encoding: "utf-8",
env: {
...stripMessagingEnv(process.env),
HOME: tmpDir,
NEMOCLAW_HOME: path.join(tmpDir, ".nemoclaw"),
PATH: `${fakeBin}:${process.env.PATH || ""}`,
NEMOCLAW_NON_INTERACTIVE: "1",
},
});

assert.equal(result.status, 0, result.stderr);
const payload = parseStdoutJson<{
logs: string[];
warnings: string[];
baseResolutionCalls: unknown[];
}>(result.stdout);
assert.equal(payload.baseResolutionCalls.length, 0);
assert.ok(
!payload.logs.some((line) => line.includes("Using sandbox base image")),
"Hermes agent Dockerfile path should not log OpenClaw sandbox-base usage",
);
assert.ok(
!payload.warnings.some((line) => line.includes("base image")),
"Hermes agent Dockerfile path should not warn about OpenClaw sandbox-base availability",
);
});

it("keeps resolving the OpenClaw sandbox base image on the default Dockerfile path", async () => {
const repoRoot = path.join(import.meta.dirname, "..");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-base-"));
const fakeBin = path.join(tmpDir, "bin");
const scriptPath = path.join(tmpDir, "openclaw-base-resolve.js");
const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js"));
const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js"));
const registryPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "state", "registry.js"));
const preflightPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard", "preflight.js"));
const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials", "store.js"));
const buildContextPath = JSON.stringify(
path.join(repoRoot, "dist", "lib", "sandbox", "build-context.js"),
);
const sandboxBaseImagePath = JSON.stringify(
path.join(repoRoot, "dist", "lib", "sandbox-base-image.js"),
);

fs.mkdirSync(fakeBin, { recursive: true });
fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", {
mode: 0o755,
});

const script = String.raw`
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const runner = require(${runnerPath});
const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, "");
const registry = require(${registryPath});
const preflight = require(${preflightPath});
const credentials = require(${credentialsPath});
const buildContext = require(${buildContextPath});
const sandboxBaseImage = require(${sandboxBaseImagePath});
const childProcess = require("node:child_process");
const { EventEmitter } = require("node:events");

const commands = [];
const logs = [];
const baseResolutionCalls = [];
const originalLog = console.log;
console.log = (...args) => {
logs.push(args.join(" "));
originalLog(...args);
};

sandboxBaseImage.resolveSandboxBaseImage = (options) => {
baseResolutionCalls.push(options);
return {
ref: "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
digest: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
source: "latest",
glibcVersion: "2.39",
};
};
buildContext.stageOptimizedSandboxBuildContext = () => {
const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-build-"));
const stagedDockerfile = path.join(buildCtx, "Dockerfile");
fs.writeFileSync(
stagedDockerfile,
[
"ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base:latest",
"FROM \${BASE_IMAGE}",
"ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b",
"ARG NEMOCLAW_PROVIDER_KEY=nvidia",
"ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b",
"ARG CHAT_UI_URL=http://127.0.0.1:18789",
"ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1",
"ARG NEMOCLAW_INFERENCE_API=openai-completions",
"ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=",
"ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10=",
"ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=",
"ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=",
"ARG NEMOCLAW_TELEGRAM_CONFIG_B64=e30=",
"ARG NEMOCLAW_WECHAT_CONFIG_B64=e30=",
"ARG NEMOCLAW_BUILD_ID=default",
"ARG NEMOCLAW_DARWIN_VM_COMPAT=0",
"CMD [\"/bin/bash\"]",
].join("\\n"),
);
return { buildCtx, stagedDockerfile };
};

runner.run = (command, opts = {}) => {
commands.push({ command: _n(command), env: opts.env || null });
return { status: 0 };
};
runner.runFile = (file, args = [], opts = {}) => {
commands.push({ command: _n([file, ...args]), env: opts.env || null });
return { status: 0 };
};
runner.runCapture = (command) => {
if (_n(command).includes("sandbox get my-assistant")) return "";
if (_n(command).includes("sandbox list")) return "my-assistant Ready";
{
const sandboxExecCurl = require(${onboardScriptMocksPath}).mockSandboxExecCurl(command);
if (sandboxExecCurl !== null) return sandboxExecCurl;
}
if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running";
return "";
};
registry.registerSandbox = () => true;
registry.updateSandbox = () => true;
registry.setDefault = () => true;
registry.removeSandbox = () => true;
registry.getSandbox = () => null;
preflight.checkPortAvailable = async () => ({ ok: true });
credentials.prompt = async () => "";

childProcess.spawn = (...args) => {
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
commands.push({ command: _n(args[1][1]), env: args[2]?.env || null });
process.nextTick(() => {
child.stdout.emit("data", Buffer.from("Created sandbox: my-assistant\\n"));
child.emit("close", 0);
});
return child;
};

const { createSandbox } = require(${onboardPath});

(async () => {
process.env.OPENSHELL_GATEWAY = "nemoclaw";
await createSandbox(null, "gpt-5.4", "nvidia-prod", null, "my-assistant");
console.log(JSON.stringify({ commands, logs, baseResolutionCalls }));
})().catch((error) => {
console.error(error);
process.exit(1);
});
`;
fs.writeFileSync(scriptPath, script);

const result = spawnSync(process.execPath, [scriptPath], {
cwd: repoRoot,
encoding: "utf-8",
env: {
...stripMessagingEnv(process.env),
HOME: tmpDir,
NEMOCLAW_HOME: path.join(tmpDir, ".nemoclaw"),
PATH: `${fakeBin}:${process.env.PATH || ""}`,
NEMOCLAW_NON_INTERACTIVE: "1",
},
});

assert.equal(result.status, 0, result.stderr);
const payload = parseStdoutJson<{
logs: string[];
baseResolutionCalls: Array<{ imageName?: string }>;
}>(result.stdout);
assert.equal(payload.baseResolutionCalls.length, 1);
assert.equal(
payload.baseResolutionCalls[0]?.imageName,
"ghcr.io/nvidia/nemoclaw/sandbox-base",
);
assert.ok(
payload.logs.some((line) => line.includes("Pinning base image to sha256:bbbbbbbbbbbb")),
"default OpenClaw path should still log base-image pinning",
);
});

it("binds the dashboard forward to 0.0.0.0 when CHAT_UI_URL points to a remote host", async () => {
const repoRoot = path.join(import.meta.dirname, "..");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-remote-forward-"));
Expand Down
Loading