diff --git a/test/cli.test.ts b/test/cli.test.ts deleted file mode 100644 index 51b1f3efd10..00000000000 --- a/test/cli.test.ts +++ /dev/null @@ -1,6940 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect } from "vitest"; -import { execSync, spawn, spawnSync } from "node:child_process"; -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; - -import { - CLI, - FAKE_OPENCLAW_LOG_LINE, - FAKE_OPENSHELL_LOG_LINE, - HERMES_CLI, - OPENCLAW_EXPECTED_VERSION, - PARSER_EXIT_CODE, - createCloudflaredServiceDir, - createDebugCommandTestEnv, - createDoctorTestSetup, - createLogsTestSetup, - execTimeout, - isChildRunning, - isCliErrorCandidate, - readBufferOrStringProperty, - readCliErrorOutput, - readRecordedArgs, - run, - runWithEnv, - testTimeout, - testTimeoutOptions, - waitForChildExit, - writeHealthyDockerStub, - writeHostAliasDockerStub, - writeRecordingCommand, - writeSandboxRegistry, -} from "./cli/helpers"; - -describe("CLI dispatch", () => { - it("config get validates flags and values before dispatch", async () => { - const sandboxConfigModule = await import("../dist/lib/sandbox/config.js"); - const { parseConfigGetArgs } = (sandboxConfigModule.default ?? sandboxConfigModule) as { - parseConfigGetArgs: ( - args: string[], - ) => - | { ok: true; opts: { key: string | null; format: string } } - | { ok: false; errors: string[] }; - }; - - const missingKey = parseConfigGetArgs(["--key"]); - expect(missingKey.ok).toBe(false); - expect(missingKey).toEqual( - expect.objectContaining({ - errors: expect.arrayContaining([expect.stringContaining("--key requires a value")]), - }), - ); - - const missingFormat = parseConfigGetArgs(["--format"]); - expect(missingFormat.ok).toBe(false); - expect(missingFormat).toEqual( - expect.objectContaining({ - errors: expect.arrayContaining([expect.stringContaining("--format requires a value")]), - }), - ); - - const badFormat = parseConfigGetArgs(["--format", "xml"]); - expect(badFormat.ok).toBe(false); - expect(badFormat).toEqual( - expect.objectContaining({ - errors: expect.arrayContaining([expect.stringContaining("Unknown format: xml")]), - }), - ); - - const unknownFlag = parseConfigGetArgs(["--bogus"]); - expect(unknownFlag.ok).toBe(false); - expect(unknownFlag).toEqual( - expect.objectContaining({ - errors: expect.arrayContaining([expect.stringContaining("Unknown flag: --bogus")]), - }), - ); - - expect(parseConfigGetArgs(["--key", "gateway.auth", "--format", "yaml"])).toEqual({ - ok: true, - opts: { key: "gateway.auth", format: "yaml" }, - }); - }); - - it("help exits 0 and shows sections", () => { - const r = run("help"); - expect(r.code).toBe(0); - expect(r.out.includes("Getting Started")).toBeTruthy(); - expect(r.out.includes("Sandbox Management")).toBeTruthy(); - expect(r.out.includes("Policy Presets")).toBeTruthy(); - expect(r.out.includes("Compatibility Commands")).toBeTruthy(); - expect(r.out).toContain("nemoclaw upgrade-sandboxes"); - expect(r.out).toContain("(--check, --auto, --yes|-y)"); - expect(r.out).toContain("nemoclaw update"); - expect(r.out).toContain("(--check, --yes|-y)"); - expect(r.out).toContain("nemoclaw gc"); - expect(r.out).toContain("(--yes|-y|--force, --dry-run)"); - expect(r.out).toContain("nemoclaw onboard"); - expect(r.out).toContain("Configure inference endpoint and credentials"); - expect(r.out).toContain("nemoclaw onboard --from"); - expect(r.out).toContain("Use a custom Dockerfile for the sandbox image"); - }); - - it("--help exits 0", () => { - expect(run("--help").code).toBe(0); - }); - - it("version exits 0", () => { - const r = run("version"); - expect(r.code).toBe(0); - expect(r.out.trim()).toMatch(/^nemoclaw v/); - }); - - it("-h exits 0", () => { - expect(run("-h").code).toBe(0); - }); - - it("no args exits 0 (shows help)", () => { - const r = run(""); - expect(r.code).toBe(0); - expect(r.out.includes("nemoclaw")).toBeTruthy(); - }); - - it("bare unknown name surfaces sandbox-not-found (#2164)", testTimeoutOptions(35_000), () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-unknown-sandbox-")); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - fs.writeFileSync(path.join(localBin, "openshell"), "#!/usr/bin/env bash\nexit 1\n", { - mode: 0o755, - }); - - const r = runWithEnv( - "boguscmd", - { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }, - execTimeout(30_000), - ); - expect(r.code).toBe(1); - expect(r.out.includes("Sandbox 'boguscmd' does not exist")).toBeTruthy(); - }); - - it("unknown command with non-sandbox action exits 1", () => { - const r = run("boguscmd boguscmd2"); - expect(r.code).toBe(1); - expect(r.out.includes("Unknown command")).toBeTruthy(); - }); - - it("points OpenShell-only commands at openshell instead of sandbox connect (#3388)", () => { - const term = run("term"); - expect(term.code).toBe(1); - expect(term.out).toContain("Unknown nemoclaw command: term"); - expect(term.out).toContain("Run: openshell term"); - expect(term.out).not.toContain("Try: nemoclaw connect"); - - const policy = run("policy set"); - expect(policy.code).toBe(1); - expect(policy.out).toContain("Unknown nemoclaw command: policy set"); - expect(policy.out).toContain("Run: openshell policy set --policy "); - expect(policy.out).toContain("nemoclaw policy-add "); - expect(policy.out).not.toContain("Try: nemoclaw connect"); - - const gateway = run("gateway stop"); - expect(gateway.code).toBe(1); - expect(gateway.out).toContain("Unknown nemoclaw command: gateway stop"); - expect(gateway.out).toContain("Run: openshell gateway stop -g nemoclaw"); - expect(gateway.out).not.toContain("Try: nemoclaw connect"); - }); - - it("redirects `inference set` to openshell when provider or model is missing", () => { - for (const argv of [ - "inference set 2>&1", - "inference set --provider nvidia-prod 2>&1", - "inference set --model nvidia/model 2>&1", - ]) { - const r = run(argv); - expect(r.code, `nemoclaw ${argv}`).toBe(1); - expect(r.out, `nemoclaw ${argv}`).toContain("Unknown nemoclaw command: inference set"); - expect(r.out, `nemoclaw ${argv}`).toContain("This operation belongs to OpenShell."); - expect(r.out, `nemoclaw ${argv}`).toContain( - "Run: openshell inference set -g nemoclaw --model --provider ", - ); - expect(r.out, `nemoclaw ${argv}`).not.toContain("Missing required flag"); - expect(r.out, `nemoclaw ${argv}`).not.toContain("FailedFlagValidationError"); - expect(r.out, `nemoclaw ${argv}`).not.toContain("node_modules/@oclif/core"); - } - - let hermesOut = ""; - let hermesCode = 0; - try { - hermesOut = execSync(`node "${HERMES_CLI}" inference set 2>&1`, { - encoding: "utf-8", - stdio: "pipe", - timeout: execTimeout(), - env: { - ...process.env, - HOME: fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-test-")), - }, - }); - } catch (err) { - const result = readCliErrorOutput( - isCliErrorCandidate(err) - ? { - status: typeof err.status === "number" ? err.status : undefined, - stdout: readBufferOrStringProperty(err, "stdout"), - stderr: readBufferOrStringProperty(err, "stderr"), - } - : String(err), - ); - hermesOut = result.out; - hermesCode = result.code; - } - expect(hermesCode).toBe(1); - expect(hermesOut).toContain("Unknown nemohermes command: inference set"); - expect(hermesOut).toContain("This operation belongs to OpenShell."); - expect(hermesOut).toContain( - "Run: openshell inference set -g nemoclaw --model --provider ", - ); - }); - - it("suggests list for a mistyped list command", () => { - // Isolate from any real openshell gateway on the host so recovery - // doesn't intercept the typo suggestion. - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-typo-suggest-")); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - fs.writeFileSync( - path.join(localBin, "openshell"), - ["#!/usr/bin/env bash", "exit 1"].join("\n"), - { mode: 0o755 }, - ); - - try { - const r = runWithEnv("liost", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - NEMOCLAW_HEALTH_POLL_COUNT: "0", - }); - expect(r.code).toBe(1); - expect(r.out).toContain("Unknown command: liost"); - expect(r.out).toContain("Did you mean: nemoclaw list?"); - } finally { - fs.rmSync(home, { recursive: true, force: true }); - } - }); - - it("recovers a live sandbox before suggesting a bare command typo", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-recover-typo-")); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'printf "%s\\n" "$*" >> "$HOME/openshell-calls.log"', - 'case "$*" in', - ' "status") printf "Status: Connected\\nGateway: nemoclaw\\n"; exit 0 ;;', - ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ' "sandbox list") echo "liost Ready"; exit 0 ;;', - ' "sandbox get liost") printf "Name: liost\\nPhase: Ready\\nPolicy:\\n"; exit 0 ;;', - ' "policy get --full liost") exit 1 ;;', - ' "inference get") exit 1 ;;', - ' "sandbox connect liost") echo "CONNECTED_LIOST"; exit 0 ;;', - " *) exit 0 ;;", - "esac", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("liost", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - NEMOCLAW_CONNECT_TIMEOUT: "1", - NEMOCLAW_NO_CONNECT_HINT: "1", - }); - expect(r.code).toBe(0); - expect(r.out).toContain("CONNECTED_LIOST"); - expect(r.out).not.toContain("Unknown command: liost"); - }); - - it("fails fast on gated NEMOCLAW_VLLM_MODEL without HF token before sandbox side effects", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-vllm-preflight-")); - try { - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home); - const openshellLog = path.join(home, "openshell-calls.log"); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - `printf "%s\\n" "$*" >> ${JSON.stringify(openshellLog)}`, - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const childEnv: Record = {}; - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined) childEnv[key] = value; - } - delete childEnv.HF_TOKEN; - delete childEnv.HUGGING_FACE_HUB_TOKEN; - childEnv.HOME = home; - childEnv.PATH = `${localBin}:${process.env.PATH || ""}`; - childEnv.NEMOCLAW_HEALTH_POLL_COUNT = "1"; - childEnv.NEMOCLAW_HEALTH_POLL_INTERVAL = "0"; - childEnv.NEMOCLAW_VLLM_MODEL = "deepseek-r1-distill-70b"; - - let code = 0; - let out = ""; - try { - execSync(`node "${CLI}" alpha connect 2>&1`, { - encoding: "utf-8", - stdio: "pipe", - timeout: execTimeout(), - env: childEnv, - }); - } catch (err) { - const e = err as { - status?: number; - stdout?: string | Buffer; - stderr?: string | Buffer; - }; - code = typeof e.status === "number" ? e.status : 1; - out = `${e.stdout ?? ""}${e.stderr ?? ""}`; - } - - expect(code).toBe(1); - expect(out).toMatch(/gated on Hugging Face/); - expect(out).toMatch(/HF_TOKEN/); - expect(out).toMatch(/HUGGING_FACE_HUB_TOKEN/); - expect(out).toContain( - "NEMOCLAW_VLLM_MODEL is consumed by the managed-vLLM install path", - ); - const calls = fs.existsSync(openshellLog) ? fs.readFileSync(openshellLog, "utf8") : ""; - expect(calls).not.toMatch(/\bsandbox\s+(get|connect|list)\b/); - } finally { - fs.rmSync(home, { recursive: true, force: true }); - } - }); - - it("explains sandbox connect command order when the sandbox name is last", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-connect-order-")); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home); - fs.writeFileSync( - path.join(localBin, "openshell"), - ["#!/usr/bin/env bash", "exit 1"].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("hermes connect alpha", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(1); - expect(r.out).toContain("Sandbox 'hermes' does not exist"); - expect(r.out).toContain("Command order is: nemoclaw connect"); - expect(r.out).toContain("Did you mean: nemoclaw alpha connect?"); - }); - - it("list exits 0", () => { - const r = run("list"); - expect(r.code).toBe(0); - // With empty HOME, should say no sandboxes - expect(r.out.includes("No sandboxes")).toBeTruthy(); - }); - - it("list --help exits 0 and shows list usage", () => { - const r = run("list --help"); - expect(r.code).toBe(0); - expect(r.out).toContain("list [--json]"); - expect(r.out).toContain("List all sandboxes"); - }); - - it("nemohermes list --help uses alias branding", () => { - const out = execSync(`node "${HERMES_CLI}" list --help`, { - encoding: "utf-8", - stdio: "pipe", - timeout: execTimeout(), - env: { - ...process.env, - HOME: fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-test-")), - }, - }); - expect(out).toContain("$ nemohermes list [--json]"); - expect(out).not.toContain("$ nemoclaw list [--json]"); - }); - - it("nemohermes inference set --help uses alias branding and agent-aware wording", () => { - const out = execSync(`node "${HERMES_CLI}" inference set --help`, { - encoding: "utf-8", - stdio: "pipe", - timeout: execTimeout(), - env: { - ...process.env, - HOME: fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-test-")), - }, - }); - expect(out).toContain("$ nemohermes inference set --provider --model "); - expect(out).toContain("[--sandbox ] [--no-verify]"); - expect(out).toMatch(/OpenClaw or Hermes\s+sandbox config/); - }); - - it("inference set rejects empty provider values during oclif parsing", () => { - const result = run("inference set --provider '' --model nvidia/model"); - expect(result.code).toBe(1); - expect(result.out).toContain("Parsing --provider"); - expect(result.out).toContain("OpenShell inference provider name cannot be empty"); - }); - - it("inference get reports the live NemoClaw gateway route", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-inference-get-")); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "inference" ] && [ "$2" = "get" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', - " echo 'Gateway inference:'", - " echo ' Provider: nvidia-prod'", - " echo ' Model: nvidia/nemotron-3-super-120b-a12b'", - " exit 0", - "fi", - "exit 1", - ].join("\n"), - { mode: 0o755 }, - ); - - try { - const text = runWithEnv("inference get", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - expect(text.code).toBe(0); - expect(text.out).toContain("Provider: nvidia-prod"); - expect(text.out).toContain("Model: nvidia/nemotron-3-super-120b-a12b"); - - const json = runWithEnv("inference get --json", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - expect(json.code).toBe(0); - expect(JSON.parse(json.out)).toEqual({ - provider: "nvidia-prod", - model: "nvidia/nemotron-3-super-120b-a12b", - }); - } finally { - fs.rmSync(home, { recursive: true, force: true }); - } - }); - - it("list --json emits structured empty inventory", () => { - const r = run("list --json"); - expect(r.code).toBe(0); - expect(JSON.parse(r.out)).toEqual({ - schemaVersion: 1, - defaultSandbox: null, - recovery: { - recoveredFromSession: false, - recoveredFromGateway: 0, - }, - lastOnboardedSandbox: null, - sandboxes: [], - }); - }); - - it("list --json emits structured sandbox details", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-list-json-")); - const localBin = path.join(home, "bin"); - const registryDir = path.join(home, ".nemoclaw"); - fs.mkdirSync(localBin, { recursive: true }); - fs.mkdirSync(registryDir, { recursive: true }); - fs.writeFileSync( - path.join(registryDir, "sandboxes.json"), - JSON.stringify({ - sandboxes: { - alpha: { - name: "alpha", - model: "configured-model", - provider: "configured-provider", - gpuEnabled: true, - policies: ["pypi"], - agent: "openclaw", - }, - }, - defaultSandbox: "alpha", - }), - { mode: 0o600 }, - ); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - fs.writeFileSync( - path.join(localBin, "ps"), - ["#!/bin/sh", "echo '123 ssh openshell-alpha'", "exit 0"].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("list --json", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(0); - expect(JSON.parse(r.out)).toEqual({ - schemaVersion: 1, - defaultSandbox: "alpha", - recovery: { - recoveredFromSession: false, - recoveredFromGateway: 0, - }, - lastOnboardedSandbox: null, - sandboxes: [ - { - name: "alpha", - model: "configured-model", - provider: "configured-provider", - gpuEnabled: true, - policies: ["pypi"], - agent: "openclaw", - isDefault: true, - activeSessionCount: 1, - connected: true, - hostGpuDetected: false, - sandboxGpuEnabled: true, - sandboxGpuMode: null, - sandboxGpuDevice: null, - openshellDriver: null, - openshellVersion: null, - }, - ], - }); - }); - - it("list forwards oclif parse errors for unknown options", () => { - const r = run("list --bogus"); - expect(r.code).toBe(2); - expect(r.out.includes("Nonexistent flag: --bogus")).toBeTruthy(); - expect(r.out.includes("See more help with --help")).toBeTruthy(); - }); - - it("status --help exits 0 and shows status usage", () => { - const r = run("status --help"); - expect(r.code).toBe(0); - expect(r.out).toContain("status [--json]"); - expect(r.out).toContain("Show sandbox list and service status"); - }); - - it("status --json emits parseable structured status without credentials", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-status-json-")); - const localBin = path.join(home, "bin"); - const registryDir = path.join(home, ".nemoclaw"); - const sandboxName = `alpha-${process.pid}-${Date.now()}`; - const serviceDir = path.join("/tmp", `nemoclaw-services-${sandboxName}`); - fs.rmSync(serviceDir, { recursive: true, force: true }); - fs.mkdirSync(localBin, { recursive: true }); - fs.mkdirSync(registryDir, { recursive: true }); - fs.writeFileSync( - path.join(registryDir, "sandboxes.json"), - JSON.stringify({ - sandboxes: { - [sandboxName]: { - name: sandboxName, - model: "configured-model", - provider: "configured-provider", - gpuEnabled: true, - policies: ["npm"], - agent: "openclaw", - dashboardPort: 18789, - providerCredentialHashes: { - OPENAI_API_KEY: "sk-should-not-render-000000000000", - }, - messagingChannels: ["slack"], - dashboardUrl: "http://127.0.0.1:18789/?token=dashboard-secret", - logs: "Bearer should-not-render xoxb-should-not-render-000000", - }, - }, - defaultSandbox: sandboxName, - }), - { mode: 0o600 }, - ); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', - " echo 'Gateway inference:'", - " echo", - " echo ' Provider: nvidia-prod'", - " echo ' Model: nvidia/nemotron'", - " exit 0", - "fi", - 'if [ "$1" = "status" ]; then', - " echo 'Gateway: nemoclaw'", - " echo 'Status: Connected'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', - " echo 'Gateway: nemoclaw'", - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - try { - const r = runWithEnv("status --json", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(0); - expect(r.out.trim().startsWith("{")).toBe(true); - expect(r.out.trim().endsWith("}")).toBe(true); - expect(r.out).not.toContain("Sandboxes:"); - expect(r.out).not.toContain("(stopped)"); - - const parsed = JSON.parse(r.out); - expect(parsed).toMatchObject({ - schemaVersion: 1, - defaultSandbox: sandboxName, - liveInference: { - provider: "nvidia-prod", - model: "nvidia/nemotron", - }, - gatewayHealth: { - healthy: true, - state: "healthy_named", - }, - sandboxes: [ - { - name: sandboxName, - model: "nvidia/nemotron", - provider: "nvidia-prod", - gpuEnabled: true, - policies: ["npm"], - agent: "openclaw", - dashboardPort: 18789, - isDefault: true, - }, - ], - services: [ - { - name: "cloudflared", - running: false, - pid: null, - }, - ], - }); - expect(r.out).not.toMatch( - /Bearer|nvapi-|sk-|xoxb-|xapp-|password|api[-_]?key|providerCredentialHashes|dashboard-secret|should-not-render/i, - ); - } finally { - fs.rmSync(serviceDir, { recursive: true, force: true }); - } - }); - - it("status --json reports gateway health and exits 1 when gateway is unhealthy", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-status-json-gateway-")); - const localBin = path.join(home, "bin"); - const registryDir = path.join(home, ".nemoclaw"); - fs.mkdirSync(localBin, { recursive: true }); - fs.mkdirSync(registryDir, { recursive: true }); - fs.writeFileSync( - path.join(registryDir, "sandboxes.json"), - JSON.stringify({ - sandboxes: { - alpha: { - name: "alpha", - model: "configured-model", - provider: "configured-provider", - gpuEnabled: false, - policies: [], - }, - }, - defaultSandbox: "alpha", - }), - { mode: 0o600 }, - ); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', - " exit 1", - "fi", - 'if [ "$1" = "status" ]; then', - " echo 'Gateway: nemoclaw'", - " echo 'Error: client error (Connect): Connection refused'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', - " echo 'Gateway: nemoclaw'", - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("status --json", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(1); - expect(r.out.trim().startsWith("{")).toBe(true); - expect(r.out.trim().endsWith("}")).toBe(true); - - const parsed = JSON.parse(r.out); - expect(parsed).toMatchObject({ - schemaVersion: 1, - defaultSandbox: "alpha", - liveInference: null, - gatewayHealth: { - healthy: false, - state: "named_unreachable", - reason: "host port held or container not running", - }, - sandboxes: [ - { - name: "alpha", - model: "configured-model", - provider: "configured-provider", - isDefault: true, - }, - ], - }); - }); - - it("sandbox status surfaces docker_unreachable header and suppresses stale Inference probe", () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-cli-sandbox-status-docker-unreachable-"), - ); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home, "alpha", { - provider: "openai-api", - model: "gpt-4o-mini", - openshellDriver: "docker", - }); - - fs.writeFileSync( - path.join(localBin, "docker"), - ["#!/usr/bin/env bash", "exit 1"].join("\n"), - { mode: 0o755 }, - ); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', - " echo 'Gateway inference:'", - " echo ' Provider: openai-api'", - " echo ' Model: gpt-4o-mini'", - " exit 0", - "fi", - 'if [ "$1" = "status" ]; then', - " echo 'Gateway: nemoclaw'", - " echo 'Status: Connected'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', - " echo 'Gateway: nemoclaw'", - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("alpha status", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(1); - expect(r.out.startsWith( - "Failure layer: docker_unreachable — Docker daemon is not reachable.", - )).toBe(true); - expect(r.out).not.toContain("Inference: healthy"); - const headerIdx = r.out.indexOf("Failure layer: docker_unreachable"); - const sandboxIdx = r.out.indexOf("Sandbox: alpha"); - expect(headerIdx).toBeGreaterThanOrEqual(0); - expect(sandboxIdx).toBeGreaterThan(headerIdx); - expect( - (r.out.match(/Failure layer: docker_unreachable/g) || []).length, - ).toBe(1); - }); - - it("sandbox status preserves Inference probe and exits 0 when openshellDriver is not docker", () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-cli-sandbox-status-non-docker-driver-"), - ); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home, "alpha", { - provider: "openai-api", - model: "gpt-4o-mini", - openshellDriver: "vm", - }); - - fs.writeFileSync( - path.join(localBin, "docker"), - ["#!/usr/bin/env bash", "exit 1"].join("\n"), - { mode: 0o755 }, - ); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', - " echo 'Gateway inference:'", - " echo ' Provider: openai-api'", - " echo ' Model: gpt-4o-mini'", - " exit 0", - "fi", - 'if [ "$1" = "status" ]; then', - " echo 'Gateway: nemoclaw'", - " echo 'Status: Connected'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', - " echo 'Gateway: nemoclaw'", - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("alpha status", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(0); - expect(r.out).not.toContain("Failure layer: docker_unreachable"); - expect(r.out).toContain("Sandbox: alpha"); - expect(r.out).toContain("Provider: openai-api"); - expect(r.out).toContain("Model: gpt-4o-mini"); - expect(r.out).toContain("Inference: healthy"); - }); - - it("sandbox status surfaces sandbox_container_stopped when the per-sandbox container exists but is not running", () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-cli-sandbox-status-container-stopped-"), - ); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home, "alpha", { - provider: "openai-api", - model: "gpt-4o-mini", - openshellDriver: "docker", - }); - - fs.writeFileSync( - path.join(localBin, "docker"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "info" ]; then echo "Server: docker"; exit 0; fi', - 'if [ "$1" = "ps" ] && [ "$2" = "-a" ]; then echo "openshell-alpha-7616dcb1"; exit 0; fi', - 'if [ "$1" = "ps" ]; then echo "openshell-cluster-nemoclaw"; exit 0; fi', - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', - " echo 'Sandbox:'", - " echo ' Name: alpha'", - " echo ' Phase: Error'", - " exit 0", - "fi", - 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', - " echo 'Gateway inference:'", - " echo ' Provider: openai-api'", - " echo ' Model: gpt-4o-mini'", - " exit 0", - "fi", - 'if [ "$1" = "status" ]; then', - " echo 'Gateway: nemoclaw'", - " echo 'Status: Connected'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', - " echo 'Gateway: nemoclaw'", - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("alpha status", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(1); - expect( - r.out.startsWith( - "Failure layer: sandbox_container_stopped — sandbox container exists but is not running.", - ), - ).toBe(true); - expect(r.out).not.toContain("Inference: healthy"); - expect(r.out).toContain("Phase: Error"); - expect(r.out).not.toContain("Failure layer: docker_unreachable"); - expect(r.out).not.toContain("Failure layer: sandbox_dashboard_port_conflict"); - const headerIdx = r.out.indexOf("Failure layer: sandbox_container_stopped"); - const sandboxIdx = r.out.indexOf("Sandbox: alpha"); - expect(headerIdx).toBeGreaterThanOrEqual(0); - expect(sandboxIdx).toBeGreaterThan(headerIdx); - // The downstream gateway-state fallback header (`Failure layer: ...`) - // must be suppressed once preflight has already emitted its own. - // Otherwise a non-`present` gateway lookup would print a redundant - // second `Failure layer:` line later in the output. - expect((r.out.match(/Failure layer:/g) || []).length).toBe(1); - }); - - it("sandbox status surfaces sandbox_dashboard_port_conflict when the sandbox container is stopped and the dashboard port is held by a foreign listener", async () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-cli-sandbox-status-port-conflict-"), - ); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - - const server = net.createServer(); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve()); - }); - const address = server.address(); - if (!address || typeof address === "string") { - server.close(); - throw new Error("failed to bind foreign listener on a free port"); - } - const dashboardPort = address.port; - - try { - writeSandboxRegistry(home, "alpha", { - provider: "openai-api", - model: "gpt-4o-mini", - openshellDriver: "docker", - dashboardPort, - }); - - fs.writeFileSync( - path.join(localBin, "docker"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "info" ]; then echo "Server: docker"; exit 0; fi', - 'if [ "$1" = "ps" ] && [ "$2" = "-a" ]; then echo "openshell-alpha-7616dcb1"; exit 0; fi', - 'if [ "$1" = "ps" ]; then echo "openshell-cluster-nemoclaw"; exit 0; fi', - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', - " echo 'Sandbox:'", - " echo ' Name: alpha'", - " echo ' Phase: Error'", - " exit 0", - "fi", - 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', - " echo 'Gateway inference:'", - " echo ' Provider: openai-api'", - " echo ' Model: gpt-4o-mini'", - " exit 0", - "fi", - 'if [ "$1" = "status" ]; then', - " echo 'Gateway: nemoclaw'", - " echo 'Status: Connected'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', - " echo 'Gateway: nemoclaw'", - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("alpha status", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(1); - expect( - r.out.startsWith( - "Failure layer: sandbox_dashboard_port_conflict — sandbox container is stopped and the dashboard port is held by a foreign listener.", - ), - ).toBe(true); - expect(r.out).not.toContain("Inference: healthy"); - expect(r.out).toContain("Phase: Error"); - expect(r.out).not.toContain("Failure layer: sandbox_container_stopped —"); - const headerIdx = r.out.indexOf("Failure layer: sandbox_dashboard_port_conflict"); - const sandboxIdx = r.out.indexOf("Sandbox: alpha"); - expect(headerIdx).toBeGreaterThanOrEqual(0); - expect(sandboxIdx).toBeGreaterThan(headerIdx); - // Downstream gateway-state fallback must not print a second - // `Failure layer:` line when preflight already emitted one. - expect((r.out.match(/Failure layer:/g) || []).length).toBe(1); - } finally { - await new Promise((resolve) => server.close(() => resolve())); - } - }); - - it("sandbox status --json emits structured per-sandbox report", () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-cli-sandbox-status-json-"), - ); - const localBin = path.join(home, "bin"); - const sandboxName = `alpha-${process.pid}-${Date.now()}`; - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home, sandboxName, { - model: "configured-model", - provider: "configured-provider", - gpuEnabled: true, - policies: ["npm"], - hostGpuDetected: true, - sandboxGpuEnabled: true, - sandboxGpuMode: "passthrough", - sandboxGpuDevice: "0", - openshellDriver: "docker", - openshellVersion: "0.0.44", - }); - fs.writeFileSync( - path.join(localBin, "docker"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "info" ]; then echo "Server: docker"; exit 0; fi', - `if [ "$1" = "ps" ] && [ "$2" = "-a" ]; then echo "openshell-cluster-nemoclaw"; echo "openshell-${sandboxName}-7616dcb1"; exit 0; fi`, - `if [ "$1" = "ps" ]; then echo "openshell-cluster-nemoclaw"; echo "openshell-${sandboxName}-7616dcb1"; exit 0; fi`, - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', - " echo 'Gateway inference:'", - " echo", - " echo ' Provider: nvidia-prod'", - " echo ' Model: nvidia/nemotron'", - " exit 0", - "fi", - 'if [ "$1" = "status" ]; then', - " echo 'Gateway: nemoclaw'", - " echo 'Status: Connected'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', - " echo 'Gateway: nemoclaw'", - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv(`${sandboxName} status --json`, { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(0); - expect(r.out.trim().startsWith("{")).toBe(true); - expect(r.out.trim().endsWith("}")).toBe(true); - expect(r.out).not.toContain("Sandbox: "); - expect(r.out).not.toContain("Nonexistent flag: --json"); - - const parsed = JSON.parse(r.out); - expect(parsed).toMatchObject({ - schemaVersion: 1, - name: sandboxName, - found: true, - model: "nvidia/nemotron", - provider: "nvidia-prod", - hostGpuDetected: true, - sandboxGpuEnabled: true, - sandboxGpuMode: "passthrough", - sandboxGpuDevice: "0", - openshellDriver: "docker", - openshellVersion: "0.0.44", - policies: ["npm"], - rpcIssue: null, - }); - expect(typeof parsed.openshellDriver).toBe("string"); - expect(typeof parsed.openshellVersion).toBe("string"); - expect(parsed).toHaveProperty("phase"); - expect(parsed).toHaveProperty("inferenceHealth"); - expect(parsed).toHaveProperty("gatewayState"); - }); - - // #4495: a paused Docker-driver container can surface upstream as - // `Phase: Error` even though the sandbox is intact. NemoClaw must keep the - // raw OpenShell phase but add an actionable paused-container recovery hint. - it("status surfaces a paused Docker-driver container hint without rewriting Phase: Error", testTimeoutOptions(30_000), () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-cli-status-paused-"), - ); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home, "alpha", { - openshellDriver: "docker", - openshellVersion: "0.0.44", - }); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', - " echo 'Sandbox:'", - " echo", - " echo ' Id: abc'", - " echo ' Name: alpha'", - " echo ' Namespace: openshell'", - " echo ' Phase: Error'", - " exit 0", - "fi", - 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', - " echo ' Provider: nvidia-prod'", - " echo ' Model: nvidia/nemotron'", - " exit 0", - "fi", - 'if [ "$1" = "status" ]; then', - " echo 'Gateway: nemoclaw'", - " echo 'Status: Connected'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', - " echo 'Gateway: nemoclaw'", - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - // Docker reports the resolved sandbox container as paused. - fs.writeFileSync( - path.join(localBin, "docker"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "ps" ]; then echo "openshell-alpha-abc123"; exit 0; fi', - 'if [ "$1" = "inspect" ]; then', - ' for a in "$@"; do', - " case \"$a\" in", - ' *Paused*) echo "true"; exit 0 ;;', - ' *Health*) echo "none"; exit 0 ;;', - " esac", - " done", - ' echo ""; exit 0', - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv( - "alpha status", - { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }, - 30000, - ); - - // Raw OpenShell phase is preserved verbatim — not rewritten to Ready. - expect(r.out).toContain("Phase: Error"); - // Actionable paused-container recovery hint is added. - expect(r.out).toContain("paused: openshell-alpha-abc123"); - expect(r.out).toContain("docker unpause openshell-alpha-abc123"); - // The misleading rebuild suggestion must not fire for a paused container. - expect(r.out).not.toContain("rebuild --yes"); - - // The structured report exposes the paused flag for automation consumers. - const j = runWithEnv( - "alpha status --json", - { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }, - 30000, - ); - const parsed = JSON.parse(j.out); - expect(parsed.phase).toBe("Error"); - expect(parsed.dockerPaused).toBe(true); - }); - - it("sandbox status --json defaults openshell driver/version to 'unknown' strings", () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-cli-sandbox-status-json-unknown-"), - ); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home, "alpha"); - fs.writeFileSync( - path.join(localBin, "openshell"), - ["#!/usr/bin/env bash", "exit 0"].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("alpha status --json", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - const parsed = JSON.parse(r.out); - expect(r.code).toBe(0); - expect(parsed.openshellDriver).toBe("unknown"); - expect(parsed.openshellVersion).toBe("unknown"); - expect(typeof parsed.openshellDriver).toBe("string"); - expect(typeof parsed.openshellVersion).toBe("string"); - }); - - it("sandbox status --json surfaces rpcIssue and exits 1 on protobuf mismatch", () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-cli-sandbox-status-json-rpc-"), - ); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home, "alpha"); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', - " echo 'protobuf decode: invalid wire type'", - " exit 0", - "fi", - 'if [ "$1" = "status" ]; then', - " echo 'Gateway: nemoclaw'", - " echo 'Status: Connected'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', - " echo 'Gateway: nemoclaw'", - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("alpha status --json", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(1); - const parsed = JSON.parse(r.out); - expect(parsed.rpcIssue).toEqual({ kind: "protobuf_mismatch" }); - expect(parsed.inferenceHealth).toBeNull(); - expect(parsed.model).toBe("unknown"); - expect(parsed.provider).toBe("unknown"); - }); - - it("sandbox status --json reports found:false and exits 1 for unknown sandbox via canonical form", () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-cli-sandbox-status-json-notfound-"), - ); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - // Registry contains "alpha"; we will query a different name so the - // canonical `sandbox status --json` path produces the documented - // automation contract: `found: false`, gatewayState != present, exit 1. - writeSandboxRegistry(home, "alpha"); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ]; then', - " echo 'NotFound: sandbox not found'", - " exit 1", - "fi", - 'if [ "$1" = "status" ]; then', - " echo 'Gateway: nemoclaw'", - " echo 'Status: Connected'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', - " echo 'Gateway: nemoclaw'", - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("sandbox status ghost --json", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(1); - const parsed = JSON.parse(r.out); - expect(parsed.name).toBe("ghost"); - expect(parsed.found).toBe(false); - expect(parsed.gatewayState).not.toBe("present"); - expect(parsed.rpcIssue).toBeNull(); - expect(parsed.model).toBe("unknown"); - expect(parsed.provider).toBe("unknown"); - expect(parsed.openshellDriver).toBe("unknown"); - expect(parsed.openshellVersion).toBe("unknown"); - }); - - it("sandbox status --json reports gatewayState!=present and exits 1 when sandbox is registered but gateway lookup is missing", () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-cli-sandbox-status-json-nonpresent-"), - ); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home, "alpha", { - model: "configured-model", - provider: "configured-provider", - }); - // openshell `sandbox get alpha` returns NotFound -> gatewayState becomes - // "missing" after reconciliation against a healthy named gateway. - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ]; then', - " echo 'NotFound: sandbox not found'", - " exit 1", - "fi", - 'if [ "$1" = "status" ]; then', - " echo 'Gateway: nemoclaw'", - " echo 'Status: Connected'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', - " echo 'Gateway: nemoclaw'", - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("alpha status --json", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(1); - const parsed = JSON.parse(r.out); - expect(parsed.name).toBe("alpha"); - expect(parsed.found).toBe(true); - expect(parsed.gatewayState).not.toBe("present"); - expect(parsed.rpcIssue).toBeNull(); - // Live inference probe is not attempted when gateway is not present, so - // the report falls back to registry model/provider rather than "unknown". - expect(parsed.model).toBe("configured-model"); - expect(parsed.provider).toBe("configured-provider"); - expect(parsed.inferenceHealth).toBeNull(); - }); - - it("sandbox status --json sets failureLayer=docker_unreachable, suppresses inferenceHealth, and exits 1 when the host Docker daemon is unreachable", () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-cli-sandbox-status-json-docker-unreachable-"), - ); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home, "alpha", { - provider: "openai-api", - model: "gpt-4o-mini", - openshellDriver: "docker", - }); - - fs.writeFileSync( - path.join(localBin, "docker"), - ["#!/usr/bin/env bash", "exit 1"].join("\n"), - { mode: 0o755 }, - ); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', - " echo 'Gateway inference:'", - " echo ' Provider: openai-api'", - " echo ' Model: gpt-4o-mini'", - " exit 0", - "fi", - 'if [ "$1" = "status" ]; then', - " echo 'Gateway: nemoclaw'", - " echo 'Status: Connected'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', - " echo 'Gateway: nemoclaw'", - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("alpha status --json", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(1); - const parsed = JSON.parse(r.out); - expect(parsed.failureLayer).toBe("docker_unreachable"); - expect(parsed.inferenceHealth).toBeNull(); - expect(parsed.name).toBe("alpha"); - expect(parsed.found).toBe(true); - }); - - it("sandbox status --json sets failureLayer=sandbox_container_stopped when the per-sandbox container is stopped", () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-cli-sandbox-status-json-container-stopped-"), - ); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home, "alpha", { - provider: "openai-api", - model: "gpt-4o-mini", - openshellDriver: "docker", - }); - - fs.writeFileSync( - path.join(localBin, "docker"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "info" ]; then echo "Server: docker"; exit 0; fi', - 'if [ "$1" = "ps" ] && [ "$2" = "-a" ]; then echo "openshell-alpha-7616dcb1"; exit 0; fi', - 'if [ "$1" = "ps" ]; then echo "openshell-cluster-nemoclaw"; exit 0; fi', - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', - " echo 'Sandbox:'", - " echo ' Name: alpha'", - " echo ' Phase: Error'", - " exit 0", - "fi", - 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', - " echo 'Gateway inference:'", - " echo ' Provider: openai-api'", - " echo ' Model: gpt-4o-mini'", - " exit 0", - "fi", - 'if [ "$1" = "status" ]; then', - " echo 'Gateway: nemoclaw'", - " echo 'Status: Connected'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', - " echo 'Gateway: nemoclaw'", - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("alpha status --json", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(1); - const parsed = JSON.parse(r.out); - expect(parsed.failureLayer).toBe("sandbox_container_stopped"); - expect(parsed.phase).toBe("Error"); - expect(parsed.inferenceHealth).toBeNull(); - }); - - it("sandbox status --json sets failureLayer=sandbox_dashboard_port_conflict when the dashboard port is held by a foreign listener", async () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-cli-sandbox-status-json-port-conflict-"), - ); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - - const server = net.createServer(); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve()); - }); - const address = server.address(); - if (!address || typeof address === "string") { - server.close(); - throw new Error("failed to bind foreign listener on a free port"); - } - const dashboardPort = address.port; - - try { - writeSandboxRegistry(home, "alpha", { - provider: "openai-api", - model: "gpt-4o-mini", - openshellDriver: "docker", - dashboardPort, - }); - - fs.writeFileSync( - path.join(localBin, "docker"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "info" ]; then echo "Server: docker"; exit 0; fi', - 'if [ "$1" = "ps" ] && [ "$2" = "-a" ]; then echo "openshell-alpha-7616dcb1"; exit 0; fi', - 'if [ "$1" = "ps" ]; then echo "openshell-cluster-nemoclaw"; exit 0; fi', - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', - " echo 'Sandbox:'", - " echo ' Name: alpha'", - " echo ' Phase: Error'", - " exit 0", - "fi", - 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', - " echo 'Gateway inference:'", - " echo ' Provider: openai-api'", - " echo ' Model: gpt-4o-mini'", - " exit 0", - "fi", - 'if [ "$1" = "status" ]; then', - " echo 'Gateway: nemoclaw'", - " echo 'Status: Connected'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', - " echo 'Gateway: nemoclaw'", - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("alpha status --json", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(1); - const parsed = JSON.parse(r.out); - expect(parsed.failureLayer).toBe("sandbox_dashboard_port_conflict"); - expect(parsed.phase).toBe("Error"); - expect(parsed.inferenceHealth).toBeNull(); - } finally { - await new Promise((resolve) => server.close(() => resolve())); - } - }); - - it("sandbox status --json sets failureLayer=null when no preflight failure applies", () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-cli-sandbox-status-json-failure-layer-null-"), - ); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home, "alpha", { - provider: "openai-api", - model: "gpt-4o-mini", - openshellDriver: "vm", - }); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', - " echo 'Gateway inference:'", - " echo ' Provider: openai-api'", - " echo ' Model: gpt-4o-mini'", - " exit 0", - "fi", - 'if [ "$1" = "status" ]; then', - " echo 'Gateway: nemoclaw'", - " echo 'Status: Connected'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', - " echo 'Gateway: nemoclaw'", - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("alpha status --json", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - const parsed = JSON.parse(r.out); - expect(parsed.failureLayer).toBeNull(); - }); - - it("sandbox status --help advertises --json flag", () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-cli-sandbox-status-help-json-"), - ); - writeSandboxRegistry(home); - const r = runWithEnv("sandbox status alpha --help", { HOME: home }); - expect(r.code).toBe(0); - expect(r.out).toContain("--json"); - expect(r.out).toContain("$ nemoclaw sandbox status [--json]"); - expect(r.out).toContain("$ nemoclaw sandbox status alpha --json"); - - const alias = runWithEnv("alpha status --help", { HOME: home }); - expect(alias.code).toBe(0); - expect(alias.out).toContain("--json"); - }); - - it("status rejects unknown flags through current dispatch path", () => { - const r = run("status --bogus"); - expect(r.code).toBe(2); - expect(r.out).toContain("Nonexistent flag: --bogus"); - }); - - it("status rejects unexpected positional arguments through current dispatch path", () => { - const r = run("status bogus"); - expect(r.code).toBe(2); - expect(r.out).toContain("Unexpected argument: bogus"); - }); - - it("tunnel --help exits 0 and shows tunnel subcommands", () => { - const r = run("tunnel --help"); - expect(r.code).toBe(0); - expect(r.out).toContain("tunnel "); - expect(r.out).toContain("tunnel start"); - expect(r.out).toContain("tunnel stop"); - expect(r.out).toContain("tunnel status"); - }); - - it("root help shows tunnel status with tunnel start and stop", () => { - const r = run("--help"); - expect(r.code).toBe(0); - expect(r.out).toContain("nemoclaw tunnel start"); - expect(r.out).toContain("nemoclaw tunnel stop"); - expect(r.out).toContain("nemoclaw tunnel status"); - }); - - it("tunnel start --help exits 0 and shows tunnel usage", () => { - const r = run("tunnel start --help"); - expect(r.code).toBe(0); - expect(r.out).toContain("tunnel start"); - expect(r.out).toContain("Start the cloudflared public-URL tunnel"); - }); - - it("deprecated start --help exits 0 and shows alias usage", () => { - const r = run("start --help"); - expect(r.code).toBe(0); - expect(r.out).toContain("start"); - expect(r.out).toContain("Deprecated alias"); - }); - - it("tunnel stop --help exits 0 and shows tunnel usage", () => { - const r = run("tunnel stop --help"); - expect(r.code).toBe(0); - expect(r.out).toContain("tunnel stop"); - expect(r.out).toContain("Stop the cloudflared public-URL tunnel"); - }); - - it("tunnel status --help exits 0 and shows tunnel status usage", () => { - const r = run("tunnel status --help"); - expect(r.code).toBe(0); - expect(r.out).toContain("tunnel status"); - expect(r.out).toContain("Show cloudflared public-URL tunnel status"); - }); - - it("tunnel status exits 0 and prints cloudflared status", () => { - const r = run("tunnel status"); - expect(r.code).toBe(0); - expect(r.out).toContain("cloudflared"); - }); - - it("bare tunnel exits 0 and shows tunnel subcommands", () => { - const r = run("tunnel"); - expect(r.code).toBe(0); - expect(r.out).toContain("tunnel "); - expect(r.out).toContain("tunnel start"); - expect(r.out).toContain("tunnel stop"); - expect(r.out).toContain("tunnel status"); - }); - - it("deprecated stop --help exits 0 and shows alias usage", () => { - const r = run("stop --help"); - expect(r.code).toBe(0); - expect(r.out).toContain("stop"); - expect(r.out).toContain("Deprecated alias"); - }); - - it("credentials help exits 0 and shows credential subcommands", () => { - const r = run("credentials --help"); - expect(r.code).toBe(0); - expect(r.out).toContain("USAGE"); - expect(r.out).toContain("$ nemoclaw credentials "); - expect(r.out).toContain("credentials list"); - expect(r.out).toContain("credentials reset"); - }); - - it("credentials list --help exits 0 and shows list usage", () => { - const r = run("credentials list --help"); - expect(r.code).toBe(0); - expect(r.out).toContain("credentials list"); - expect(r.out).toContain("List provider credentials"); - }); - - it("credentials reset without provider uses oclif required-arg validation", () => { - const r = run("credentials reset --yes"); - expect(r.code).toBe(2); - expect(r.out).toContain("Missing 1 required arg"); - expect(r.out).toContain("provider OpenShell provider name"); - }); - - it("maintenance command help exits 0 and shows migrated usage", () => { - const backup = run("backup-all --help"); - expect(backup.code).toBe(0); - expect(backup.out).toContain("backup-all"); - expect(backup.out).toContain("Back up all sandbox state before upgrade"); - - const upgrade = run("upgrade-sandboxes --help"); - expect(upgrade.code).toBe(0); - expect(upgrade.out).toContain("upgrade-sandboxes [--check] [--auto] [--yes|-y]"); - expect(upgrade.out).toContain("Detect and rebuild stale sandboxes"); - - const gc = run("gc --help"); - expect(gc.code).toBe(0); - expect(gc.out).toContain("gc [--dry-run] [--yes|-y|--force]"); - expect(gc.out).toContain("Remove orphaned sandbox Docker images"); - }); - - it("maintenance commands dispatch through oclif", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-maintenance-")); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - fs.writeFileSync( - path.join(localBin, "docker"), - ["#!/bin/sh", "if [ \"$1\" = \"images\" ]; then exit 0; fi", "exit 0"].join("\n"), - { mode: 0o755 }, - ); - - const backup = runWithEnv("backup-all", { HOME: home }); - expect(backup.code).toBe(0); - expect(backup.out).toContain("No sandboxes registered. Nothing to back up."); - - const upgrade = runWithEnv("upgrade-sandboxes --check", { HOME: home }); - expect(upgrade.code).toBe(0); - expect(upgrade.out).toContain("No sandboxes found in the registry."); - - const gc = runWithEnv("gc --dry-run", { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}` }); - expect(gc.code).toBe(0); - expect(gc.out).toContain("No sandbox images found on the host."); - }); - - it("shows native skill install help when --help follows install", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-skill-help-")); - writeSandboxRegistry(home); - - const r = runWithEnv("alpha skill install --help", { HOME: home }); - - expect(r.code).toBe(0); - expect(r.out).toContain("$ nemoclaw sandbox skill install "); - expect(r.out).toContain("Deploy a skill directory"); - expect(r.out).not.toContain("No SKILL.md found"); - }); - - it("requires a skill install path before action dispatch", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-skill-missing-path-")); - writeSandboxRegistry(home); - - const r = runWithEnv("alpha skill install 2>&1", { HOME: home }); - - expect(r.code).not.toBe(0); - expect(r.out).toContain("path"); - }); - - it("points plugin-shaped directories away from skill install", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-plugin-hint-")); - const pluginDir = path.join(home, "openclaw-plugin"); - fs.mkdirSync(pluginDir, { recursive: true }); - writeSandboxRegistry(home); - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ name: "demo-plugin", openclaw: { extensions: ["./dist/index.js"] } }), - ); - - const r = runWithEnv(`alpha skill install ${JSON.stringify(pluginDir)}`, { HOME: home }); - - expect(r.code).toBe(1); - expect(r.out).toContain("No SKILL.md found in"); - expect(r.out).toContain("This looks like an OpenClaw plugin"); - expect(r.out).toContain("nemoclaw onboard --from "); - }); - - it("detects openclaw.plugin.json as a plugin marker", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-plugin-marker-")); - const pluginDir = path.join(home, "openclaw-plugin"); - fs.mkdirSync(pluginDir, { recursive: true }); - writeSandboxRegistry(home); - fs.writeFileSync( - path.join(pluginDir, "openclaw.plugin.json"), - JSON.stringify({ name: "demo" }), - ); - - const r = runWithEnv(`alpha skill install ${JSON.stringify(pluginDir)}`, { HOME: home }); - - expect(r.code).toBe(1); - expect(r.out).toContain("No SKILL.md found in"); - expect(r.out).toContain("This looks like an OpenClaw plugin"); - expect(r.out).toContain("nemoclaw onboard --from "); - }); - - it( - "start does not prompt for NVIDIA_API_KEY before launching local services", - testTimeoutOptions(35_000), - () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-start-no-key-")); - const localBin = path.join(home, "bin"); - const registryDir = path.join(home, ".nemoclaw"); - const markerFile = path.join(home, "start-args"); - fs.mkdirSync(localBin, { recursive: true }); - fs.mkdirSync(registryDir, { recursive: true }); - fs.writeFileSync( - path.join(registryDir, "sandboxes.json"), - JSON.stringify({ - sandboxes: { - alpha: { - name: "alpha", - model: "test-model", - provider: "nvidia-prod", - gpuEnabled: false, - policies: [], - }, - }, - defaultSandbox: "alpha", - }), - { mode: 0o600 }, - ); - fs.writeFileSync( - path.join(localBin, "bash"), - [ - "#!/bin/sh", - `marker_file=${JSON.stringify(markerFile)}`, - 'printf \'%s\\n\' "$@" > "$marker_file"', - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv( - "start", - { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - NVIDIA_API_KEY: "", - TELEGRAM_BOT_TOKEN: "", - }, - 30000, - ); - - expect(r.code).toBe(0); - expect(r.out).not.toContain("NVIDIA API Key required"); - // Services module now runs in-process (no bash shelling) - 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).toContain("USAGE"); - expect(r.out).toContain("nemoclaw onboard"); - expect(r.out).toContain("--from "); - expect(r.out).toContain("--yes"); - expect(r.out).toContain("--sandbox-gpu-device="); - }); - - it("unknown onboard option exits 1", () => { - const r = run("onboard --non-interactiv"); - expect(r.code).toBe(PARSER_EXIT_CODE); - expect(r.out).toContain("Nonexistent flag: --non-interactiv"); - }); - - it("accepts onboard --resume in CLI parsing", () => { - const r = run("onboard --resume --non-interactiv"); - expect(r.code).toBe(PARSER_EXIT_CODE); - expect(r.out).toContain("Nonexistent flag: --non-interactiv"); - }); - - it("accepts the third-party software flag in onboard CLI parsing", () => { - const r = run("onboard --yes-i-accept-third-party-software --non-interactiv"); - expect(r.code).toBe(PARSER_EXIT_CODE); - expect(r.out).toContain("Nonexistent flag: --non-interactiv"); - }); - - it("accepts install automation --yes in onboard CLI parsing", () => { - const r = run("onboard --resume --non-interactive --yes-i-accept-third-party-software --yes"); - expect(r.code).toBe(1); - expect(r.out.includes("No resumable onboarding session was found")).toBeTruthy(); - expect(r.out).not.toContain("Nonexistent flag: --yes"); - }); - - it("passes onboard sandbox GPU flags to legacy validation", () => { - const r = run( - "onboard --sandbox-gpu --no-sandbox-gpu --non-interactive --yes-i-accept-third-party-software --yes", - ); - expect(r.code).toBe(1); - expect(r.out).toContain("--sandbox-gpu and --no-sandbox-gpu are mutually exclusive"); - expect(r.out).not.toContain("Nonexistent flag: --sandbox-gpu"); - expect(r.out).not.toContain("Nonexistent flag: --no-sandbox-gpu"); - }); - - it("passes onboard sandbox GPU device flags to legacy validation", () => { - const r = run( - "onboard --sandbox-gpu-device nvidia.com/gpu=0 --no-sandbox-gpu --non-interactive --yes-i-accept-third-party-software --yes", - ); - expect(r.code).toBe(1); - expect(r.out).toContain("--sandbox-gpu-device cannot be used with --no-sandbox-gpu"); - expect(r.out).not.toContain("Nonexistent flag: --sandbox-gpu-device"); - }); - - 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(PARSER_EXIT_CODE); - expect(r.out).toContain("Nonexistent flag: --non-interactiv"); - }); - - it("setup forwards --resume into onboard parsing", () => { - const r = run("setup --resume --non-interactive --yes-i-accept-third-party-software --yes"); - expect(r.code).toBe(1); - expect(r.out.includes("deprecated")).toBeTruthy(); - expect(r.out.includes("No resumable onboarding session was found")).toBeTruthy(); - }); - - it("resume rejection clarifies --resume semantics and points to onboard (#2281)", () => { - const r = run("onboard --resume --non-interactive --yes-i-accept-third-party-software --yes"); - expect(r.code).toBe(1); - expect(r.out.includes("No resumable onboarding session was found")).toBeTruthy(); - expect(r.out.includes("--resume only continues an interrupted onboarding run")).toBeTruthy(); - expect( - r.out.includes("To change configuration on an existing sandbox, rebuild it"), - ).toBeTruthy(); - expect(r.out.includes("nemoclaw onboard")).toBeTruthy(); - }); - - it("#2753: refuses non-interactive --resume when sandbox step never completed and no name is provided", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-resume-no-name-")); - const localBin = path.join(home, "bin"); - const nemoclawDir = path.join(home, ".nemoclaw"); - fs.mkdirSync(localBin, { recursive: true }); - fs.mkdirSync(nemoclawDir, { recursive: true }); - // Fake openshell so preflight passes and we reach the resume sandbox-name - // init where the new guard lives. - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "--version" ]; then echo "openshell 0.0.37"; exit 0; fi', - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - // Simulates a pre-fix on-disk session that recorded only provider/model - // (with #2753's onboard fix, sandboxName is no longer written here either). - fs.writeFileSync( - path.join(nemoclawDir, "onboard-session.json"), - JSON.stringify( - { - version: 1, - sessionId: "session-1", - resumable: true, - status: "in_progress", - mode: "interactive", - startedAt: "2026-05-03T00:00:00.000Z", - updatedAt: "2026-05-03T00:00:00.000Z", - lastStepStarted: "inference", - lastCompletedStep: "inference", - failure: null, - sandboxName: null, - provider: "nvidia-prod", - model: "nvidia/nemotron-3-super-120b-a12b", - endpointUrl: null, - credentialEnv: null, - preferredInferenceApi: null, - nimContainer: null, - policyPresets: null, - metadata: { gatewayName: "nemoclaw" }, - steps: { - preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, - gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, - provider_selection: { - status: "complete", - startedAt: null, - completedAt: null, - error: null, - }, - inference: { status: "complete", startedAt: null, completedAt: null, error: null }, - sandbox: { status: "pending", startedAt: null, completedAt: null, error: null }, - }, - }, - null, - 2, - ), - { mode: 0o600 }, - ); - - const r = runWithEnv( - "onboard --resume --non-interactive --yes-i-accept-third-party-software", - { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - NEMOCLAW_SANDBOX_NAME: "", - }, - ); - - expect(r.code).toBe(1); - expect(r.out.includes("Cannot resume non-interactive onboard")).toBeTruthy(); - expect(r.out.includes("--name ")).toBeTruthy(); - }); - - it("#2753: whitespace-only NEMOCLAW_SANDBOX_NAME does not satisfy the resume guard", () => { - // The env-var ingest pipeline trims and rejects whitespace-only values - // before populating requestedSandboxName, so the guard sees no recovered - // name and fires correctly. - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-resume-ws-name-")); - const localBin = path.join(home, "bin"); - const nemoclawDir = path.join(home, ".nemoclaw"); - fs.mkdirSync(localBin, { recursive: true }); - fs.mkdirSync(nemoclawDir, { recursive: true }); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "--version" ]; then echo "openshell 0.0.37"; exit 0; fi', - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - fs.writeFileSync( - path.join(nemoclawDir, "onboard-session.json"), - JSON.stringify( - { - version: 1, - sessionId: "session-1", - resumable: true, - status: "in_progress", - mode: "interactive", - startedAt: "2026-05-03T00:00:00.000Z", - updatedAt: "2026-05-03T00:00:00.000Z", - lastStepStarted: "inference", - lastCompletedStep: "inference", - failure: null, - sandboxName: null, - provider: "nvidia-prod", - model: "nvidia/nemotron-3-super-120b-a12b", - endpointUrl: null, - credentialEnv: null, - preferredInferenceApi: null, - nimContainer: null, - policyPresets: null, - metadata: { gatewayName: "nemoclaw" }, - steps: { - preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, - gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, - provider_selection: { - status: "complete", - startedAt: null, - completedAt: null, - error: null, - }, - inference: { status: "complete", startedAt: null, completedAt: null, error: null }, - sandbox: { status: "pending", startedAt: null, completedAt: null, error: null }, - }, - }, - null, - 2, - ), - { mode: 0o600 }, - ); - - const r = runWithEnv( - "onboard --resume --non-interactive --yes-i-accept-third-party-software", - { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - NEMOCLAW_SANDBOX_NAME: " ", - }, - ); - - expect(r.code).toBe(1); - expect(r.out.includes("Cannot resume non-interactive onboard")).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 --yes", - ); - expect(r.code).toBe(1); - expect(r.out.includes("setup-spark` is deprecated")).toBeTruthy(); - expect(r.out.includes("Use `nemoclaw onboard` instead")).toBeTruthy(); - expect(r.out.includes("No resumable onboarding session was found")).toBeTruthy(); - }); - - it("deploy --help exits 0 and shows deprecated usage", () => { - const r = run("deploy --help"); - expect(r.code).toBe(0); - expect(r.out).toContain("deploy [instance-name]"); - expect(r.out).toContain("Deprecated Brev-specific bootstrap path"); - }); - - it("debug --help exits 0 and shows usage", () => { - const r = run("debug --help"); - expect(r.code).toBe(0); - expect(r.out.includes("Collect NemoClaw diagnostic information")).toBeTruthy(); - expect(r.out.includes("--quick")).toBeTruthy(); - expect(r.out.includes("--output")).toBeTruthy(); - }); - - it("debug --quick exits 0 and produces diagnostic output", testTimeoutOptions(30_000), () => { - const r = runWithEnv( - "debug --quick", - createDebugCommandTestEnv("nemoclaw-cli-debug-quick-"), - 30000, - ); - expect(r.code).toBe(0); - expect(r.out.includes("Collecting diagnostics")).toBeTruthy(); - expect(r.out.includes("System")).toBeTruthy(); - expect(r.out.includes("Onboard Session")).toBeTruthy(); - expect(r.out.includes("Done")).toBeTruthy(); - }); - - it.skipIf(os.platform() !== "linux")( - "debug --quick explains restricted dmesg instead of printing raw stderr on Linux", - testTimeoutOptions(30_000), - () => { - const env = createDebugCommandTestEnv("nemoclaw-cli-debug-dmesg-"); - const localBin = env.PATH?.split(path.delimiter)[0]; - if (!localBin) throw new Error("Expected debug test PATH to include a fake bin dir"); - fs.writeFileSync( - path.join(localBin, "dmesg"), - [ - "#!/bin/sh", - "echo 'dmesg: read kernel buffer failed: Operation not permitted' >&2", - "exit 1", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("debug --quick", env, 30000); - - expect(r.code).toBe(0); - expect(r.out).toContain("Kernel Messages"); - expect(r.out).toContain("kernel messages skipped"); - expect(r.out).toContain("dmesg access is restricted"); - expect(r.out).not.toContain("dmesg: read kernel buffer failed: Operation not permitted"); - }, - ); - - it("debug exits 1 on unknown option", () => { - const r = run("debug --quik"); - expect(r.code).not.toBe(0); - expect(r.out).toContain("Nonexistent flag: --quik"); - }); - - it("debug --output without a path is rejected by oclif", () => { - const r = run("debug --output"); - expect(r.code).not.toBe(0); - expect(r.out).toContain("Flag --output expects a value"); - }); - - it("help mentions debug command", () => { - const r = run("help"); - expect(r.code).toBe(0); - expect(r.out.includes("Troubleshooting")).toBeTruthy(); - expect(r.out.includes("nemoclaw debug")).toBeTruthy(); - }); - - it("debug --sandbox NAME targets the specified sandbox", testTimeoutOptions(30_000), () => { - const r = runWithEnv( - "debug --quick --sandbox mybox", - createDebugCommandTestEnv("nemoclaw-cli-debug-sandbox-", { extraSandboxNames: ["mybox"] }), - 30000, - ); - expect(r.code).toBe(0); - expect(r.out).toContain("Collecting diagnostics for sandbox 'mybox'"); - }); - - it("debug --sandbox NAME rejects an unregistered name and exits non-zero", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-debug-unknown-")); - writeSandboxRegistry(home); - const tarball = path.join(home, "out.tar.gz"); - const r = runWithEnv( - `debug --sandbox does-not-exist --output ${tarball} 2>&1`, - { HOME: home }, - 30000, - ); - expect(r.code).not.toBe(0); - expect(r.out).toContain("does-not-exist"); - expect(r.out).toContain("not registered"); - expect(fs.existsSync(tarball)).toBe(false); - }); - - it( - "debug --sandbox NAME rejects a stale registry entry missing from the live gateway", - testTimeoutOptions(30_000), - () => { - // Same fixture pattern as createDebugCommandTestEnv but with an openshell - // stub whose live list intentionally omits the registry name, mirroring - // the bug where the local registry kept a name the gateway no longer - // serves. - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-debug-stale-")); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home, "stale-box"); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/bin/sh", - 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', - " echo 'NAME'", - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - const tarball = path.join(home, "out.tar.gz"); - const r = runWithEnv( - `debug --sandbox stale-box --output ${tarball} 2>&1`, - { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }, - 30000, - ); - expect(r.code).not.toBe(0); - expect(r.out).toContain("stale-box"); - expect(r.out).toContain("not registered"); - expect(fs.existsSync(tarball)).toBe(false); - }, - ); - - it("debug --sandbox without a name exits 1", () => { - const r = run("debug --sandbox"); - expect(r.code).not.toBe(0); - expect(r.out).toContain("--sandbox"); - }); - - it("debug warns when default sandbox is stale", testTimeoutOptions(30_000), () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-stale-")); - fs.mkdirSync(path.join(home, ".nemoclaw"), { recursive: true }); - fs.writeFileSync( - path.join(home, ".nemoclaw", "sandboxes.json"), - JSON.stringify({ sandboxes: {}, defaultSandbox: "ghost" }), - { mode: 0o600 }, - ); - const r = runWithEnv("debug --quick 2>&1", { HOME: home }, 30000); - expect(r.code).toBe(0); - expect(r.out).toContain("Warning"); - expect(r.out).toContain("ghost"); - expect(r.out).toContain("--sandbox NAME"); - }); - - it("debug --sandbox skips stale default warning", testTimeoutOptions(30_000), () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-stale-")); - fs.mkdirSync(path.join(home, ".nemoclaw"), { recursive: true }); - fs.writeFileSync( - path.join(home, ".nemoclaw", "sandboxes.json"), - JSON.stringify({ - sandboxes: { - mybox: { - name: "mybox", - model: "test-model", - provider: "nvidia-prod", - gpuEnabled: false, - policies: [], - }, - }, - defaultSandbox: "ghost", - }), - { mode: 0o600 }, - ); - // Fake openshell so the live-list check sees `mybox`. Without this the - // host's real openshell (or absence thereof) decides the assertion. - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/bin/sh", - 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', - " echo 'NAME'", - " echo 'mybox Ready'", - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - const r = runWithEnv( - "debug --quick --sandbox mybox 2>&1", - { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}` }, - 30000, - ); - expect(r.code).toBe(0); - expect(r.out).not.toContain("default sandbox 'ghost'"); - expect(r.out).not.toContain("--sandbox NAME"); - expect(r.out).toContain("Collecting diagnostics for sandbox 'mybox'"); - }); - - it("gateway-token help uses native oclif usage", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-token-help-")); - writeSandboxRegistry(home); - - const r = runWithEnv("alpha gateway-token --help", { HOME: home }); - - expect(r.code).toBe(0); - expect(r.out).toContain("$ nemoclaw sandbox gateway token [--quiet|-q]"); - expect(r.out).toContain("Print the OpenClaw gateway auth token"); - }); - - it("doctor fails a present sandbox that is not Ready", () => { - const setup = createDoctorTestSetup("nemoclaw-cli-doctor-not-ready-", [ - 'case "$*" in', - ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', - ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ' "sandbox list") printf "NAME STATUS\\nalpha Creating\\n"; exit 0 ;;', - ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', - "esac", - ]); - - const r = setup.runDoctor(); - - expect(r.code).toBe(1); - const report = JSON.parse(r.out) as { - checks: Array<{ label: string; status: string; detail: string }>; - }; - const liveSandbox = report.checks.find((check) => check.label === "Live sandbox"); - expect(liveSandbox).toEqual( - expect.objectContaining({ - status: "fail", - detail: expect.stringContaining("Creating"), - }), - ); - }); - - it("doctor does not inspect the legacy k3s gateway container in Docker-driver mode", () => { - const setup = createDoctorTestSetup("nemoclaw-cli-doctor-docker-driver-", [ - 'case "$*" in', - ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', - ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ' "sandbox list") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', - ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', - "esac", - ]); - // Docker-driver sandbox: no legacy `openshell-cluster-*` container exists. - writeSandboxRegistry(setup.home, "alpha", { openshellDriver: "docker" }); - // Record docker argv and make `docker inspect` fail like an absent legacy - // container would. The doctor must not even attempt the inspect, so this - // should never produce a failure — and we assert the call was skipped, not - // merely that its failure was tolerated. - const dockerCalls = path.join(setup.home, "docker-calls"); - fs.writeFileSync( - path.join(setup.localBin, "docker"), - [ - "#!/usr/bin/env bash", - `printf '%s\\n' "$*" >> ${JSON.stringify(dockerCalls)}`, - 'if [ "$1" = "info" ]; then echo "24.0.0"; exit 0; fi', - 'if [ "$1" = "inspect" ]; then echo "Error: No such object: $3" >&2; exit 1; fi', - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - // Healthy curl so the unrelated provider-health probe does not fail the - // report and mask the gateway-only assertions below. - fs.writeFileSync( - path.join(setup.localBin, "curl"), - ["#!/usr/bin/env bash", 'echo "{}"', "exit 0"].join("\n"), - { mode: 0o755 }, - ); - - const r = setup.runDoctor("alpha doctor --json"); - - expect(r.out).not.toContain("openshell-cluster"); - const report = JSON.parse(r.out) as { - status: string; - checks: Array<{ group: string; label: string; status: string; detail: string }>; - }; - expect(report.checks.find((check) => check.label === "Docker container")).toBeUndefined(); - // Core contract: the legacy k3s container inspect must be skipped entirely, - // not attempted-and-ignored. - const recordedDockerCalls = fs.existsSync(dockerCalls) - ? fs.readFileSync(dockerCalls, "utf8") - : ""; - expect(recordedDockerCalls).not.toMatch(/\binspect\b/); - const openshellStatus = report.checks.find((check) => check.label === "OpenShell status"); - expect(openshellStatus).toEqual( - expect.objectContaining({ group: "Gateway", status: "ok", detail: "connected to nemoclaw" }), - ); - // The Docker-driver gateway is healthy, so no Gateway check should fail. - expect(report.checks.filter((c) => c.group === "Gateway" && c.status === "fail")).toEqual([]); - expect(report.status).toBe("ok"); - expect(r.code).toBe(0); - }); - - it("doctor still inspects the legacy k3s gateway container for the kubernetes driver", () => { - const setup = createDoctorTestSetup("nemoclaw-cli-doctor-k8s-driver-", [ - 'case "$*" in', - ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', - ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ' "sandbox list") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', - ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', - "esac", - ]); - writeSandboxRegistry(setup.home, "alpha", { openshellDriver: "kubernetes" }); - - const r = setup.runDoctor("alpha doctor --json"); - - const report = JSON.parse(r.out) as { - checks: Array<{ group: string; label: string; status: string; detail: string }>; - }; - const dockerContainer = report.checks.find((check) => check.label === "Docker container"); - expect(dockerContainer).toEqual( - expect.objectContaining({ - group: "Gateway", - status: "ok", - detail: expect.stringContaining("openshell-cluster-nemoclaw"), - }), - ); - }); - - it( - "doctor reports fresh shields state as not configured instead of down", - testTimeoutOptions(30_000), - () => { - const setup = createDoctorTestSetup("nemoclaw-cli-doctor-shields-default-", [ - 'case "$*" in', - ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', - ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ' "sandbox list") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', - ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', - "esac", - ]); - - const r = setup.runDoctor("alpha doctor --json"); - - const report = JSON.parse(r.out) as { - checks: Array<{ label: string; status: string; detail: string; hint?: string }>; - }; - const shields = report.checks.find((check) => check.label === "Shields"); - expect(shields).toEqual( - expect.objectContaining({ - status: "info", - detail: "not configured (default mutable state)", - }), - ); - expect(shields?.detail).not.toBe("down"); - }, - ); - - it("doctor does not query sandbox state from a different active gateway", () => { - const setup = createDoctorTestSetup("nemoclaw-cli-doctor-wrong-gateway-", [ - 'case "$*" in', - ' "status") printf "Server Status\\n\\n Gateway: other\\n Status: Connected\\n"; exit 0 ;;', - ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ' "gateway select nemoclaw") exit 1 ;;', - ' "gateway start --name nemoclaw --port 8080") exit 1 ;;', - ' "sandbox list") echo "queried wrong gateway sandbox list" >> "$marker_file"; exit 0 ;;', - "esac", - ]); - - const r = setup.runDoctor("alpha doctor"); - - expect(r.code).toBe(1); - expect(r.out).toContain("OpenShell status"); - expect(r.out).toContain("Gateway: other"); - expect(setup.readCalls().some((call) => /^sandbox list(\s|$)/.test(call))).toBe(false); - }); - - it("doctor treats a live non-cloudflared PID as stale", () => { - const { sandboxName, serviceDir } = createCloudflaredServiceDir("doctorpid-"); - const setup = createDoctorTestSetup( - "nemoclaw-cli-doctor-wrong-cloudflared-pid-", - [ - 'case "$*" in', - ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', - ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ` "sandbox list") printf "NAME STATUS\\n${sandboxName} Ready\\n"; exit 0 ;;`, - ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', - "esac", - ], - sandboxName, - ); - const sleeper = spawn(process.execPath, ["-e", "setTimeout(() => {}, 30000)"], { - stdio: "ignore", - }); - const sleeperPid = sleeper.pid; - if (typeof sleeperPid !== "number") { - throw new Error("expected spawned helper process to have a PID"); - } - - try { - fs.writeFileSync(path.join(serviceDir, "cloudflared.pid"), String(sleeperPid)); - const r = setup.runDoctor(`${sandboxName} doctor --json`); - - const report = JSON.parse(r.out) as { - checks: Array<{ label: string; status: string; detail: string }>; - }; - const cloudflared = report.checks.find((check) => check.label === "cloudflared"); - expect(cloudflared).toEqual( - expect.objectContaining({ - status: "warn", - detail: `stale PID ${sleeperPid}`, - }), - ); - } finally { - sleeper.kill(); - fs.rmSync(serviceDir, { recursive: true, force: true }); - } - }); - - it("doctor accepts a live cloudflared PID", testTimeoutOptions(35_000), () => { - const { sandboxName, serviceDir } = createCloudflaredServiceDir("doctorcloudflared-"); - const setup = createDoctorTestSetup( - "nemoclaw-cli-doctor-cloudflared-pid-", - [ - 'case "$*" in', - ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', - ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ` "sandbox list") printf "NAME STATUS\\n${sandboxName} Ready\\n"; exit 0 ;;`, - ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', - "esac", - ], - sandboxName, - ); - const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cloudflared-shim-")); - const cloudflaredBin = path.join(shimDir, "cloudflared"); - fs.symlinkSync(process.execPath, cloudflaredBin); - const sleeper = spawn(cloudflaredBin, ["-e", "setTimeout(() => {}, 30000)"], { - stdio: "ignore", - }); - const sleeperPid = sleeper.pid; - if (typeof sleeperPid !== "number") { - throw new Error("expected spawned helper process to have a PID"); - } - - try { - fs.writeFileSync(path.join(serviceDir, "cloudflared.pid"), String(sleeperPid)); - const r = setup.runDoctor(`${sandboxName} doctor --json`); - - const report = JSON.parse(r.out) as { - checks: Array<{ label: string; status: string; detail: string }>; - }; - const cloudflared = report.checks.find((check) => check.label === "cloudflared"); - expect(cloudflared).toEqual( - expect.objectContaining({ - status: "ok", - detail: `running (PID ${sleeperPid})`, - }), - ); - } finally { - sleeper.kill(); - fs.rmSync(serviceDir, { recursive: true, force: true }); - fs.rmSync(shimDir, { recursive: true, force: true }); - } - }); - - it("sandbox inspection help uses native oclif usage", testTimeoutOptions(15_000), () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-inspection-help-")); - writeSandboxRegistry(home); - - const connect = runWithEnv("alpha connect --help", { HOME: home }); - expect(connect.code).toBe(0); - expect(connect.out).toContain("Usage: nemoclaw alpha connect"); - expect(connect.out).not.toContain("sandbox:connect"); - - const status = runWithEnv("alpha status --help", { HOME: home }); - expect(status.code).toBe(0); - expect(status.out).toContain("$ nemoclaw sandbox status "); - - const doctor = runWithEnv("alpha doctor --help", { HOME: home }); - expect(doctor.code).toBe(0); - expect(doctor.out).toContain("$ nemoclaw sandbox doctor [--json]"); - - const logs = runWithEnv("alpha logs --help", { HOME: home }); - expect(logs.code).toBe(0); - expect(logs.out).toContain("$ nemoclaw sandbox logs "); - expect(logs.out).toContain("--follow"); - expect(logs.out).toContain("--tail"); - expect(logs.out).toContain("--since"); - - const destroy = runWithEnv("alpha destroy --help", { HOME: home }); - expect(destroy.code).toBe(0); - expect(destroy.out).toContain("$ nemoclaw sandbox destroy "); - - const rebuild = runWithEnv("alpha rebuild --help", { HOME: home }); - expect(rebuild.code).toBe(0); - expect(rebuild.out).toContain("$ nemoclaw sandbox rebuild "); - - for (const action of ["policy-add", "policy-remove", "policy-list"]) { - const policy = runWithEnv(`alpha ${action} --help`, { HOME: home }); - expect(policy.code).toBe(0); - expect(policy.out).toContain("$ nemoclaw sandbox "); - } - - for (const action of ["hosts-add", "hosts-list", "hosts-remove"]) { - const hosts = runWithEnv(`alpha ${action} --help`, { HOME: home }); - expect(hosts.code).toBe(0); - expect(hosts.out).toContain("$ nemoclaw sandbox hosts "); - } - - const channels = runWithEnv("alpha channels list --help", { HOME: home }); - expect(channels.code).toBe(0); - expect(channels.out).toContain("$ nemoclaw sandbox channels list "); - - for (const subcommand of ["add", "remove", "stop", "start"]) { - const result = runWithEnv(`alpha channels ${subcommand} --help`, { HOME: home }); - expect(result.code).toBe(0); - expect(result.out).toContain(`$ nemoclaw sandbox channels ${subcommand} `); - } - - const config = runWithEnv("alpha config get --help", { HOME: home }); - expect(config.code).toBe(0); - expect(config.out).toContain("$ nemoclaw sandbox config get "); - expect(config.out).toContain("--format json|yaml"); - }); - - it("policy mutation dry-run paths dispatch through oclif", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-policy-dry-run-")); - writeSandboxRegistry(home); - - const add = runWithEnv("alpha policy-add github --dry-run", { HOME: home }); - expect(add.code).toBe(0); - expect(add.out).toContain("--dry-run: no changes applied."); - - const registryPath = path.join(home, ".nemoclaw", "sandboxes.json"); - const registryJson = JSON.parse(fs.readFileSync(registryPath, "utf8")); - registryJson.sandboxes.alpha.policies = ["github"]; - fs.writeFileSync(registryPath, JSON.stringify(registryJson), { mode: 0o600 }); - - const remove = runWithEnv("alpha policy-remove github --dry-run", { HOME: home }); - expect(remove.code).toBe(0); - expect(remove.out).toContain("--dry-run: no changes applied."); - }); - - it( - "channels mutation dry-run paths dispatch through oclif", - testTimeoutOptions(15_000), - () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-channels-dry-run-")); - writeSandboxRegistry(home); - - const add = runWithEnv("alpha channels add telegram --dry-run", { HOME: home }); - expect(add.code).toBe(0); - expect(add.out).toContain("--dry-run: would enable channel 'telegram' for 'alpha'."); - - const addMixedCase = runWithEnv("alpha channels add Telegram --dry-run", { HOME: home }); - expect(addMixedCase.code).toBe(0); - expect(addMixedCase.out).toContain("--dry-run: would enable channel 'telegram' for 'alpha'."); - - const remove = runWithEnv("alpha channels remove telegram --dry-run", { HOME: home }); - expect(remove.code).toBe(0); - expect(remove.out).toContain("--dry-run: would remove channel 'telegram' for 'alpha'."); - - const removeMixedCase = runWithEnv("alpha channels remove Telegram --dry-run", { - HOME: home, - }); - expect(removeMixedCase.code).toBe(0); - expect(removeMixedCase.out).toContain( - "--dry-run: would remove channel 'telegram' for 'alpha'.", - ); - - const stop = runWithEnv("alpha channels stop telegram --dry-run", { HOME: home }); - expect(stop.code).toBe(0); - expect(stop.out).toContain("--dry-run: would stop channel 'telegram' for 'alpha'."); - - const start = runWithEnv("alpha channels start telegram --dry-run", { HOME: home }); - expect(start.code).toBe(0); - expect(start.out).toContain("Channel 'telegram' is already enabled for 'alpha'. Nothing to do."); - }, - ); - - it("sandbox channels start rejects a sandbox missing from the registry (#4584)", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-channels-missing-")); - writeSandboxRegistry(home); - // The native `sandbox channels start ` grammar reaches - // sandboxChannelsSetEnabled directly, bypassing the public-route existence - // guard. For a missing sandbox the start path short-circuited as - // "already enabled ... Nothing to do" and exited 0, while stop exited 1. - const startMissing = runWithEnv("sandbox channels start does-not-exist telegram", { HOME: home }); - expect(startMissing.code).toBe(1); - expect(startMissing.out).toContain("Sandbox 'does-not-exist' not found in the registry."); - const stopMissing = runWithEnv("sandbox channels stop does-not-exist telegram", { HOME: home }); - expect(stopMissing.code).toBe(1); - expect(stopMissing.out).toContain("Sandbox 'does-not-exist' not found in the registry."); - }); - - it("adds host aliases with a sandbox json patch", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-hosts-add-")); - const localBin = path.join(home, "bin"); - const dockerLog = path.join(home, "docker.log"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home); - fs.writeFileSync( - path.join(localBin, "docker"), - [ - "#!/usr/bin/env bash", - `log_file=${JSON.stringify(dockerLog)}`, - 'printf "%s\\n" "$@" >> "$log_file"', - 'if [ "$1" = "ps" ]; then', - ' printf "%s\\n" "openshell-cluster-nemoclaw"', - " exit 0", - "fi", - 'if printf "%s\\n" "$@" | grep -q "^get$"; then', - ' printf "%s\\n" \'{"metadata":{"resourceVersion":"123"},"spec":{"podTemplate":{"spec":{"hostAliases":[{"ip":"10.0.0.5","hostnames":["old.local"]}]}}}}\'', - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("alpha hosts-add searxng.local 192.168.1.105", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(0); - expect(r.out).toContain("Added host alias searxng.local -> 192.168.1.105"); - const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); - // The docker invocation targeting the legacy gateway container must use - // the `exec` subcommand. Without it, docker parses kubectl's `-n` as a - // docker flag and exits 125 ("unknown shorthand flag: 'n' in -n"). The - // legacy-gateway runtime probe runs `docker ps --format {{.Names}}` - // first, so check the subcommand position relative to `kubectl` rather - // than at index 0, and check that the probe argv has the expected - // unfiltered shape (no fragile `--filter name=^...$` regex anchors). - const psIndex = log.indexOf("ps"); - expect(psIndex).toBe(0); - expect(log[psIndex + 1]).toBe("--format"); - expect(log[psIndex + 2]).toBe("{{.Names}}"); - expect(log).not.toContain("--filter"); - const kubectlIndex = log.indexOf("kubectl"); - expect(kubectlIndex).toBeGreaterThan(psIndex); - expect(log[kubectlIndex - 1]).toBe("openshell-cluster-nemoclaw"); - expect(log[kubectlIndex - 2]).toBe("exec"); - expect(log).toContain("patch"); - expect(log).toContain("--type=json"); - const patch = JSON.parse(log[log.indexOf("-p") + 1]); - expect(patch[0]).toEqual({ - op: "test", - path: "/metadata/resourceVersion", - value: "123", - }); - expect(patch[1]).toEqual({ - op: "replace", - path: "/spec/podTemplate/spec/hostAliases", - value: [ - { ip: "10.0.0.5", hostnames: ["old.local"] }, - { ip: "192.168.1.105", hostnames: ["searxng.local"] }, - ], - }); - }); - - it("lists host aliases from the sandbox resource", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-hosts-list-")); - const localBin = path.join(home, "bin"); - const dockerLog = path.join(home, "docker.log"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home); - fs.writeFileSync( - path.join(localBin, "docker"), - [ - "#!/usr/bin/env bash", - `log_file=${JSON.stringify(dockerLog)}`, - 'printf "%s\\n" "$@" >> "$log_file"', - 'if [ "$1" = "ps" ]; then', - ' printf "%s\\n" "openshell-cluster-nemoclaw"', - " exit 0", - "fi", - 'printf "%s\\n" \'{"metadata":{"resourceVersion":"123"},"spec":{"podTemplate":{"spec":{"hostAliases":[{"ip":"192.168.1.105","hostnames":["searxng.local","search.lan"]}]}}}}\'', - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("alpha hosts-list", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(0); - expect(r.out).toContain("Host aliases for 'alpha'"); - expect(r.out).toContain("192.168.1.105 searxng.local, search.lan"); - const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); - const kubectlIndex = log.indexOf("kubectl"); - expect(kubectlIndex).toBeGreaterThan(1); - expect(log[kubectlIndex - 1]).toBe("openshell-cluster-nemoclaw"); - expect(log[kubectlIndex - 2]).toBe("exec"); - expect(log).toContain("get"); - }); - - it("removes host aliases with a sandbox json patch", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-hosts-remove-")); - const localBin = path.join(home, "bin"); - const dockerLog = path.join(home, "docker.log"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home); - writeHostAliasDockerStub(localBin, dockerLog, [ - { ip: "10.0.0.5", hostnames: ["searxng.local", "old.local"] }, - { ip: "192.168.1.10", hostnames: ["keep.local"] }, - ]); - - const r = runWithEnv("alpha hosts-remove searxng.local", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(0); - expect(r.out).toContain("Removed host alias searxng.local"); - const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); - const kubectlIndex = log.indexOf("kubectl"); - expect(kubectlIndex).toBeGreaterThan(1); - expect(log[kubectlIndex - 1]).toBe("openshell-cluster-nemoclaw"); - expect(log[kubectlIndex - 2]).toBe("exec"); - expect(log).toContain("patch"); - const patch = JSON.parse(log[log.lastIndexOf("-p") + 1]); - expect(patch[0]).toEqual({ - op: "test", - path: "/metadata/resourceVersion", - value: "123", - }); - expect(patch[1]).toEqual({ - op: "replace", - path: "/spec/podTemplate/spec/hostAliases", - value: [ - { ip: "10.0.0.5", hostnames: ["old.local"] }, - { ip: "192.168.1.10", hostnames: ["keep.local"] }, - ], - }); - }); - - it("rejects duplicate host aliases case-insensitively", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-hosts-duplicate-")); - const localBin = path.join(home, "bin"); - const dockerLog = path.join(home, "docker.log"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home); - writeHostAliasDockerStub(localBin, dockerLog, [ - { ip: "10.0.0.5", hostnames: ["SearXNG.local"] }, - ]); - - const r = runWithEnv("alpha hosts-add searxng.local 192.168.1.105", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(1); - expect(r.out).toContain("Host alias 'searxng.local' already exists"); - const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); - expect(log).not.toContain("patch"); - }); - - it("previews host alias changes with dry-run without patching", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-hosts-dry-run-")); - const localBin = path.join(home, "bin"); - const dockerLog = path.join(home, "docker.log"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home); - writeHostAliasDockerStub(localBin, dockerLog, [ - { ip: "10.0.0.5", hostnames: ["searxng.local", "old.local"] }, - ]); - - const add = runWithEnv("alpha hosts-add dry.local 192.168.1.105 --dry-run", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - const remove = runWithEnv("alpha hosts-remove searxng.local --dry-run", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(add.code).toBe(0); - expect(add.out).toContain('\"/metadata/resourceVersion\"'); - expect(add.out).toContain('\"/spec/podTemplate/spec/hostAliases\"'); - expect(add.out).toContain('\"dry.local\"'); - expect(add.out).toContain('\"192.168.1.105\"'); - expect(remove.code).toBe(0); - expect(remove.out).toContain('\"/metadata/resourceVersion\"'); - expect(remove.out).toContain('\"/spec/podTemplate/spec/hostAliases\"'); - expect(remove.out).toContain('\"old.local\"'); - expect(remove.out).not.toContain('\"searxng.local\"'); - const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); - expect(log).not.toContain("patch"); - }); - - it("rejects unknown host alias flags without patching", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-hosts-unknown-flag-")); - const localBin = path.join(home, "bin"); - const dockerLog = path.join(home, "docker.log"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home); - writeHostAliasDockerStub(localBin, dockerLog, [ - { ip: "10.0.0.5", hostnames: ["searxng.local"] }, - ]); - - const add = runWithEnv("alpha hosts-add searxng.local 192.168.1.105 --dry-rnu", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - const remove = runWithEnv("alpha hosts-remove searxng.local --force", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(add.code).not.toBe(0); - expect(add.out).toContain("Nonexistent flag: --dry-rnu"); - expect(remove.code).not.toBe(0); - expect(remove.out).toContain("Nonexistent flag: --force"); - expect(fs.existsSync(dockerLog)).toBe(false); - }); - - it("retries host alias patches when the resource version changes", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-hosts-retry-")); - const localBin = path.join(home, "bin"); - const dockerLog = path.join(home, "docker.log"); - const getCount = path.join(home, "get-count"); - const patchCount = path.join(home, "patch-count"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home); - fs.writeFileSync( - path.join(localBin, "docker"), - [ - "#!/usr/bin/env bash", - `log_file=${JSON.stringify(dockerLog)}`, - `get_count=${JSON.stringify(getCount)}`, - `patch_count=${JSON.stringify(patchCount)}`, - 'printf "%s\\n" "$@" >> "$log_file"', - 'if [ "$1" = "ps" ]; then', - ' printf "%s\\n" "openshell-cluster-nemoclaw"', - " exit 0", - "fi", - 'if printf "%s\\n" "$@" | grep -q "^get$"; then', - ' count=$(cat "$get_count" 2>/dev/null || echo 0)', - " count=$((count + 1))", - ' printf "%s" "$count" > "$get_count"', - ' if [ "$count" = "1" ]; then version=123; else version=124; fi', - ' printf \'{"metadata":{"resourceVersion":"%s"},"spec":{"podTemplate":{"spec":{"hostAliases":[{"ip":"10.0.0.5","hostnames":["old.local"]}]}}}}\\n\' "$version"', - " exit 0", - "fi", - 'if printf "%s\\n" "$@" | grep -q "^patch$"; then', - ' count=$(cat "$patch_count" 2>/dev/null || echo 0)', - " count=$((count + 1))", - ' printf "%s" "$count" > "$patch_count"', - ' if [ "$count" = "1" ]; then', - ' echo "Operation cannot be fulfilled: the object has been modified" >&2', - " exit 1", - " fi", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("alpha hosts-add retry.local 192.168.1.105", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - - expect(r.code).toBe(0); - expect(r.out).toContain("Added host alias retry.local -> 192.168.1.105"); - expect(fs.readFileSync(getCount, "utf8")).toBe("2"); - expect(fs.readFileSync(patchCount, "utf8")).toBe("2"); - const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); - const patchArgs = log.filter((line) => line.startsWith("[")); - const finalPatch = patchArgs.at(-1); - expect(finalPatch).toBeDefined(); - expect(JSON.parse(finalPatch!)[0]).toEqual({ - op: "test", - path: "/metadata/resourceVersion", - value: "124", - }); - }); - - for (const driver of ["docker", "vm"] as const) { - it(`gates host alias commands on the ${driver} driver without targeting the legacy gateway container`, testTimeoutOptions(30_000), () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), `nemoclaw-cli-hosts-${driver}-`), - ); - const localBin = path.join(home, "bin"); - const dockerLog = path.join(home, "docker.log"); - fs.mkdirSync(localBin, { recursive: true }); - // Record any docker invocation so we can prove the gate fires before - // the legacy `docker exec openshell-cluster-nemoclaw kubectl` path. - writeHostAliasDockerStub(localBin, dockerLog, [ - { ip: "10.0.0.5", hostnames: ["old.local"] }, - ]); - writeSandboxRegistry(home, "alpha", { openshellDriver: driver }); - - const env = { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}` }; - const list = runWithEnv("alpha hosts-list", env); - const add = runWithEnv("alpha hosts-add searxng.local 192.168.1.105", env); - const remove = runWithEnv("alpha hosts-remove searxng.local", env); - - for (const result of [list, add, remove]) { - expect(result.code).toBe(1); - expect(result.out).toContain( - `Host aliases are not supported on the '${driver}' driver sandbox 'alpha'.`, - ); - } - - // Even the dry-run preview must not reach the legacy resource read. - const dryRun = runWithEnv( - "alpha hosts-add searxng.local 192.168.1.105 --dry-run", - env, - ); - expect(dryRun.code).toBe(1); - expect(dryRun.out).not.toContain("/spec/podTemplate/spec/hostAliases"); - - // The gate runs before any docker exec, so the legacy gateway container - // is never targeted. - expect(fs.existsSync(dockerLog)).toBe(false); - }); - } - - it( - "fails host alias commands with an actionable error when the legacy gateway container is not running", - testTimeoutOptions(30_000), - () => { - // A sandbox onboarded by an older NemoClaw release whose registry - // entry predates the openshellDriver field, on a host where the - // legacy `openshell-cluster-nemoclaw` k3s gateway is not running. - // Without the runtime probe, `docker exec openshell-cluster-nemoclaw - // kubectl ...` bubbles up an opaque `Error response from daemon: No - // such container: openshell-cluster-nemoclaw` to the user. - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-cli-hosts-no-gateway-"), - ); - const localBin = path.join(home, "bin"); - const dockerLog = path.join(home, "docker.log"); - fs.mkdirSync(localBin, { recursive: true }); - writeHostAliasDockerStub( - localBin, - dockerLog, - [{ ip: "10.0.0.5", hostnames: ["old.local"] }], - { gatewayRunning: false }, - ); - // Registry omits openshellDriver to mimic a pre-feature sandbox entry. - writeSandboxRegistry(home); - - const env = { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}` }; - const list = runWithEnv("alpha hosts-list", env); - const add = runWithEnv("alpha hosts-add searxng.local 192.168.1.105", env); - const remove = runWithEnv("alpha hosts-remove searxng.local", env); - - for (const result of [list, add, remove]) { - expect(result.code).toBe(1); - expect(result.out).toContain( - "Host aliases require the legacy OpenShell gateway container 'openshell-cluster-nemoclaw' to be running.", - ); - expect(result.out).not.toContain("Error response from daemon"); - expect(result.out).not.toContain("No such container"); - } - - const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); - // Probe argv must be the unfiltered `docker ps --format {{.Names}}` - // shape. No exec/get/patch reached the missing container. - expect(log[0]).toBe("ps"); - expect(log[1]).toBe("--format"); - expect(log[2]).toBe("{{.Names}}"); - expect(log).not.toContain("--filter"); - expect(log).not.toContain("exec"); - expect(log).not.toContain("kubectl"); - expect(log).not.toContain("get"); - expect(log).not.toContain("patch"); - }, - ); - - it( - "validates host alias arguments before probing the legacy gateway", - testTimeoutOptions(30_000), - () => { - // Arg validation (missing args, bad hostname, bad IP) must run before - // the legacy-gateway probe, so a missing legacy gateway never masks - // an invalid-input failure that would otherwise reach the user. - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-cli-hosts-validate-first-"), - ); - const localBin = path.join(home, "bin"); - const dockerLog = path.join(home, "docker.log"); - fs.mkdirSync(localBin, { recursive: true }); - writeHostAliasDockerStub(localBin, dockerLog, [], { gatewayRunning: false }); - writeSandboxRegistry(home); - - const env = { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}` }; - - const badHostnameAdd = runWithEnv("alpha hosts-add invalid_name!! 1.2.3.4", env); - expect(badHostnameAdd.code).toBe(1); - expect(badHostnameAdd.out).toContain("Invalid hostname 'invalid_name!!'"); - expect(badHostnameAdd.out).not.toContain("Host aliases require the legacy"); - - const badIpAdd = runWithEnv("alpha hosts-add searxng.local not-an-ip", env); - expect(badIpAdd.code).toBe(1); - expect(badIpAdd.out).toContain("Invalid IP address 'not-an-ip'"); - expect(badIpAdd.out).not.toContain("Host aliases require the legacy"); - - const badHostnameRemove = runWithEnv("alpha hosts-remove invalid_name!!", env); - expect(badHostnameRemove.code).toBe(1); - expect(badHostnameRemove.out).toContain("Invalid hostname 'invalid_name!!'"); - expect(badHostnameRemove.out).not.toContain("Host aliases require the legacy"); - - // No docker probe runs when validation fails up front. - expect(fs.existsSync(dockerLog)).toBe(false); - }, - ); - - it( - "classifies docker spawn ENOENT distinctly from a missing gateway", - testTimeoutOptions(30_000), - () => { - // When the docker binary is absent from PATH, spawnSync returns - // error.code === "ENOENT". The probe must surface a docker-could- - // not-launch error rather than the legacy-gateway-missing error. - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-cli-hosts-docker-enoent-"), - ); - const emptyBin = path.join(home, "nodocker"); - fs.mkdirSync(emptyBin, { recursive: true }); - // The shell that execSync forks needs to find `node`. Symlink the - // running node executable into the otherwise-empty bin so the shell - // can launch the CLI; docker remains absent from this PATH so the - // CLI's `spawnSync("docker", ...)` returns ENOENT. - fs.symlinkSync(process.execPath, path.join(emptyBin, "node")); - writeSandboxRegistry(home); - - const env = { HOME: home, PATH: emptyBin }; - const list = runWithEnv("alpha hosts-list", env); - expect(list.code).toBe(1); - expect(list.out).toContain( - "Could not verify the legacy OpenShell gateway container 'openshell-cluster-nemoclaw'.", - ); - expect(list.out).toContain("Docker probe failed:"); - expect(list.out).toContain("could not launch"); - expect(list.out).not.toContain( - "Host aliases require the legacy OpenShell gateway container 'openshell-cluster-nemoclaw' to be running.", - ); - }, - ); - - it( - "classifies docker probe timeouts distinctly from a missing gateway", - testTimeoutOptions(60_000), - () => { - // When `docker ps` hangs past the probe timeout, spawnSync kills it - // and reports ETIMEDOUT (or a terminating SIGTERM with no exit). - // The probe must surface a docker-timed-out error rather than the - // legacy-gateway-missing error. - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-cli-hosts-docker-timeout-"), - ); - const localBin = path.join(home, "bin"); - const dockerLog = path.join(home, "docker.log"); - fs.mkdirSync(localBin, { recursive: true }); - fs.writeFileSync( - path.join(localBin, "docker"), - [ - "#!/usr/bin/env bash", - `log_file=${JSON.stringify(dockerLog)}`, - 'printf "%s\\n" "$@" >> "$log_file"', - 'if [ "$1" = "ps" ]; then', - " sleep 20", - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - writeSandboxRegistry(home); - - const env = { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}` }; - const list = runWithEnv("alpha hosts-list", env, 45_000); - expect(list.code).toBe(1); - expect(list.out).toContain( - "Could not verify the legacy OpenShell gateway container 'openshell-cluster-nemoclaw'.", - ); - expect(list.out).toContain("Docker probe failed:"); - expect(list.out).not.toContain( - "Host aliases require the legacy OpenShell gateway container 'openshell-cluster-nemoclaw' to be running.", - ); - - const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); - expect(log[0]).toBe("ps"); - expect(log).not.toContain("exec"); - expect(log).not.toContain("kubectl"); - }, - ); - - it( - "classifies docker probe failures distinctly from a missing gateway", - testTimeoutOptions(30_000), - () => { - // When `docker ps` itself fails (daemon down, permission denied, - // timeout), the user must see a docker-probe-failed error rather than - // the legacy-gateway-missing error. - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-cli-hosts-docker-down-"), - ); - const localBin = path.join(home, "bin"); - const dockerLog = path.join(home, "docker.log"); - fs.mkdirSync(localBin, { recursive: true }); - fs.writeFileSync( - path.join(localBin, "docker"), - [ - "#!/usr/bin/env bash", - `log_file=${JSON.stringify(dockerLog)}`, - 'printf "%s\\n" "$@" >> "$log_file"', - 'if [ "$1" = "ps" ]; then', - ' printf "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?\\n" >&2', - " exit 1", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - writeSandboxRegistry(home); - - const env = { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}` }; - const list = runWithEnv("alpha hosts-list", env); - expect(list.code).toBe(1); - expect(list.out).toContain( - "Could not verify the legacy OpenShell gateway container 'openshell-cluster-nemoclaw'.", - ); - expect(list.out).toContain("Docker probe failed:"); - expect(list.out).toContain("docker info"); - expect(list.out).not.toContain( - "Host aliases require the legacy OpenShell gateway container 'openshell-cluster-nemoclaw' to be running.", - ); - - const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); - expect(log[0]).toBe("ps"); - expect(log).not.toContain("exec"); - expect(log).not.toContain("kubectl"); - }, - ); - - it("supports oclif-native sandbox command forms", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-native-sandbox-")); - writeSandboxRegistry(home); - - const statusHelp = runWithEnv("sandbox status alpha --help", { HOME: home }); - expect(statusHelp.code).toBe(0); - expect(statusHelp.out).toContain("$ nemoclaw sandbox status "); - expect(statusHelp.out).not.toContain("Sandbox 'sandbox' does not exist"); - - const policy = runWithEnv("sandbox policy add alpha github --dry-run", { HOME: home }); - expect(policy.code).toBe(0); - expect(policy.out).toContain("--dry-run: no changes applied."); - - const channels = runWithEnv("sandbox channels add alpha telegram --dry-run", { HOME: home }); - expect(channels.code).toBe(0); - expect(channels.out).toContain("--dry-run: would enable channel 'telegram' for 'alpha'."); - - const snapshots = runWithEnv("sandbox snapshot list alpha", { HOME: home }); - expect(snapshots.code).toBe(0); - expect(snapshots.out).toContain("No snapshots found for 'alpha'."); - }); - - it( - "policy and channel mutations reject missing parser-owned values before dispatch", - testTimeoutOptions(30_000), - () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-cli-mutation-missing-values-"), - ); - writeSandboxRegistry(home); - - const missingPolicyFile = runWithEnv("alpha policy-add --from-file 2>&1", { - HOME: home, - }); - expect(missingPolicyFile.code).not.toBe(0); - expect(missingPolicyFile.out).toContain("--from-file"); - - for (const action of ["add", "remove", "start", "stop"]) { - const missingChannel = runWithEnv(`alpha channels ${action} 2>&1`, { HOME: home }); - expect(missingChannel.code).toBe(PARSER_EXIT_CODE); - expect(missingChannel.out).toContain("Missing 1 required arg:"); - expect(missingChannel.out).toContain("channel Messaging channel"); - expect(missingChannel.out).toContain("USAGE"); - expect(missingChannel.out).toContain( - `$ nemoclaw sandbox channels ${action} [--dry-run]`, - ); - expect(missingChannel.out).not.toContain("RequiredArgsError"); - expect(missingChannel.out).not.toContain("at validateArgs"); - expect(missingChannel.out).not.toContain(`Command alpha:channels:${action} not found`); - } - }, - ); - - it("diagnostic commands reject invalid parser-owned flags before dispatch", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-diagnostics-invalid-flags-")); - writeSandboxRegistry(home); - - const badConfigFormat = runWithEnv("alpha config get --format xml 2>&1", { HOME: home }); - expect(badConfigFormat.code).not.toBe(0); - expect(badConfigFormat.out).toContain("--format"); - expect(badConfigFormat.out).toContain("json"); - expect(badConfigFormat.out).toContain("yaml"); - - const badDoctorFlag = runWithEnv("alpha doctor --bogus 2>&1", { HOME: home }); - expect(badDoctorFlag.code).not.toBe(0); - expect(badDoctorFlag.out).toContain("Nonexistent flag: --bogus"); - }); - - it("shields help uses native oclif usage", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-shields-help-")); - writeSandboxRegistry(home); - - const down = runWithEnv("alpha shields down --help", { HOME: home }); - expect(down.code).toBe(0); - expect(down.out).toContain("$ nemoclaw sandbox shields down "); - - const up = runWithEnv("alpha shields up --help", { HOME: home }); - expect(up.code).toBe(0); - expect(up.out).toContain("$ nemoclaw sandbox shields up "); - - const status = runWithEnv("alpha shields status --help", { HOME: home }); - expect(status.code).toBe(0); - expect(status.out).toContain("$ nemoclaw sandbox shields status "); - }); - - it("snapshot subcommand help uses native oclif usage", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-snapshot-help-")); - writeSandboxRegistry(home); - - const parent = runWithEnv("alpha snapshot --help", { HOME: home }); - expect(parent.code).toBe(0); - expect(parent.out).toContain("$ nemoclaw sandbox snapshot "); - expect(parent.out).toContain("sandbox snapshot create"); - expect(parent.out).toContain("sandbox snapshot list"); - - const list = runWithEnv("alpha snapshot list --help", { HOME: home }); - expect(list.code).toBe(0); - expect(list.out).toContain("$ nemoclaw sandbox snapshot list "); - - const create = runWithEnv("alpha snapshot create --help", { HOME: home }); - expect(create.code).toBe(0); - expect(create.out).toContain("$ nemoclaw sandbox snapshot create [--name