From 6e28bb0c4fe53908117b90971f97d938d85eb816 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 6 Apr 2026 15:41:30 -0700 Subject: [PATCH 1/5] refactor(cli): extract onboard command handler --- bin/nemoclaw.js | 61 ++++++++++++++--------- src/lib/onboard-command.test.ts | 88 +++++++++++++++++++++++++++++++++ src/lib/onboard-command.ts | 72 +++++++++++++++++++++++++++ test/runner.test.js | 4 +- 4 files changed, 199 insertions(+), 26 deletions(-) create mode 100644 src/lib/onboard-command.test.ts create mode 100644 src/lib/onboard-command.ts diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 6b7bc61ca8c..46af517d152 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -41,6 +41,10 @@ const onboardSession = require("./lib/onboard-session"); const { parseLiveSandboxNames } = require("./lib/runtime-recovery"); const { NOTICE_ACCEPT_ENV, NOTICE_ACCEPT_FLAG } = require("./lib/usage-notice"); const { executeDeploy } = require("../dist/lib/deploy"); +const { + runDeprecatedOnboardAliasCommand, + runOnboardCommand, +} = require("../dist/lib/onboard-command"); // ── Global commands ────────────────────────────────────────────── @@ -779,36 +783,45 @@ function exitWithSpawnResult(result) { async function onboard(args) { const { onboard: runOnboard } = require("./lib/onboard"); - const allowedArgs = new Set(["--non-interactive", "--resume", 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] [${NOTICE_ACCEPT_FLAG}]`, - ); - process.exit(1); - } - const nonInteractive = args.includes("--non-interactive"); - const resume = args.includes("--resume"); - const acceptThirdPartySoftware = - args.includes(NOTICE_ACCEPT_FLAG) || String(process.env[NOTICE_ACCEPT_ENV] || "") === "1"; - await runOnboard({ nonInteractive, resume, 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 00000000000..20d1c500284 --- /dev/null +++ b/src/lib/onboard-command.test.ts @@ -0,0 +1,88 @@ +// 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, + 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, + 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, + acceptThirdPartySoftware: false, + }); + }); + + 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 00000000000..98a81b20ea0 --- /dev/null +++ b/src/lib/onboard-command.ts @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface OnboardCommandOptions { + nonInteractive: boolean; + resume: boolean; + 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"]; + +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)); + 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(", ")}`); + error( + ` Usage: nemoclaw onboard [--non-interactive] [--resume] [${noticeAcceptFlag}]`, + ); + exit(1); + } + + return { + nonInteractive: args.includes("--non-interactive"), + resume: args.includes("--resume"), + acceptThirdPartySoftware: + args.includes(noticeAcceptFlag) || String(deps.env[noticeAcceptEnv] || "") === "1", + }; +} + +export async function runOnboardCommand(deps: RunOnboardCommandDeps): Promise { + 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/runner.test.js b/test/runner.test.js index eb9519fd561..f5b2e8b42b9 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"'); }); From 527ba0fb80efa8d7034900982428359c57b8aa58 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 6 Apr 2026 23:12:06 -0700 Subject: [PATCH 2/5] fix(cli): handle --help for deprecated setup aliases --- src/lib/onboard-command.test.ts | 20 ++++++++++++++++++++ src/lib/onboard-command.ts | 28 ++++++++++++++++++++++++---- test/cli.test.js | 24 ++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/lib/onboard-command.test.ts b/src/lib/onboard-command.test.ts index ad26608fb0d..2fbd1aeb361 100644 --- a/src/lib/onboard-command.test.ts +++ b/src/lib/onboard-command.test.ts @@ -69,6 +69,26 @@ describe("onboard command", () => { }); }); + 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( diff --git a/src/lib/onboard-command.ts b/src/lib/onboard-command.ts index 9a05b7f8043..e251c3d6833 100644 --- a/src/lib/onboard-command.ts +++ b/src/lib/onboard-command.ts @@ -21,8 +21,22 @@ export interface RunOnboardCommandDeps { const ONBOARD_BASE_ARGS = ["--non-interactive", "--resume"]; -function onboardUsage(noticeAcceptFlag: string): string { - return ` Usage: nemoclaw onboard [--non-interactive] [--resume] [--from ] [${noticeAcceptFlag}]`; +function onboardUsageLines(noticeAcceptFlag: string): string[] { + return [ + ` Usage: nemoclaw onboard [--non-interactive] [--resume] [--from ] [${noticeAcceptFlag}]`, + "", + " Options:", + " --non-interactive Run without prompts", + " --resume Resume a saved onboarding session", + " --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( @@ -39,7 +53,7 @@ export function parseOnboardArgs( fromDockerfile = args[fromIdx + 1] || null; if (!fromDockerfile || fromDockerfile.startsWith("--")) { error(" --from requires a path to a Dockerfile"); - error(onboardUsage(noticeAcceptFlag)); + printOnboardUsage(error, noticeAcceptFlag); exit(1); } args = [...args.slice(0, fromIdx), ...args.slice(fromIdx + 2)]; @@ -49,7 +63,7 @@ export function parseOnboardArgs( const unknownArgs = args.filter((arg) => !allowedArgs.has(arg)); if (unknownArgs.length > 0) { error(` Unknown onboard option(s): ${unknownArgs.join(", ")}`); - error(onboardUsage(noticeAcceptFlag)); + printOnboardUsage(error, noticeAcceptFlag); exit(1); } @@ -63,6 +77,12 @@ export function parseOnboardArgs( } 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); } diff --git a/test/cli.test.js b/test/cli.test.js index 736fb58f1c6..13236bbe1d6 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); From 35aeff82049f916e06a91a3ded22677693106a25 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 6 Apr 2026 23:35:22 -0700 Subject: [PATCH 3/5] test(cli): relax slow onboard subprocess checks --- test/onboard.test.js | 636 +++++++++++++++++++++---------------------- 1 file changed, 316 insertions(+), 320 deletions(-) diff --git a/test/onboard.test.js b/test/onboard.test.js index bfe4fa10fcf..2d2fafa9344 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"; @@ -43,6 +44,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( @@ -1454,128 +1463,126 @@ 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", - }, - }); + 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, + }); - 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", - ); - }); + 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; + } + }, + ); - 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")); + it( + "binds the dashboard forward to 0.0.0.0 when CHAT_UI_URL points to a remote host", + { timeout: 45000 }, + 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, - }); + 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 script = String.raw` const runner = require(${runnerPath}); const registry = require(${registryPath}); const preflight = require(${preflightPath}); @@ -1624,28 +1631,32 @@ const { createSandbox } = require(${onboardPath}); process.exit(1); }); `; - fs.writeFileSync(scriptPath, script); + 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", - }, - }); + 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", + NODE_OPTIONS: "", + NODE_V8_COVERAGE: "", + }, + timeout: 30000, + }); - 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", - ); - }); + 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", + ); + }, + ); it( "creates providers for messaging tokens and attaches them to the sandbox", @@ -2280,127 +2291,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, ".."); @@ -3030,45 +3019,48 @@ 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")); + it( + "uses the custom Dockerfile parent directory as build context when --from is given", + { timeout: 45000 }, + 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"); + // 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, - }); + 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 customDockerfilePath = JSON.stringify(path.join(customBuildDir, "Dockerfile")); - const script = String.raw` + const script = String.raw` const runner = require(${runnerPath}); const registry = require(${registryPath}); const preflight = require(${preflightPath}); @@ -3127,35 +3119,39 @@ const { createSandbox } = require(${onboardPath}); process.exit(1); }); `; - fs.writeFileSync(scriptPath, script); + 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", - }, - }); + 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", + NODE_OPTIONS: "", + NODE_V8_COVERAGE: "", + }, + timeout: 30000, + }); - 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", - ); - }); + 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", + ); + }, + ); it("exits with an error when the --from Dockerfile path does not exist", async () => { const repoRoot = path.join(import.meta.dirname, ".."); From 9f37f724ca4df022abbc6a3ed5ac6cc3b599783f Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 7 Apr 2026 00:59:40 -0700 Subject: [PATCH 4/5] test(cli): stabilize coverage-heavy subprocess checks --- test/nemoclaw-cli-recovery.test.js | 103 ++++----- test/onboard.test.js | 328 ++++++++++++++--------------- test/shellquote-sandbox.test.js | 5 +- 3 files changed, 214 insertions(+), 222 deletions(-) diff --git a/test/nemoclaw-cli-recovery.test.js b/test/nemoclaw-cli-recovery.test.js index 841b2dee821..733a6dd04f3 100644 --- a/test/nemoclaw-cli-recovery.test.js +++ b/test/nemoclaw-cli-recovery.test.js @@ -9,38 +9,41 @@ import path from "node:path"; import { describe, it } from "vitest"; describe("nemoclaw CLI runtime recovery", () => { - it("recovers sandbox status when openshell is only available via the resolved fallback path", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-recovery-")); - const homeLocalBin = path.join(tmpDir, ".local", "bin"); - const stateDir = path.join(tmpDir, "state"); - const registryDir = path.join(tmpDir, ".nemoclaw"); - const openshellPath = path.join(homeLocalBin, "openshell"); - const stateFile = path.join(stateDir, "openshell-state.json"); + it( + "recovers sandbox status when openshell is only available via the resolved fallback path", + { timeout: 45000 }, + () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-recovery-")); + const homeLocalBin = path.join(tmpDir, ".local", "bin"); + const stateDir = path.join(tmpDir, "state"); + const registryDir = path.join(tmpDir, ".nemoclaw"); + const openshellPath = path.join(homeLocalBin, "openshell"); + const stateFile = path.join(stateDir, "openshell-state.json"); - fs.mkdirSync(homeLocalBin, { recursive: true }); - fs.mkdirSync(stateDir, { recursive: true }); - fs.mkdirSync(registryDir, { recursive: true }); - fs.writeFileSync( - path.join(registryDir, "sandboxes.json"), - JSON.stringify({ - defaultSandbox: "my-assistant", - sandboxes: { - "my-assistant": { - name: "my-assistant", - model: "nvidia/nemotron-3-super-120b-a12b", - provider: "nvidia-prod", - gpuEnabled: false, - policies: [], + fs.mkdirSync(homeLocalBin, { recursive: true }); + fs.mkdirSync(stateDir, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "my-assistant", + sandboxes: { + "my-assistant": { + name: "my-assistant", + model: "nvidia/nemotron-3-super-120b-a12b", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, }, - }, - }), - { mode: 0o600 }, - ); - fs.writeFileSync(stateFile, JSON.stringify({ statusCalls: 0, sandboxGetCalls: 0 })); - fs.writeFileSync( - openshellPath, - `#!${process.execPath} + }), + { mode: 0o600 }, + ); + fs.writeFileSync(stateFile, JSON.stringify({ statusCalls: 0, sandboxGetCalls: 0 })); + fs.writeFileSync( + openshellPath, + `#!${process.execPath} const fs = require("fs"); const path = require("path"); const statePath = ${JSON.stringify(stateFile)}; @@ -85,25 +88,29 @@ if (args[0] === "logs") { process.exit(0); `, - { mode: 0o755 }, - ); + { mode: 0o755 }, + ); - const result = spawnSync( - process.execPath, - [path.join(repoRoot, "bin", "nemoclaw.js"), "my-assistant", "status"], - { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - PATH: "/usr/bin:/bin", + const result = spawnSync( + process.execPath, + [path.join(repoRoot, "bin", "nemoclaw.js"), "my-assistant", "status"], + { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: "/usr/bin:/bin", + NODE_OPTIONS: "", + NODE_V8_COVERAGE: "", + }, + timeout: 30000, }, - }, - ); + ); - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Recovered NemoClaw gateway runtime via (start|select)/); - assert.match(result.stdout, /Phase: Ready/); - }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Recovered NemoClaw gateway runtime via (start|select)/); + assert.match(result.stdout, /Phase: Ready/); + }, + ); }); diff --git a/test/onboard.test.js b/test/onboard.test.js index 2d2fafa9344..bf9a5bfb662 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -1567,94 +1567,94 @@ console.log(JSON.stringify({ liveExists, sandbox: registry.getSandbox("my-assist "binds the dashboard forward to 0.0.0.0 when CHAT_UI_URL points to a remote host", { timeout: 45000 }, 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 () => ""; + 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"); -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 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 { createSandbox } = require(${onboardPath}); + 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, + }; + }; -(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); + 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 result = spawnSync(process.execPath, [scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - PATH: `${fakeBin}:${process.env.PATH || ""}`, - NEMOCLAW_NON_INTERACTIVE: "1", - NODE_OPTIONS: "", - NODE_V8_COVERAGE: "", - }, - timeout: 30000, - }); + const { createSandbox } = loadFreshOnboardModule(); + await createSandbox(null, "gpt-5.4"); - 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", - ); + 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; + } }, ); @@ -3023,15 +3023,8 @@ const { setupMessagingChannels } = require(${onboardPath}); "uses the custom Dockerfile parent directory as build context when --from is given", { timeout: 45000 }, 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"); @@ -3058,98 +3051,93 @@ const { setupMessagingChannels } = require(${onboardPath}); 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 () => ""; + 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"); -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 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 { createSandbox } = require(${onboardPath}); + 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, + }; + }; -(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); + process.env.HOME = tmpDir; + process.env.PATH = `${fakeBin}:${originalPath || ""}`; + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + process.env.NEMOCLAW_NON_INTERACTIVE = "1"; - 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", - NODE_OPTIONS: "", - NODE_V8_COVERAGE: "", - }, - timeout: 30000, - }); + const { createSandbox } = loadFreshOnboardModule(); + const sandboxName = await createSandbox( + null, + "gpt-5.4", + "openai-api", + null, + "my-assistant", + null, + null, + path.join(customBuildDir, "Dockerfile"), + ); - 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", - ); + 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; + } }, ); diff --git a/test/shellquote-sandbox.test.js b/test/shellquote-sandbox.test.js index 3bc3d7c64da..4757f27922e 100644 --- a/test/shellquote-sandbox.test.js +++ b/test/shellquote-sandbox.test.js @@ -30,10 +30,7 @@ describe("sandboxName shell quoting in onboard.js", () => { let match; while ((match = callPattern.exec(src)) !== null) { const template = match[2]; - if ( - template.includes("${sandboxName}") && - !template.includes("shellQuote(sandboxName)") - ) { + if (template.includes("${sandboxName}") && !template.includes("shellQuote(sandboxName)")) { const line = src.slice(0, match.index).split("\n").length; violations.push(`Line ${line}: ${match[0].slice(0, 120).trim()}`); } From 7efb4531a4f82af20a8d7e0acde2fc586298f323 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 9 Apr 2026 00:34:41 +0000 Subject: [PATCH 5/5] fix(test): add recreateSandbox to onboard-command test expectations Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/onboard-command.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/onboard-command.test.ts b/src/lib/onboard-command.test.ts index 2fbd1aeb361..f2b752606f7 100644 --- a/src/lib/onboard-command.test.ts +++ b/src/lib/onboard-command.test.ts @@ -21,6 +21,7 @@ describe("onboard command", () => { ).toEqual({ nonInteractive: true, resume: true, + recreateSandbox: false, fromDockerfile: null, acceptThirdPartySoftware: true, }); @@ -43,6 +44,7 @@ describe("onboard command", () => { ).toEqual({ nonInteractive: false, resume: false, + recreateSandbox: false, fromDockerfile: null, acceptThirdPartySoftware: true, }); @@ -64,6 +66,7 @@ describe("onboard command", () => { expect(runOnboard).toHaveBeenCalledWith({ nonInteractive: false, resume: true, + recreateSandbox: false, fromDockerfile: null, acceptThirdPartySoftware: false, }); @@ -106,6 +109,7 @@ describe("onboard command", () => { ).toEqual({ nonInteractive: false, resume: true, + recreateSandbox: false, fromDockerfile: "/tmp/Custom.Dockerfile", acceptThirdPartySoftware: false, });