diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index dfc1a921d..ac9c7079f 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -55,6 +55,10 @@ const { } = require("../dist/lib/openshell"); const { listSandboxesCommand, showStatusCommand } = require("../dist/lib/inventory-commands"); const { executeDeploy } = require("../dist/lib/deploy"); +const { + runDeprecatedOnboardAliasCommand, + runOnboardCommand, +} = require("../dist/lib/onboard-command"); const { runStartCommand, runStopCommand } = require("../dist/lib/services-command"); const { buildVersionedUninstallUrl, @@ -761,65 +765,45 @@ function exitWithSpawnResult(result) { async function onboard(args) { const { onboard: runOnboard } = require("./lib/onboard"); - - // Extract --from 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] [--recreate-sandbox] [--from ] [${NOTICE_ACCEPT_FLAG}]`, - ); - process.exit(1); - } - args = [...args.slice(0, fromIdx), ...args.slice(fromIdx + 2)]; - } - - const allowedArgs = new Set([ - "--non-interactive", - "--resume", - "--recreate-sandbox", - NOTICE_ACCEPT_FLAG, - ]); - 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] [--recreate-sandbox] [--from ] [${NOTICE_ACCEPT_FLAG}]`, - ); - process.exit(1); - } - const nonInteractive = args.includes("--non-interactive"); - const resume = args.includes("--resume"); - const recreateSandbox = args.includes("--recreate-sandbox"); - const acceptThirdPartySoftware = - args.includes(NOTICE_ACCEPT_FLAG) || String(process.env[NOTICE_ACCEPT_ENV] || "") === "1"; - await runOnboard({ - nonInteractive, - resume, - recreateSandbox, - fromDockerfile, - acceptThirdPartySoftware, + await runOnboardCommand({ + args, + noticeAcceptFlag: NOTICE_ACCEPT_FLAG, + noticeAcceptEnv: NOTICE_ACCEPT_ENV, + env: process.env, + runOnboard, + error: console.error, + exit: (code) => process.exit(code), }); } async function setup(args = []) { - console.log(""); - console.log(" ⚠ `nemoclaw setup` is deprecated. Use `nemoclaw onboard` instead."); - console.log(""); - await onboard(args); + const { onboard: runOnboard } = require("./lib/onboard"); + await runDeprecatedOnboardAliasCommand({ + kind: "setup", + args, + noticeAcceptFlag: NOTICE_ACCEPT_FLAG, + noticeAcceptEnv: NOTICE_ACCEPT_ENV, + env: process.env, + runOnboard, + log: console.log, + error: console.error, + exit: (code) => process.exit(code), + }); } async function setupSpark(args = []) { - console.log(""); - console.log(" ⚠ `nemoclaw setup-spark` is deprecated."); - console.log(" Current OpenShell releases handle the old DGX Spark cgroup issue themselves."); - console.log(" Use `nemoclaw onboard` instead."); - console.log(""); - await onboard(args); + const { onboard: runOnboard } = require("./lib/onboard"); + await runDeprecatedOnboardAliasCommand({ + kind: "setup-spark", + args, + noticeAcceptFlag: NOTICE_ACCEPT_FLAG, + noticeAcceptEnv: NOTICE_ACCEPT_ENV, + env: process.env, + runOnboard, + log: console.log, + error: console.error, + exit: (code) => process.exit(code), + }); } async function deploy(instanceName) { diff --git a/src/lib/onboard-command.test.ts b/src/lib/onboard-command.test.ts new file mode 100644 index 000000000..f2b752606 --- /dev/null +++ b/src/lib/onboard-command.test.ts @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + parseOnboardArgs, + runDeprecatedOnboardAliasCommand, + runOnboardCommand, +} from "../../dist/lib/onboard-command"; + +describe("onboard command", () => { + it("parses onboard flags", () => { + expect( + parseOnboardArgs( + ["--non-interactive", "--resume", "--yes-i-accept-third-party-software"], + "--yes-i-accept-third-party-software", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + { env: {}, error: () => {}, exit: ((code: number) => { throw new Error(String(code)); }) as never }, + ), + ).toEqual({ + nonInteractive: true, + resume: true, + recreateSandbox: false, + fromDockerfile: null, + acceptThirdPartySoftware: true, + }); + }); + + it("accepts the env-based third-party notice acknowledgement", () => { + expect( + parseOnboardArgs( + [], + "--yes-i-accept-third-party-software", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + { + env: { NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" }, + error: () => {}, + exit: ((code: number) => { + throw new Error(String(code)); + }) as never, + }, + ), + ).toEqual({ + nonInteractive: false, + resume: false, + recreateSandbox: false, + fromDockerfile: null, + acceptThirdPartySoftware: true, + }); + }); + + it("runs onboard with parsed options", async () => { + const runOnboard = vi.fn(async () => {}); + await runOnboardCommand({ + args: ["--resume"], + noticeAcceptFlag: "--yes-i-accept-third-party-software", + noticeAcceptEnv: "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + env: {}, + runOnboard, + error: () => {}, + exit: ((code: number) => { + throw new Error(String(code)); + }) as never, + }); + expect(runOnboard).toHaveBeenCalledWith({ + nonInteractive: false, + resume: true, + recreateSandbox: false, + fromDockerfile: null, + acceptThirdPartySoftware: false, + }); + }); + + it("prints usage and skips onboarding for --help", async () => { + const runOnboard = vi.fn(async () => {}); + const lines: string[] = []; + await runOnboardCommand({ + args: ["--help"], + noticeAcceptFlag: "--yes-i-accept-third-party-software", + noticeAcceptEnv: "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + env: {}, + runOnboard, + log: (message = "") => lines.push(message), + error: () => {}, + exit: ((code: number) => { + throw new Error(String(code)); + }) as never, + }); + expect(runOnboard).not.toHaveBeenCalled(); + expect(lines.join("\n")).toContain("Usage: nemoclaw onboard"); + expect(lines.join("\n")).toContain("--from "); + }); + + it("parses --from ", () => { + expect( + parseOnboardArgs( + ["--resume", "--from", "/tmp/Custom.Dockerfile"], + "--yes-i-accept-third-party-software", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + { + env: {}, + error: () => {}, + exit: ((code: number) => { + throw new Error(String(code)); + }) as never, + }, + ), + ).toEqual({ + nonInteractive: false, + resume: true, + recreateSandbox: false, + fromDockerfile: "/tmp/Custom.Dockerfile", + acceptThirdPartySoftware: false, + }); + }); + + it("exits when --from is missing its Dockerfile path", () => { + expect(() => + parseOnboardArgs( + ["--from"], + "--yes-i-accept-third-party-software", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + { + env: {}, + error: () => {}, + exit: ((code: number) => { + throw new Error(`exit:${code}`); + }) as never, + }, + ), + ).toThrow("exit:1"); + }); + + it("prints the setup-spark deprecation text before delegating", async () => { + const lines: string[] = []; + const runOnboard = vi.fn(async () => {}); + await runDeprecatedOnboardAliasCommand({ + kind: "setup-spark", + args: [], + noticeAcceptFlag: "--yes-i-accept-third-party-software", + noticeAcceptEnv: "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + env: {}, + runOnboard, + log: (message = "") => lines.push(message), + error: () => {}, + exit: ((code: number) => { + throw new Error(String(code)); + }) as never, + }); + expect(lines.join("\n")).toContain("setup-spark` is deprecated"); + expect(lines.join("\n")).toContain("Use `nemoclaw onboard` instead"); + }); +}); diff --git a/src/lib/onboard-command.ts b/src/lib/onboard-command.ts new file mode 100644 index 000000000..bd2d29e73 --- /dev/null +++ b/src/lib/onboard-command.ts @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface OnboardCommandOptions { + nonInteractive: boolean; + resume: boolean; + recreateSandbox: boolean; + fromDockerfile: string | null; + acceptThirdPartySoftware: boolean; +} + +export interface RunOnboardCommandDeps { + args: string[]; + noticeAcceptFlag: string; + noticeAcceptEnv: string; + env: NodeJS.ProcessEnv; + runOnboard: (options: OnboardCommandOptions) => Promise; + log?: (message?: string) => void; + error?: (message?: string) => void; + exit?: (code: number) => never; +} + +const ONBOARD_BASE_ARGS = ["--non-interactive", "--resume", "--recreate-sandbox"]; + +function onboardUsageLines(noticeAcceptFlag: string): string[] { + return [ + ` Usage: nemoclaw onboard [--non-interactive] [--resume] [--recreate-sandbox] [--from ] [${noticeAcceptFlag}]`, + "", + " Options:", + " --non-interactive Run without prompts", + " --resume Resume a saved onboarding session", + " --recreate-sandbox Destroy and recreate the sandbox", + " --from Build the sandbox image from a Dockerfile", + ` ${noticeAcceptFlag} Accept the third-party software notice for non-interactive runs`, + ]; +} + +function printOnboardUsage(writer: (message?: string) => void, noticeAcceptFlag: string): void { + for (const line of onboardUsageLines(noticeAcceptFlag)) { + writer(line); + } +} + +export function parseOnboardArgs( + args: string[], + noticeAcceptFlag: string, + noticeAcceptEnv: string, + deps: Pick, +): OnboardCommandOptions { + const error = deps.error ?? console.error; + const exit = deps.exit ?? ((code: number) => process.exit(code)); + let fromDockerfile: string | null = null; + const fromIdx = args.indexOf("--from"); + if (fromIdx !== -1) { + fromDockerfile = args[fromIdx + 1] || null; + if (!fromDockerfile || fromDockerfile.startsWith("--")) { + error(" --from requires a path to a Dockerfile"); + printOnboardUsage(error, noticeAcceptFlag); + exit(1); + } + args = [...args.slice(0, fromIdx), ...args.slice(fromIdx + 2)]; + } + + const allowedArgs = new Set([...ONBOARD_BASE_ARGS, noticeAcceptFlag]); + const unknownArgs = args.filter((arg) => !allowedArgs.has(arg)); + if (unknownArgs.length > 0) { + error(` Unknown onboard option(s): ${unknownArgs.join(", ")}`); + printOnboardUsage(error, noticeAcceptFlag); + exit(1); + } + + return { + nonInteractive: args.includes("--non-interactive"), + resume: args.includes("--resume"), + recreateSandbox: args.includes("--recreate-sandbox"), + fromDockerfile, + acceptThirdPartySoftware: + args.includes(noticeAcceptFlag) || String(deps.env[noticeAcceptEnv] || "") === "1", + }; +} + +export async function runOnboardCommand(deps: RunOnboardCommandDeps): Promise { + const log = deps.log ?? console.log; + if (deps.args.includes("--help") || deps.args.includes("-h")) { + printOnboardUsage(log, deps.noticeAcceptFlag); + return; + } + + const options = parseOnboardArgs(deps.args, deps.noticeAcceptFlag, deps.noticeAcceptEnv, deps); + await deps.runOnboard(options); +} + +export interface RunAliasCommandDeps extends RunOnboardCommandDeps { + kind: "setup" | "setup-spark"; +} + +export async function runDeprecatedOnboardAliasCommand( + deps: RunAliasCommandDeps, +): Promise { + const log = deps.log ?? console.log; + log(""); + if (deps.kind === "setup") { + log(" ⚠ `nemoclaw setup` is deprecated. Use `nemoclaw onboard` instead."); + } else { + log(" ⚠ `nemoclaw setup-spark` is deprecated."); + log(" Current OpenShell releases handle the old DGX Spark cgroup issue themselves."); + log(" Use `nemoclaw onboard` instead."); + } + log(""); + await runOnboardCommand(deps); +} diff --git a/test/cli.test.js b/test/cli.test.js index 7dab74e10..7e6e91400 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -116,6 +116,13 @@ describe("CLI dispatch", () => { expect(r.out).toContain("NemoClaw Services"); }); + it("onboard --help exits 0 and shows usage", () => { + const r = run("onboard --help"); + expect(r.code).toBe(0); + expect(r.out.includes("Usage: nemoclaw onboard")).toBeTruthy(); + expect(r.out.includes("--from ")).toBeTruthy(); + }); + it("unknown onboard option exits 1", () => { const r = run("onboard --non-interactiv"); expect(r.code).toBe(1); @@ -134,6 +141,14 @@ describe("CLI dispatch", () => { expect(r.out.includes("Unknown onboard option(s): --non-interactiv")).toBeTruthy(); }); + it("setup --help exits 0 and shows onboard usage", () => { + const r = run("setup --help"); + expect(r.code).toBe(0); + expect(r.out.includes("setup` is deprecated")).toBeTruthy(); + expect(r.out.includes("Usage: nemoclaw onboard")).toBeTruthy(); + expect(r.out.includes("Unknown onboard option")).toBeFalsy(); + }); + it("setup forwards unknown options into onboard parsing", () => { const r = run("setup --non-interactiv"); expect(r.code).toBe(1); @@ -148,6 +163,15 @@ describe("CLI dispatch", () => { expect(r.out.includes("No resumable onboarding session was found")).toBeTruthy(); }); + it("setup-spark --help exits 0 and shows onboard usage", () => { + const r = run("setup-spark --help"); + expect(r.code).toBe(0); + expect(r.out.includes("setup-spark` is deprecated")).toBeTruthy(); + expect(r.out.includes("Use `nemoclaw onboard` instead")).toBeTruthy(); + expect(r.out.includes("Usage: nemoclaw onboard")).toBeTruthy(); + expect(r.out.includes("Unknown onboard option")).toBeFalsy(); + }); + it("setup-spark is a deprecated compatibility alias for onboard", () => { const r = run("setup-spark --resume --non-interactive --yes-i-accept-third-party-software"); expect(r.code).toBe(1); diff --git a/test/nemoclaw-cli-recovery.test.js b/test/nemoclaw-cli-recovery.test.js index 89904a13f..7586e7e8c 100644 --- a/test/nemoclaw-cli-recovery.test.js +++ b/test/nemoclaw-cli-recovery.test.js @@ -101,7 +101,10 @@ process.exit(0); ...process.env, HOME: tmpDir, PATH: "/usr/bin:/bin", + NODE_OPTIONS: "", + NODE_V8_COVERAGE: "", }, + timeout: 30000, }, ); diff --git a/test/onboard.test.js b/test/onboard.test.js index f4e2bf16a..62ef0b987 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import fs from "node:fs"; +import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -46,6 +47,14 @@ import { import { stageOptimizedSandboxBuildContext } from "../bin/lib/sandbox-build-context"; import { buildWebSearchDockerConfig } from "../dist/lib/web-search"; +const cjsRequire = createRequire(import.meta.url); + +function loadFreshOnboardModule() { + const onboardPath = cjsRequire.resolve("../bin/lib/onboard"); + delete cjsRequire.cache[onboardPath]; + return cjsRequire("../bin/lib/onboard"); +} + describe("onboard helpers", () => { it("classifies sandbox create timeout failures and tracks upload progress", () => { expect( @@ -1753,198 +1762,200 @@ console.log(JSON.stringify({ liveExists, sandbox: registry.getSandbox("my-assist assert.equal(payload.sandbox, null); }); - it("builds the sandbox without uploading an external OpenClaw config file", async () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-create-sandbox-")); - const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "create-sandbox-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); - const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); - const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); - const preflightPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "preflight.js")); - const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.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 runner = require(${runnerPath}); -const registry = require(${registryPath}); -const preflight = require(${preflightPath}); -const credentials = require(${credentialsPath}); -const childProcess = require("node:child_process"); -const { EventEmitter } = require("node:events"); - -const commands = []; -runner.run = (command, opts = {}) => { - commands.push({ command, env: opts.env || null }); - return { status: 0 }; -}; -runner.runCapture = (command) => { - if (command.includes("'sandbox' 'get' 'my-assistant'")) return ""; - if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; - if (command.includes("sandbox exec 'my-assistant' curl -sf http://localhost:18789/")) return "ok"; - if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; - return ""; -}; -registry.registerSandbox = () => true; -registry.removeSandbox = () => true; -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: 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"; - const sandboxName = await createSandbox(null, "gpt-5.4"); - console.log(JSON.stringify({ sandboxName, commands })); -})().catch((error) => { - console.error(error); - process.exit(1); -}); -`; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - PATH: `${fakeBin}:${process.env.PATH || ""}`, - NEMOCLAW_NON_INTERACTIVE: "1", - }, - }); - - assert.equal(result.status, 0, result.stderr); - const payloadLine = result.stdout - .trim() - .split("\n") - .slice() - .reverse() - .find((line) => line.startsWith("{") && line.endsWith("}")); - assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); - const payload = JSON.parse(payloadLine); - assert.equal(payload.sandboxName, "my-assistant"); - const createCommand = payload.commands.find((entry) => - entry.command.includes("'sandbox' 'create'"), - ); - assert.ok(createCommand, "expected sandbox create command"); - assert.match(createCommand.command, /'nemoclaw-start'/); - assert.doesNotMatch(createCommand.command, /'--upload'/); - assert.doesNotMatch(createCommand.command, /OPENCLAW_CONFIG_PATH/); - assert.doesNotMatch(createCommand.command, /NVIDIA_API_KEY=/); - assert.doesNotMatch(createCommand.command, /DISCORD_BOT_TOKEN=/); - assert.doesNotMatch(createCommand.command, /SLACK_BOT_TOKEN=/); - assert.ok( - payload.commands.some((entry) => - entry.command.includes("'forward' 'start' '--background' '18789' 'my-assistant'"), - ), - "expected default loopback dashboard forward", - ); - }); - - 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-")); - const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "create-sandbox-remote-forward.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); - const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); - const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); - const preflightPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "preflight.js")); - const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.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 runner = require(${runnerPath}); -const registry = require(${registryPath}); -const preflight = require(${preflightPath}); -const credentials = require(${credentialsPath}); -const childProcess = require("node:child_process"); -const { EventEmitter } = require("node:events"); - -const commands = []; -runner.run = (command, opts = {}) => { - commands.push({ command, env: opts.env || null }); - return { status: 0 }; -}; -runner.runCapture = (command) => { - if (command.includes("'sandbox' 'get' 'my-assistant'")) return ""; - if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; - if (command.includes("sandbox exec 'my-assistant' curl -sf http://localhost:18789/")) return "ok"; - if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; - return ""; -}; -registry.registerSandbox = () => true; -registry.removeSandbox = () => true; -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: 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}); + it( + "builds the sandbox without uploading an external OpenClaw config file", + { timeout: 45000 }, + async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-create-sandbox-")); + const fakeBin = path.join(tmpDir, "bin"); + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); -(async () => { - process.env.OPENSHELL_GATEWAY = "nemoclaw"; - process.env.CHAT_UI_URL = "https://chat.example.com"; - await createSandbox(null, "gpt-5.4"); - console.log(JSON.stringify(commands)); -})().catch((error) => { - console.error(error); - process.exit(1); -}); -`; - fs.writeFileSync(scriptPath, script); + const runner = cjsRequire("../bin/lib/runner"); + const registry = cjsRequire("../bin/lib/registry"); + const preflight = cjsRequire("../bin/lib/preflight"); + const credentials = cjsRequire("../bin/lib/credentials"); + const sandboxCreateStream = cjsRequire("../dist/lib/sandbox-create-stream"); + + const originalRun = runner.run; + const originalRunCapture = runner.runCapture; + const originalRegisterSandbox = registry.registerSandbox; + const originalRemoveSandbox = registry.removeSandbox; + const originalCheckPortAvailable = preflight.checkPortAvailable; + const originalPrompt = credentials.prompt; + const originalStreamSandboxCreate = sandboxCreateStream.streamSandboxCreate; + const originalHome = process.env.HOME; + const originalPath = process.env.PATH; + const originalGateway = process.env.OPENSHELL_GATEWAY; + const originalNonInteractive = process.env.NEMOCLAW_NON_INTERACTIVE; + + const commands = []; + try { + runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + return { status: 0 }; + }; + runner.runCapture = (command) => { + if (command.includes("'sandbox' 'get' 'my-assistant'")) return ""; + if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; + if (command.includes("sandbox exec 'my-assistant' curl -sf http://localhost:18789/")) { + return "ok"; + } + if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; + return ""; + }; + registry.registerSandbox = () => true; + registry.removeSandbox = () => true; + preflight.checkPortAvailable = async () => ({ ok: true }); + credentials.prompt = async () => ""; + sandboxCreateStream.streamSandboxCreate = async (command, env) => { + commands.push({ command, env: env || null }); + return { + status: 0, + output: "Created sandbox: my-assistant", + sawProgress: true, + }; + }; + + process.env.HOME = tmpDir; + process.env.PATH = `${fakeBin}:${originalPath || ""}`; + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + process.env.NEMOCLAW_NON_INTERACTIVE = "1"; + + const { createSandbox } = loadFreshOnboardModule(); + const sandboxName = await createSandbox(null, "gpt-5.4"); + + assert.equal(sandboxName, "my-assistant"); + const createCommand = commands.find((entry) => + entry.command.includes("'sandbox' 'create'"), + ); + assert.ok(createCommand, "expected sandbox create command"); + assert.match(createCommand.command, /'nemoclaw-start'/); + assert.doesNotMatch(createCommand.command, /'--upload'/); + assert.doesNotMatch(createCommand.command, /OPENCLAW_CONFIG_PATH/); + assert.doesNotMatch(createCommand.command, /NVIDIA_API_KEY=/); + assert.doesNotMatch(createCommand.command, /DISCORD_BOT_TOKEN=/); + assert.doesNotMatch(createCommand.command, /SLACK_BOT_TOKEN=/); + assert.ok( + commands.some((entry) => + entry.command.includes("'forward' 'start' '--background' '18789' 'my-assistant'"), + ), + "expected default loopback dashboard forward", + ); + } finally { + runner.run = originalRun; + runner.runCapture = originalRunCapture; + registry.registerSandbox = originalRegisterSandbox; + registry.removeSandbox = originalRemoveSandbox; + preflight.checkPortAvailable = originalCheckPortAvailable; + credentials.prompt = originalPrompt; + sandboxCreateStream.streamSandboxCreate = originalStreamSandboxCreate; + process.env.HOME = originalHome; + process.env.PATH = originalPath; + if (originalGateway === undefined) delete process.env.OPENSHELL_GATEWAY; + else process.env.OPENSHELL_GATEWAY = originalGateway; + if (originalNonInteractive === undefined) delete process.env.NEMOCLAW_NON_INTERACTIVE; + else process.env.NEMOCLAW_NON_INTERACTIVE = originalNonInteractive; + } + }, + ); - const result = spawnSync(process.execPath, [scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - PATH: `${fakeBin}:${process.env.PATH || ""}`, - NEMOCLAW_NON_INTERACTIVE: "1", - }, - }); + it( + "binds the dashboard forward to 0.0.0.0 when CHAT_UI_URL points to a remote host", + { timeout: 45000 }, + async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-remote-forward-")); + const fakeBin = path.join(tmpDir, "bin"); + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); - assert.equal(result.status, 0, result.stderr); - const commands = JSON.parse(result.stdout.trim().split("\n").pop()); - assert.ok( - commands.some((entry) => - entry.command.includes("'forward' 'start' '--background' '0.0.0.0:18789' 'my-assistant'"), - ), - "expected remote dashboard forward target", - ); - }); + const runner = cjsRequire("../bin/lib/runner"); + const registry = cjsRequire("../bin/lib/registry"); + const preflight = cjsRequire("../bin/lib/preflight"); + const credentials = cjsRequire("../bin/lib/credentials"); + const sandboxCreateStream = cjsRequire("../dist/lib/sandbox-create-stream"); + + const originalRun = runner.run; + const originalRunCapture = runner.runCapture; + const originalRegisterSandbox = registry.registerSandbox; + const originalRemoveSandbox = registry.removeSandbox; + const originalCheckPortAvailable = preflight.checkPortAvailable; + const originalPrompt = credentials.prompt; + const originalStreamSandboxCreate = sandboxCreateStream.streamSandboxCreate; + const originalHome = process.env.HOME; + const originalPath = process.env.PATH; + const originalGateway = process.env.OPENSHELL_GATEWAY; + const originalNonInteractive = process.env.NEMOCLAW_NON_INTERACTIVE; + const originalChatUiUrl = process.env.CHAT_UI_URL; + + const commands = []; + try { + runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + return { status: 0 }; + }; + runner.runCapture = (command) => { + if (command.includes("'sandbox' 'get' 'my-assistant'")) return ""; + if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; + if (command.includes("sandbox exec 'my-assistant' curl -sf http://localhost:18789/")) { + return "ok"; + } + if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; + return ""; + }; + registry.registerSandbox = () => true; + registry.removeSandbox = () => true; + preflight.checkPortAvailable = async () => ({ ok: true }); + credentials.prompt = async () => ""; + sandboxCreateStream.streamSandboxCreate = async (command, env) => { + commands.push({ command, env: env || null }); + return { + status: 0, + output: "Created sandbox: my-assistant", + sawProgress: true, + }; + }; + + process.env.HOME = tmpDir; + process.env.PATH = `${fakeBin}:${originalPath || ""}`; + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + process.env.NEMOCLAW_NON_INTERACTIVE = "1"; + process.env.CHAT_UI_URL = "https://chat.example.com"; + + const { createSandbox } = loadFreshOnboardModule(); + await createSandbox(null, "gpt-5.4"); + + assert.ok( + commands.some((entry) => + entry.command.includes( + "'forward' 'start' '--background' '0.0.0.0:18789' 'my-assistant'", + ), + ), + "expected remote dashboard forward target", + ); + } finally { + runner.run = originalRun; + runner.runCapture = originalRunCapture; + registry.registerSandbox = originalRegisterSandbox; + registry.removeSandbox = originalRemoveSandbox; + preflight.checkPortAvailable = originalCheckPortAvailable; + credentials.prompt = originalPrompt; + sandboxCreateStream.streamSandboxCreate = originalStreamSandboxCreate; + process.env.HOME = originalHome; + process.env.PATH = originalPath; + if (originalGateway === undefined) delete process.env.OPENSHELL_GATEWAY; + else process.env.OPENSHELL_GATEWAY = originalGateway; + if (originalNonInteractive === undefined) delete process.env.NEMOCLAW_NON_INTERACTIVE; + else process.env.NEMOCLAW_NON_INTERACTIVE = originalNonInteractive; + if (originalChatUiUrl === undefined) delete process.env.CHAT_UI_URL; + else process.env.CHAT_UI_URL = originalChatUiUrl; + } + }, + ); it( "creates providers for messaging tokens and attaches them to the sandbox", @@ -3092,127 +3103,105 @@ console.log(JSON.stringify({ exists: providerExistsInGateway("nonexistent") })); assert.equal(payload.exists, false); }); - it("continues once the sandbox is Ready even if the create stream never closes", async () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-create-ready-")); - const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "create-sandbox-ready-check.js"); - const payloadPath = path.join(tmpDir, "payload.json"); - const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); - const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); - const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); - const preflightPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "preflight.js")); - const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.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 runner = require(${runnerPath}); -const registry = require(${registryPath}); -const preflight = require(${preflightPath}); -const credentials = require(${credentialsPath}); -const childProcess = require("node:child_process"); -const { EventEmitter } = require("node:events"); -const fs = require("node:fs"); - -const commands = []; -let sandboxListCalls = 0; -const keepAlive = setInterval(() => {}, 1000); -runner.run = (command, opts = {}) => { - commands.push({ command, env: opts.env || null }); - return { status: 0 }; -}; -runner.runCapture = (command) => { - if (command.includes("'sandbox' 'get' 'my-assistant'")) return ""; - if (command.includes("'sandbox' 'list'")) { - sandboxListCalls += 1; - return sandboxListCalls >= 2 ? "my-assistant Ready" : "my-assistant Pending"; - } - if (command.includes("sandbox exec 'my-assistant' curl -sf http://localhost:18789/")) return "ok"; - if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; - return ""; -}; -registry.registerSandbox = () => true; -registry.removeSandbox = () => true; -preflight.checkPortAvailable = async () => ({ ok: true }); -credentials.prompt = async () => ""; - -childProcess.spawn = (...args) => { - const child = new EventEmitter(); - child.stdout = new EventEmitter(); - child.stderr = new EventEmitter(); - child.killCalls = []; - child.unrefCalls = 0; - child.stdout.destroyCalls = 0; - child.stderr.destroyCalls = 0; - child.stdout.destroy = () => { - child.stdout.destroyCalls += 1; - }; - child.stderr.destroy = () => { - child.stderr.destroyCalls += 1; - }; - child.unref = () => { - child.unrefCalls += 1; - }; - child.kill = (signal) => { - child.killCalls.push(signal); - process.nextTick(() => child.emit("close", signal === "SIGTERM" ? 0 : 1)); - return true; - }; - commands.push({ command: args[1][1], env: args[2]?.env || null, child }); - process.nextTick(() => { - child.stdout.emit("data", Buffer.from("Created sandbox: my-assistant\n")); - }); - return child; -}; - -const { createSandbox } = require(${onboardPath}); - -(async () => { - process.env.OPENSHELL_GATEWAY = "nemoclaw"; - const sandboxName = await createSandbox(null, "gpt-5.4"); - const createCommand = commands.find((entry) => entry.command.includes("'sandbox' 'create'")); - fs.writeFileSync(${JSON.stringify(payloadPath)}, JSON.stringify({ - sandboxName, - sandboxListCalls, - killCalls: createCommand.child.killCalls, - unrefCalls: createCommand.child.unrefCalls, - stdoutDestroyCalls: createCommand.child.stdout.destroyCalls, - stderrDestroyCalls: createCommand.child.stderr.destroyCalls, - })); - clearInterval(keepAlive); -})().catch((error) => { - clearInterval(keepAlive); - console.error(error); - process.exit(1); -}); -`; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - PATH: `${fakeBin}:${process.env.PATH || ""}`, - NEMOCLAW_NON_INTERACTIVE: "1", - }, - timeout: 15000, - }); + it( + "continues after sandbox-create streaming reports forced readiness", + { timeout: 45000 }, + async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-create-ready-")); + const fakeBin = path.join(tmpDir, "bin"); + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(fs.readFileSync(payloadPath, "utf8")); - assert.equal(payload.sandboxName, "my-assistant"); - assert.ok(payload.sandboxListCalls >= 2); - assert.deepEqual(payload.killCalls, ["SIGTERM"]); - assert.equal(payload.unrefCalls, 1); - assert.equal(payload.stdoutDestroyCalls, 1); - assert.equal(payload.stderrDestroyCalls, 1); - }); + const runner = cjsRequire("../bin/lib/runner"); + const registry = cjsRequire("../bin/lib/registry"); + const preflight = cjsRequire("../bin/lib/preflight"); + const credentials = cjsRequire("../bin/lib/credentials"); + const sandboxCreateStream = cjsRequire("../dist/lib/sandbox-create-stream"); + + const originalRun = runner.run; + const originalRunCapture = runner.runCapture; + const originalRegisterSandbox = registry.registerSandbox; + const originalRemoveSandbox = registry.removeSandbox; + const originalCheckPortAvailable = preflight.checkPortAvailable; + const originalPrompt = credentials.prompt; + const originalStreamSandboxCreate = sandboxCreateStream.streamSandboxCreate; + const originalHome = process.env.HOME; + const originalPath = process.env.PATH; + const originalGateway = process.env.OPENSHELL_GATEWAY; + const originalNonInteractive = process.env.NEMOCLAW_NON_INTERACTIVE; + + const commands = []; + let sandboxListCalls = 0; + try { + runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + return { status: 0 }; + }; + runner.runCapture = (command) => { + if (command.includes("'sandbox' 'get' 'my-assistant'")) return ""; + if (command.includes("'sandbox' 'list'")) { + sandboxListCalls += 1; + return sandboxListCalls >= 2 ? "my-assistant Ready" : "my-assistant Pending"; + } + if (command.includes("sandbox exec 'my-assistant' curl -sf http://localhost:18789/")) { + return "ok"; + } + if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; + return ""; + }; + registry.registerSandbox = () => true; + registry.removeSandbox = () => true; + preflight.checkPortAvailable = async () => ({ ok: true }); + credentials.prompt = async () => ""; + sandboxCreateStream.streamSandboxCreate = async (command, env, options) => { + commands.push({ + command, + env: env || null, + forcedReady: true, + hasReadyCheck: typeof options?.readyCheck === "function", + }); + return { + status: 0, + output: "Created sandbox: my-assistant", + sawProgress: true, + forcedReady: true, + }; + }; + + process.env.HOME = tmpDir; + process.env.PATH = `${fakeBin}:${originalPath || ""}`; + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + process.env.NEMOCLAW_NON_INTERACTIVE = "1"; + + const { createSandbox } = loadFreshOnboardModule(); + const sandboxName = await createSandbox(null, "gpt-5.4"); + + assert.equal(sandboxName, "my-assistant"); + assert.ok(sandboxListCalls >= 2); + const createCommand = commands.find((entry) => + entry.command.includes("'sandbox' 'create'"), + ); + assert.equal(createCommand?.forcedReady, true); + assert.equal(createCommand?.hasReadyCheck, true); + } finally { + runner.run = originalRun; + runner.runCapture = originalRunCapture; + registry.registerSandbox = originalRegisterSandbox; + registry.removeSandbox = originalRemoveSandbox; + preflight.checkPortAvailable = originalCheckPortAvailable; + credentials.prompt = originalPrompt; + sandboxCreateStream.streamSandboxCreate = originalStreamSandboxCreate; + process.env.HOME = originalHome; + process.env.PATH = originalPath; + if (originalGateway === undefined) delete process.env.OPENSHELL_GATEWAY; + else process.env.OPENSHELL_GATEWAY = originalGateway; + if (originalNonInteractive === undefined) delete process.env.NEMOCLAW_NON_INTERACTIVE; + else process.env.NEMOCLAW_NON_INTERACTIVE = originalNonInteractive; + } + }, + ); it("restores the dashboard forward when onboarding reuses an existing ready sandbox", async () => { const repoRoot = path.join(import.meta.dirname, ".."); @@ -3842,132 +3831,127 @@ const { setupMessagingChannels } = require(${onboardPath}); }, ); - it("uses the custom Dockerfile parent directory as build context when --from is given", async () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-from-dockerfile-")); - const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "create-sandbox-from.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); - const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); - const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); - const preflightPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "preflight.js")); - const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); - - // Create a minimal custom Dockerfile in a temporary directory - const customBuildDir = path.join(tmpDir, "custom-image"); - fs.mkdirSync(customBuildDir, { recursive: true }); - fs.writeFileSync( - path.join(customBuildDir, "Dockerfile"), - [ - "FROM ubuntu:22.04", - "ARG NEMOCLAW_MODEL=nvidia/nemotron-super-49b-v1", - "ARG NEMOCLAW_PROVIDER_KEY=nvidia", - "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-super-49b-v1", - "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_BUILD_ID=default", - "RUN echo done", - ].join("\n"), - ); - fs.writeFileSync(path.join(customBuildDir, "extra.txt"), "extra build context file"); - - fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { - mode: 0o755, - }); - - const customDockerfilePath = JSON.stringify(path.join(customBuildDir, "Dockerfile")); - - const script = String.raw` -const runner = require(${runnerPath}); -const registry = require(${registryPath}); -const preflight = require(${preflightPath}); -const credentials = require(${credentialsPath}); -const childProcess = require("node:child_process"); -const { EventEmitter } = require("node:events"); -const fs = require("node:fs"); - -const commands = []; -runner.run = (command, opts = {}) => { - commands.push({ command, env: opts.env || null }); - return { status: 0 }; -}; -runner.runCapture = (command) => { - if (command.includes("'sandbox' 'get' 'my-assistant'")) return ""; - if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; - if (command.includes("sandbox exec 'my-assistant' curl -sf http://localhost:18789/")) return "ok"; - if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; - return ""; -}; -registry.registerSandbox = () => true; -registry.removeSandbox = () => true; -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: 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}); + it( + "uses the custom Dockerfile parent directory as build context when --from is given", + { timeout: 45000 }, + async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-from-dockerfile-")); + const fakeBin = path.join(tmpDir, "bin"); -(async () => { - process.env.OPENSHELL_GATEWAY = "nemoclaw"; - const sandboxName = await createSandbox(null, "gpt-5.4", "openai-api", null, "my-assistant", null, null, ${customDockerfilePath}); - // Verify the staged build context contains the extra file from the custom dir - const createCmd = commands.find((e) => e.command.includes("'sandbox' 'create'")); - const fromMatch = createCmd && createCmd.command.match(/--from['\s]+'([^']+)'/); - let stagedDir = null; - let hasExtraFile = false; - if (fromMatch) { - const dockerfilePath = fromMatch[1]; - stagedDir = require("node:path").dirname(dockerfilePath); - hasExtraFile = fs.existsSync(require("node:path").join(stagedDir, "extra.txt")); - } - console.log(JSON.stringify({ sandboxName, hasExtraFile })); -})().catch((error) => { - console.error(error); - process.exit(1); -}); -`; - fs.writeFileSync(scriptPath, script); + // Create a minimal custom Dockerfile in a temporary directory + const customBuildDir = path.join(tmpDir, "custom-image"); + fs.mkdirSync(customBuildDir, { recursive: true }); + fs.writeFileSync( + path.join(customBuildDir, "Dockerfile"), + [ + "FROM ubuntu:22.04", + "ARG NEMOCLAW_MODEL=nvidia/nemotron-super-49b-v1", + "ARG NEMOCLAW_PROVIDER_KEY=nvidia", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-super-49b-v1", + "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_BUILD_ID=default", + "RUN echo done", + ].join("\n"), + ); + fs.writeFileSync(path.join(customBuildDir, "extra.txt"), "extra build context file"); - const result = spawnSync(process.execPath, [scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - PATH: `${fakeBin}:${process.env.PATH || ""}`, - NEMOCLAW_NON_INTERACTIVE: "1", - }, - }); + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); - assert.equal(result.status, 0, result.stderr); - const payloadLine = result.stdout - .trim() - .split("\n") - .slice() - .reverse() - .find((line) => line.startsWith("{") && line.endsWith("}")); - assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); - const payload = JSON.parse(payloadLine); - assert.equal(payload.sandboxName, "my-assistant"); - assert.equal( - payload.hasExtraFile, - true, - "extra.txt from custom build context should be staged", - ); - }); + const runner = cjsRequire("../bin/lib/runner"); + const registry = cjsRequire("../bin/lib/registry"); + const preflight = cjsRequire("../bin/lib/preflight"); + const credentials = cjsRequire("../bin/lib/credentials"); + const sandboxCreateStream = cjsRequire("../dist/lib/sandbox-create-stream"); + + const originalRun = runner.run; + const originalRunCapture = runner.runCapture; + const originalRegisterSandbox = registry.registerSandbox; + const originalRemoveSandbox = registry.removeSandbox; + const originalCheckPortAvailable = preflight.checkPortAvailable; + const originalPrompt = credentials.prompt; + const originalStreamSandboxCreate = sandboxCreateStream.streamSandboxCreate; + const originalHome = process.env.HOME; + const originalPath = process.env.PATH; + const originalGateway = process.env.OPENSHELL_GATEWAY; + const originalNonInteractive = process.env.NEMOCLAW_NON_INTERACTIVE; + + const commands = []; + try { + runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + return { status: 0 }; + }; + runner.runCapture = (command) => { + if (command.includes("'sandbox' 'get' 'my-assistant'")) return ""; + if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; + if (command.includes("sandbox exec 'my-assistant' curl -sf http://localhost:18789/")) { + return "ok"; + } + if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; + return ""; + }; + registry.registerSandbox = () => true; + registry.removeSandbox = () => true; + preflight.checkPortAvailable = async () => ({ ok: true }); + credentials.prompt = async () => ""; + sandboxCreateStream.streamSandboxCreate = async (command, env) => { + commands.push({ command, env: env || null }); + return { + status: 0, + output: "Created sandbox: my-assistant", + sawProgress: true, + }; + }; + + process.env.HOME = tmpDir; + process.env.PATH = `${fakeBin}:${originalPath || ""}`; + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + process.env.NEMOCLAW_NON_INTERACTIVE = "1"; + + const { createSandbox } = loadFreshOnboardModule(); + const sandboxName = await createSandbox( + null, + "gpt-5.4", + "openai-api", + null, + "my-assistant", + null, + null, + path.join(customBuildDir, "Dockerfile"), + ); + + assert.equal(sandboxName, "my-assistant"); + const createCmd = commands.find((entry) => entry.command.includes("'sandbox' 'create'")); + const fromMatch = createCmd && createCmd.command.match(/--from['\s]+'([^']+)'/); + let hasExtraFile = false; + if (fromMatch) { + const stagedDir = path.dirname(fromMatch[1]); + hasExtraFile = fs.existsSync(path.join(stagedDir, "extra.txt")); + } + assert.equal(hasExtraFile, true, "extra.txt from custom build context should be staged"); + } finally { + runner.run = originalRun; + runner.runCapture = originalRunCapture; + registry.registerSandbox = originalRegisterSandbox; + registry.removeSandbox = originalRemoveSandbox; + preflight.checkPortAvailable = originalCheckPortAvailable; + credentials.prompt = originalPrompt; + sandboxCreateStream.streamSandboxCreate = originalStreamSandboxCreate; + process.env.HOME = originalHome; + process.env.PATH = originalPath; + if (originalGateway === undefined) delete process.env.OPENSHELL_GATEWAY; + else process.env.OPENSHELL_GATEWAY = originalGateway; + if (originalNonInteractive === undefined) delete process.env.NEMOCLAW_NON_INTERACTIVE; + else process.env.NEMOCLAW_NON_INTERACTIVE = originalNonInteractive; + } + }, + ); it("exits with an error when the --from Dockerfile path does not exist", async () => { const repoRoot = path.join(import.meta.dirname, ".."); diff --git a/test/runner.test.js b/test/runner.test.js index 42a758a5b..b8106b27a 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -484,8 +484,8 @@ describe("regression guards", () => { path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), "utf-8", ); - expect(src).toContain("`nemoclaw setup-spark` is deprecated."); - expect(src).toContain("await onboard(args);"); + expect(src).toContain("runDeprecatedOnboardAliasCommand"); + expect(src).toContain('kind: "setup-spark"'); expect(src).not.toContain('sudo bash "${SCRIPTS}/setup-spark.sh"'); });