diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index bc231df3a59..1770892c801 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -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. diff --git a/test/onboard.test.ts b/test/onboard.test.ts index e05ecf72ca8..78f57e5f1c1 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -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-"));