From 26eaeba84273bfba0cb35c3d5c119d33cb008c89 Mon Sep 17 00:00:00 2001 From: Dhaval Dave Date: Tue, 24 Mar 2026 21:01:44 +0530 Subject: [PATCH 1/7] feat: add Podman as supported container runtime for macOS and Linux Remove the isUnsupportedMacosRuntime() block that prevented Podman from being used on macOS. Add Podman socket auto-detection for both macOS and Linux. Extend CoreDNS patching to Podman runtimes -- K3s-inside- Podman has the same nested DNS issue as Colima where CoreDNS forwards to 127.0.0.11 (unreachable from pods). The host-gateway limitation that originally motivated the block has been resolved in OpenShell. Podman with rootful mode and the Docker CLI works end-to-end as a drop-in Docker replacement, verified with live inference on macOS Apple Silicon with Podman 5.4.1. Changes: - platform.js: remove isUnsupportedMacosRuntime, add getPodmanSocketCandidates, update shouldPatchCoredns for podman - onboard.js: remove Podman guard clause, extend CoreDNS patching - runtime.sh: remove is_unsupported_macos_runtime, add find_podman_socket, update detect_docker_host and docker_host_runtime for Podman sockets - setup.sh: remove Podman block, extend CoreDNS condition - fix-coredns.sh: detect Podman socket, pass runtime to DNS resolver - smoke-macos-install.sh: add podman to select_runtime - README.md: update compatibility table - troubleshooting.md: add Podman sections (rootful, timeout, resources) - .shellcheckrc: enable external-sources with SCRIPTDIR path resolution - Tests updated for all changes (62 tests passing) Addresses: #420, #116, #260, #50 Made-with: Cursor --- .shellcheckrc | 14 +++--- README.md | 5 +-- bin/lib/onboard.js | 10 +---- bin/lib/platform.js | 36 ++++++++++++--- docs/reference/troubleshooting.md | 53 ++++++++++++++++++++++ scripts/fix-coredns.sh | 30 ++++++++----- scripts/lib/runtime.sh | 41 +++++++++++++++-- scripts/setup.sh | 9 ++-- scripts/smoke-macos-install.sh | 9 +++- test/platform.test.js | 73 ++++++++++++++++++++++++------- test/runtime-shell.test.js | 31 ++++++++++--- test/smoke-macos-install.test.js | 19 +++++++- 12 files changed, 263 insertions(+), 67 deletions(-) diff --git a/.shellcheckrc b/.shellcheckrc index 709c50eed..e85d57ffb 100644 --- a/.shellcheckrc +++ b/.shellcheckrc @@ -2,8 +2,12 @@ # Our info/warn/ok/error helpers and banner intentionally embed ANSI color # variables in printf format strings. This is safe because the variables # contain only escape sequences, never user input. -# -# SC1091: Not following sourced file. -# We dynamically source nvm.sh and runtime.sh at paths that shellcheck -# cannot resolve statically. -disable=SC2059,SC1091 +disable=SC2059 + +# Allow shellcheck to follow source directives (e.g. `. "$DIR/lib/runtime.sh"`). +# This replaces the SC1091 suppression with proper source resolution. +external-sources=true + +# Resolve source-path hints relative to the script's own directory, +# matching the runtime behavior of `SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"`. +source-path=SCRIPTDIR diff --git a/README.md b/README.md index 38bdb4c23..76b56656f 100644 --- a/README.md +++ b/README.md @@ -60,9 +60,8 @@ The sandbox image is approximately 2.4 GB compressed. During image push, the Doc | Platform | Supported runtimes | Notes | |----------|--------------------|-------| -| Linux | Docker | Primary supported path today | -| macOS (Apple Silicon) | Colima, Docker Desktop | Recommended runtimes for supported macOS setups | -| macOS | Podman | Not supported yet. NemoClaw currently depends on OpenShell support for Podman on macOS. | +| Linux | Docker, Podman | Primary supported path today | +| macOS (Apple Silicon) | Colima, Docker Desktop, Podman | Supported container runtimes on macOS | | Windows WSL | Docker Desktop (WSL backend) | Supported target path | > **💡 Tip** diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 63d34a253..4bd5c2866 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -27,7 +27,6 @@ const { } = require("./inference-config"); const { inferContainerRuntime, - isUnsupportedMacosRuntime, shouldPatchCoredns, } = require("./platform"); const { resolveOpenshell } = require("./resolve-openshell"); @@ -1177,12 +1176,6 @@ async function preflight() { console.log(" ✓ Docker is running"); const runtime = getContainerRuntime(); - if (isUnsupportedMacosRuntime(runtime)) { - console.error(" Podman on macOS is not supported by NemoClaw at this time."); - console.error(" OpenShell currently depends on Docker host-gateway behavior that Podman on macOS does not provide."); - console.error(" Use Colima or Docker Desktop on macOS instead."); - process.exit(1); - } if (runtime !== "unknown") { console.log(` ✓ Container runtime: ${runtime}`); } @@ -1307,10 +1300,9 @@ async function startGateway(gpu) { sleep(2); } - // CoreDNS fix — always run. k3s-inside-Docker has broken DNS on all platforms. const runtime = getContainerRuntime(); if (shouldPatchCoredns(runtime)) { - console.log(" Patching CoreDNS for Colima..."); + console.log(` Patching CoreDNS for ${runtime}...`); run(`bash "${path.join(SCRIPTS, "fix-coredns.sh")}" ${GATEWAY_NAME} 2>&1 || true`, { ignoreError: true }); } // Give DNS a moment to propagate diff --git a/bin/lib/platform.js b/bin/lib/platform.js index 67c31a3f3..7e71d7fce 100644 --- a/bin/lib/platform.js +++ b/bin/lib/platform.js @@ -30,13 +30,8 @@ function inferContainerRuntime(info = "") { return "unknown"; } -function isUnsupportedMacosRuntime(runtime, opts = {}) { - const platform = opts.platform ?? process.platform; - return platform === "darwin" && runtime === "podman"; -} - function shouldPatchCoredns(runtime) { - return runtime === "colima"; + return runtime === "colima" || runtime === "podman"; } function getColimaDockerSocketCandidates(opts = {}) { @@ -52,6 +47,24 @@ function findColimaDockerSocket(opts = {}) { return getColimaDockerSocketCandidates(opts).find((socketPath) => existsSync(socketPath)) ?? null; } +function getPodmanSocketCandidates(opts = {}) { + const home = opts.home ?? process.env.HOME ?? "/tmp"; + const platform = opts.platform ?? process.platform; + const uid = opts.uid ?? process.getuid?.() ?? 1000; + + if (platform === "darwin") { + return [ + path.join(home, ".local/share/containers/podman/machine/podman.sock"), + "/var/run/docker.sock", + ]; + } + + return [ + `/run/user/${uid}/podman/podman.sock`, + "/run/podman/podman.sock", + ]; +} + function getDockerSocketCandidates(opts = {}) { const home = opts.home ?? process.env.HOME ?? "/tmp"; const platform = opts.platform ?? process.platform; @@ -59,10 +72,19 @@ function getDockerSocketCandidates(opts = {}) { if (platform === "darwin") { return [ ...getColimaDockerSocketCandidates({ home }), + ...getPodmanSocketCandidates({ home, platform }), path.join(home, ".docker/run/docker.sock"), ]; } + if (platform === "linux") { + return [ + ...getPodmanSocketCandidates({ home, platform, uid: opts.uid }), + "/run/docker.sock", + "/var/run/docker.sock", + ]; + } + return []; } @@ -95,8 +117,8 @@ module.exports = { findColimaDockerSocket, getColimaDockerSocketCandidates, getDockerSocketCandidates, + getPodmanSocketCandidates, inferContainerRuntime, - isUnsupportedMacosRuntime, isWsl, shouldPatchCoredns, }; diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index aa654184a..9711984cd 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -189,3 +189,56 @@ $ nemoclaw logs ``` Use `--follow` to stream logs in real time while debugging. + +## Podman + +### `open /dev/kmsg: operation not permitted` + +This error appears when the Podman machine is running in rootless mode. +K3s kubelet requires `/dev/kmsg` access for its OOM watcher, which is not available in rootless containers. + +Switch the Podman machine to rootful mode and restart: + +```console +$ podman machine stop +$ podman machine set --rootful +$ podman machine start +``` + +Then destroy and recreate the gateway: + +```console +$ openshell gateway destroy --name nemoclaw +$ nemoclaw onboard +``` + +### Image push timeout with Podman + +When creating a sandbox, the 1.5 GB sandbox image push into K3s may time out through Podman's API socket. +This is a known limitation of the bollard Docker client's default timeout. + +Manually push the image using the Docker CLI, which has no such timeout: + +```console +$ docker images --format '{{.Repository}}:{{.Tag}}' | grep sandbox-from +$ docker save | \ + docker exec -i openshell-cluster-nemoclaw \ + ctr -a /run/k3s/containerd/containerd.sock -n k8s.io images import - +``` + +After the import completes, create the sandbox manually: + +```console +$ openshell sandbox create --name my-assistant --from +``` + +### Podman machine resources + +The default Podman machine has 2 GB RAM, which is insufficient for the sandbox image push and K3s cluster overhead. +Allocate at least 8 GB RAM and 4 CPUs: + +```console +$ podman machine stop +$ podman machine set --cpus 6 --memory 8192 +$ podman machine start +``` diff --git a/scripts/fix-coredns.sh b/scripts/fix-coredns.sh index 9b587ab33..c1f9f3a81 100755 --- a/scripts/fix-coredns.sh +++ b/scripts/fix-coredns.sh @@ -2,17 +2,17 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Fix CoreDNS on local OpenShell gateways running under Colima. +# Fix CoreDNS on local OpenShell gateways running under Colima or Podman. # # Problem: k3s CoreDNS forwards to /etc/resolv.conf which inside the -# CoreDNS pod resolves to 127.0.0.11 (Docker's embedded DNS). That -# address is NOT reachable from k3s pods, causing DNS to fail and +# CoreDNS pod resolves to 127.0.0.11 (Docker/Podman's embedded DNS). +# That address is NOT reachable from k3s pods, causing DNS to fail and # CoreDNS to CrashLoop. # # Fix: forward CoreDNS to the container's default gateway IP, which -# is reachable from pods and routes DNS through Docker to the host. +# is reachable from pods and routes DNS through Docker/Podman to the host. # -# Run this after `openshell gateway start` on Colima setups. +# Run this after `openshell gateway start` on Colima or Podman setups. # # Usage: ./scripts/fix-coredns.sh [gateway-name] @@ -23,15 +23,25 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=./lib/runtime.sh . "$SCRIPT_DIR/lib/runtime.sh" -COLIMA_SOCKET="$(find_colima_docker_socket || true)" +DETECTED_RUNTIME="unknown" if [ -z "${DOCKER_HOST:-}" ]; then + COLIMA_SOCKET="$(find_colima_docker_socket || true)" if [ -n "$COLIMA_SOCKET" ]; then export DOCKER_HOST="unix://$COLIMA_SOCKET" + DETECTED_RUNTIME="colima" else - echo "Skipping CoreDNS patch: Colima socket not found." - exit 0 + PODMAN_SOCKET="$(find_podman_socket || true)" + if [ -n "$PODMAN_SOCKET" ]; then + export DOCKER_HOST="unix://$PODMAN_SOCKET" + DETECTED_RUNTIME="podman" + else + echo "Skipping CoreDNS patch: no Colima or Podman socket found." + exit 0 + fi fi +else + DETECTED_RUNTIME="$(docker_host_runtime "$DOCKER_HOST" || echo "custom")" fi # Find the cluster container @@ -48,10 +58,10 @@ fi CONTAINER_RESOLV_CONF="$(docker exec "$CLUSTER" cat /etc/resolv.conf 2>/dev/null || true)" HOST_RESOLV_CONF="$(cat /etc/resolv.conf 2>/dev/null || true)" -UPSTREAM_DNS="$(resolve_coredns_upstream "$CONTAINER_RESOLV_CONF" "$HOST_RESOLV_CONF" "colima" || true)" +UPSTREAM_DNS="$(resolve_coredns_upstream "$CONTAINER_RESOLV_CONF" "$HOST_RESOLV_CONF" "$DETECTED_RUNTIME" || true)" if [ -z "$UPSTREAM_DNS" ]; then - echo "ERROR: Could not determine a non-loopback DNS upstream for Colima." + echo "ERROR: Could not determine a non-loopback DNS upstream for $DETECTED_RUNTIME." exit 1 fi diff --git a/scripts/lib/runtime.sh b/scripts/lib/runtime.sh index a6bba65f2..209a53290 100755 --- a/scripts/lib/runtime.sh +++ b/scripts/lib/runtime.sh @@ -9,6 +9,7 @@ socket_exists() { case ":$NEMOCLAW_TEST_SOCKET_PATHS:" in *":$socket_path:"*) return 0 ;; esac + return 1 fi [ -S "$socket_path" ] @@ -56,6 +57,11 @@ detect_docker_host() { return 0 fi + if socket_path="$(find_podman_socket "$home_dir")"; then + printf 'unix://%s\n' "$socket_path" + return 0 + fi + if socket_path="$(find_docker_desktop_socket "$home_dir")"; then printf 'unix://%s\n' "$socket_path" return 0 @@ -71,6 +77,9 @@ docker_host_runtime() { unix://*"/.colima/default/docker.sock" | unix://*"/.config/colima/default/docker.sock") printf 'colima\n' ;; + unix://*"/podman/machine/podman.sock"|unix://*"/podman/podman.sock") + printf 'podman\n' + ;; unix://*"/.docker/run/docker.sock") printf 'docker-desktop\n' ;; @@ -103,11 +112,35 @@ infer_container_runtime_from_info() { fi } -is_unsupported_macos_runtime() { - local platform="${1:-$(uname -s)}" - local runtime="${2:-unknown}" +find_podman_socket() { + local home_dir="${1:-${HOME:-/tmp}}" + local socket_path + + if [ "$(uname -s)" = "Darwin" ]; then + for socket_path in \ + "$home_dir/.local/share/containers/podman/machine/podman.sock" \ + "/var/run/docker.sock" + do + if socket_exists "$socket_path"; then + printf '%s\n' "$socket_path" + return 0 + fi + done + else + local uid + uid="$(id -u 2>/dev/null || echo 1000)" + for socket_path in \ + "/run/user/$uid/podman/podman.sock" \ + "/run/podman/podman.sock" + do + if socket_exists "$socket_path"; then + printf '%s\n' "$socket_path" + return 0 + fi + done + fi - [ "$platform" = "Darwin" ] && [ "$runtime" = "podman" ] + return 1 } is_loopback_ip() { diff --git a/scripts/setup.sh b/scripts/setup.sh index 6aeb68085..22b66e068 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -86,9 +86,6 @@ command -v docker >/dev/null || fail "docker not found" [ -n "${NVIDIA_API_KEY:-}" ] || fail "NVIDIA_API_KEY not set. Get one from build.nvidia.com" CONTAINER_RUNTIME="$(infer_container_runtime_from_info "$(docker info 2>/dev/null || true)")" -if is_unsupported_macos_runtime "$(uname -s)" "$CONTAINER_RUNTIME"; then - fail "Podman on macOS is not supported yet. NemoClaw currently depends on OpenShell support for Podman on macOS. Use Colima or Docker Desktop instead." -fi if [ "$CONTAINER_RUNTIME" != "unknown" ]; then info "Container runtime: $CONTAINER_RUNTIME" fi @@ -123,9 +120,9 @@ for i in 1 2 3 4 5; do done info "Gateway is healthy" -# 2. CoreDNS fix (Colima only) -if [ "$CONTAINER_RUNTIME" = "colima" ]; then - info "Patching CoreDNS for Colima..." +# 2. CoreDNS fix (Colima and Podman — nested K3s DNS is broken with these runtimes) +if [ "$CONTAINER_RUNTIME" = "colima" ] || [ "$CONTAINER_RUNTIME" = "podman" ]; then + info "Patching CoreDNS for $CONTAINER_RUNTIME..." bash "$SCRIPT_DIR/fix-coredns.sh" nemoclaw 2>&1 || warn "CoreDNS patch failed (may not be needed)" fi diff --git a/scripts/smoke-macos-install.sh b/scripts/smoke-macos-install.sh index 9dfd3d0ac..414fb8287 100755 --- a/scripts/smoke-macos-install.sh +++ b/scripts/smoke-macos-install.sh @@ -132,6 +132,13 @@ select_runtime() { export DOCKER_HOST="unix://$socket_path" info "Using runtime 'colima' via $socket_path" ;; + podman) + local socket_path + socket_path="$(find_podman_socket || true)" + [ -n "$socket_path" ] || fail "Requested runtime 'podman', but no Podman socket was found." + export DOCKER_HOST="unix://$socket_path" + info "Using runtime 'podman' via $socket_path" + ;; docker-desktop) local socket_path socket_path="$(find_docker_desktop_socket || true)" @@ -140,7 +147,7 @@ select_runtime() { info "Using runtime 'docker-desktop' via $socket_path" ;; *) - fail "Unsupported runtime '$RUNTIME'. Use 'colima' or 'docker-desktop'." + fail "Unsupported runtime '$RUNTIME'. Use 'colima', 'podman', or 'docker-desktop'." ;; esac } diff --git a/test/platform.test.js b/test/platform.test.js index 0eb85277e..4b626f0ed 100644 --- a/test/platform.test.js +++ b/test/platform.test.js @@ -8,8 +8,8 @@ import { detectDockerHost, findColimaDockerSocket, getDockerSocketCandidates, + getPodmanSocketCandidates, inferContainerRuntime, - isUnsupportedMacosRuntime, isWsl, shouldPatchCoredns, } from "../bin/lib/platform"; @@ -33,18 +33,42 @@ describe("platform helpers", () => { }); }); + describe("getPodmanSocketCandidates", () => { + it("returns macOS Podman socket paths", () => { + const home = "/tmp/test-home"; + assert.deepEqual(getPodmanSocketCandidates({ platform: "darwin", home }), [ + path.join(home, ".local/share/containers/podman/machine/podman.sock"), + "/var/run/docker.sock", + ]); + }); + + it("returns Linux Podman socket paths with uid", () => { + assert.deepEqual(getPodmanSocketCandidates({ platform: "linux", home: "/tmp/test-home", uid: 1001 }), [ + "/run/user/1001/podman/podman.sock", + "/run/podman/podman.sock", + ]); + }); + }); + describe("getDockerSocketCandidates", () => { - it("returns macOS candidates in priority order", () => { + it("returns macOS candidates in priority order (Colima > Podman > Docker Desktop)", () => { const home = "/tmp/test-home"; expect(getDockerSocketCandidates({ platform: "darwin", home })).toEqual([ path.join(home, ".colima/default/docker.sock"), path.join(home, ".config/colima/default/docker.sock"), + path.join(home, ".local/share/containers/podman/machine/podman.sock"), + "/var/run/docker.sock", path.join(home, ".docker/run/docker.sock"), ]); }); - it("does not auto-detect sockets on Linux", () => { - expect(getDockerSocketCandidates({ platform: "linux", home: "/tmp/test-home" })).toEqual([]); + it("returns Linux candidates (Podman > native Docker)", () => { + expect(getDockerSocketCandidates({ platform: "linux", home: "/tmp/test-home", uid: 1000 })).toEqual([ + "/run/user/1000/podman/podman.sock", + "/run/podman/podman.sock", + "/run/docker.sock", + "/var/run/docker.sock", + ]); }); }); @@ -123,21 +147,40 @@ describe("platform helpers", () => { }); }); - describe("isUnsupportedMacosRuntime", () => { - it("flags podman on macOS", () => { - expect(isUnsupportedMacosRuntime("podman", { platform: "darwin" })).toBe(true); - }); - - it("does not flag podman on Linux", () => { - expect(isUnsupportedMacosRuntime("podman", { platform: "linux" })).toBe(false); - }); - }); - describe("shouldPatchCoredns", () => { - it("patches CoreDNS for Colima only", () => { + it("patches CoreDNS for Colima and Podman", () => { expect(shouldPatchCoredns("colima")).toBe(true); + expect(shouldPatchCoredns("podman")).toBe(true); expect(shouldPatchCoredns("docker-desktop")).toBe(false); expect(shouldPatchCoredns("docker")).toBe(false); }); }); + + describe("detectDockerHost with Podman", () => { + it("detects Podman socket on macOS when Colima is absent", () => { + const home = "/tmp/test-home"; + const podmanSocket = path.join(home, ".local/share/containers/podman/machine/podman.sock"); + const existsSync = (candidate) => candidate === podmanSocket; + + expect(detectDockerHost({ env: {}, platform: "darwin", home, existsSync })).toEqual({ + dockerHost: `unix://${podmanSocket}`, + source: "socket", + socketPath: podmanSocket, + }); + }); + + it("prefers Colima over Podman on macOS", () => { + const home = "/tmp/test-home"; + const colimaSocket = path.join(home, ".colima/default/docker.sock"); + const podmanSocket = path.join(home, ".local/share/containers/podman/machine/podman.sock"); + const sockets = new Set([colimaSocket, podmanSocket]); + const existsSync = (candidate) => sockets.has(candidate); + + expect(detectDockerHost({ env: {}, platform: "darwin", home, existsSync })).toEqual({ + dockerHost: `unix://${colimaSocket}`, + source: "socket", + socketPath: colimaSocket, + }); + }); + }); }); diff --git a/test/runtime-shell.test.js b/test/runtime-shell.test.js index 2e5702f22..799d4d5df 100644 --- a/test/runtime-shell.test.js +++ b/test/runtime-shell.test.js @@ -35,6 +35,7 @@ describe("shell runtime helpers", () => { const result = runShell(`source "${RUNTIME_SH}"; detect_docker_host`, { HOME: home, + DOCKER_HOST: "", NEMOCLAW_TEST_SOCKET_PATHS: `${colimaSocket}:${dockerDesktopSocket}`, }); @@ -49,6 +50,7 @@ describe("shell runtime helpers", () => { const result = runShell(`source "${RUNTIME_SH}"; detect_docker_host`, { HOME: home, + DOCKER_HOST: "", NEMOCLAW_TEST_SOCKET_PATHS: dockerDesktopSocket, }); @@ -103,14 +105,33 @@ describe("shell runtime helpers", () => { expect(result.stdout.trim()).toBe("podman"); }); - it("flags podman on macOS as unsupported", () => { - const result = runShell(`source "${RUNTIME_SH}"; is_unsupported_macos_runtime Darwin podman`); + it("detects Podman socket on macOS", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-shell-")); + const podmanSocket = path.join(home, ".local/share/containers/podman/machine/podman.sock"); + + const result = runShell( + `uname() { printf 'Darwin\\n'; }; source "${RUNTIME_SH}"; find_podman_socket`, + { + HOME: home, + NEMOCLAW_TEST_SOCKET_PATHS: podmanSocket, + }, + ); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(podmanSocket); + fs.rmSync(home, { recursive: true, force: true }); }); - it("does not flag podman on Linux", () => { - const result = runShell(`source "${RUNTIME_SH}"; is_unsupported_macos_runtime Linux podman`); - expect(result.status).not.toBe(0); + it("classifies a Podman DOCKER_HOST correctly", () => { + const result = runShell(`source "${RUNTIME_SH}"; docker_host_runtime "unix:///run/user/1000/podman/podman.sock"`); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("podman"); + }); + + it("classifies a Podman machine socket correctly", () => { + const result = runShell(`source "${RUNTIME_SH}"; docker_host_runtime "unix:///Users/test/.local/share/containers/podman/machine/podman.sock"`); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("podman"); }); it("returns the vllm-local base URL", () => { diff --git a/test/smoke-macos-install.test.js b/test/smoke-macos-install.test.js index 0ba7c5534..1fcd7a49f 100644 --- a/test/smoke-macos-install.test.js +++ b/test/smoke-macos-install.test.js @@ -41,14 +41,29 @@ describe("macOS smoke install script guardrails", () => { }); it("rejects unsupported runtimes", () => { - const result = spawnSync("bash", [SMOKE_SCRIPT, "--runtime", "podman"], { + const result = spawnSync("bash", [SMOKE_SCRIPT, "--runtime", "lxc"], { cwd: path.join(import.meta.dirname, ".."), encoding: "utf-8", env: { ...process.env, NVIDIA_API_KEY: "nvapi-test" }, }); expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toMatch(/Unsupported runtime 'podman'/); + expect(`${result.stdout}${result.stderr}`).toMatch(/Unsupported runtime 'lxc'/); + }); + + it("accepts podman as a runtime option", () => { + const result = spawnSync("bash", [SMOKE_SCRIPT, "--runtime", "podman"], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { + ...process.env, + NVIDIA_API_KEY: "nvapi-test", + HOME: "/tmp/nemoclaw-smoke-no-runtime", + }, + }); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch(/no Podman socket was found/); }); it("fails when a requested runtime socket is unavailable", () => { From 521975cd3eb354a41d48b7c6cbbf5e25bbe62e3b Mon Sep 17 00:00:00 2001 From: Dhaval Dave Date: Tue, 24 Mar 2026 21:54:20 +0530 Subject: [PATCH 2/7] fix: address CodeRabbit review findings - Remove /var/run/docker.sock from getPodmanSocketCandidates on macOS to avoid inconsistent classification between find_podman_socket and docker_host_runtime (that path is ambiguous -- could be Docker or Podman via podman-mac-helper) - Use deepEqual for Linux socket candidate ordering test - Convert remaining assert.deepEqual to vitest expect().toEqual() Made-with: Cursor --- bin/lib/platform.js | 1 - scripts/lib/runtime.sh | 3 +-- test/platform.test.js | 6 ++---- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/bin/lib/platform.js b/bin/lib/platform.js index 7e71d7fce..f57155d50 100644 --- a/bin/lib/platform.js +++ b/bin/lib/platform.js @@ -55,7 +55,6 @@ function getPodmanSocketCandidates(opts = {}) { if (platform === "darwin") { return [ path.join(home, ".local/share/containers/podman/machine/podman.sock"), - "/var/run/docker.sock", ]; } diff --git a/scripts/lib/runtime.sh b/scripts/lib/runtime.sh index 209a53290..a63e4faa9 100755 --- a/scripts/lib/runtime.sh +++ b/scripts/lib/runtime.sh @@ -118,8 +118,7 @@ find_podman_socket() { if [ "$(uname -s)" = "Darwin" ]; then for socket_path in \ - "$home_dir/.local/share/containers/podman/machine/podman.sock" \ - "/var/run/docker.sock" + "$home_dir/.local/share/containers/podman/machine/podman.sock" do if socket_exists "$socket_path"; then printf '%s\n' "$socket_path" diff --git a/test/platform.test.js b/test/platform.test.js index 4b626f0ed..dda7f0159 100644 --- a/test/platform.test.js +++ b/test/platform.test.js @@ -36,14 +36,13 @@ describe("platform helpers", () => { describe("getPodmanSocketCandidates", () => { it("returns macOS Podman socket paths", () => { const home = "/tmp/test-home"; - assert.deepEqual(getPodmanSocketCandidates({ platform: "darwin", home }), [ + expect(getPodmanSocketCandidates({ platform: "darwin", home })).toEqual([ path.join(home, ".local/share/containers/podman/machine/podman.sock"), - "/var/run/docker.sock", ]); }); it("returns Linux Podman socket paths with uid", () => { - assert.deepEqual(getPodmanSocketCandidates({ platform: "linux", home: "/tmp/test-home", uid: 1001 }), [ + expect(getPodmanSocketCandidates({ platform: "linux", home: "/tmp/test-home", uid: 1001 })).toEqual([ "/run/user/1001/podman/podman.sock", "/run/podman/podman.sock", ]); @@ -57,7 +56,6 @@ describe("platform helpers", () => { path.join(home, ".colima/default/docker.sock"), path.join(home, ".config/colima/default/docker.sock"), path.join(home, ".local/share/containers/podman/machine/podman.sock"), - "/var/run/docker.sock", path.join(home, ".docker/run/docker.sock"), ]); }); From ca715ef8ee4ce17b2ce1d31ccb2c60c72e749950 Mon Sep 17 00:00:00 2001 From: Dhaval Dave Date: Tue, 24 Mar 2026 22:44:52 +0530 Subject: [PATCH 3/7] fix: address CI lint failures and CodeRabbit round 2 - Fix SC2066: replace single-item for loop in find_podman_socket Darwin branch with direct assignment - Restore SC1091 disable in .shellcheckrc for external sources not in the repo (nvm.sh) while keeping external-sources=true for repo-local sources (runtime.sh) - Update smoke test help text to list podman as supported --runtime value Made-with: Cursor --- .shellcheckrc | 10 +++++++--- scripts/lib/runtime.sh | 13 +++++-------- scripts/smoke-macos-install.sh | 2 +- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.shellcheckrc b/.shellcheckrc index e85d57ffb..e4772505c 100644 --- a/.shellcheckrc +++ b/.shellcheckrc @@ -2,10 +2,14 @@ # Our info/warn/ok/error helpers and banner intentionally embed ANSI color # variables in printf format strings. This is safe because the variables # contain only escape sequences, never user input. -disable=SC2059 +# +# SC1091: Not following sourced file. +# Kept as a fallback for files that source external dependencies not in +# the repo (e.g. nvm.sh). For repo-local sources (runtime.sh), +# external-sources + source-path below let shellcheck follow them. +disable=SC2059,SC1091 -# Allow shellcheck to follow source directives (e.g. `. "$DIR/lib/runtime.sh"`). -# This replaces the SC1091 suppression with proper source resolution. +# Follow source directives so shellcheck can analyze sourced functions. external-sources=true # Resolve source-path hints relative to the script's own directory, diff --git a/scripts/lib/runtime.sh b/scripts/lib/runtime.sh index a63e4faa9..7e4c24b15 100755 --- a/scripts/lib/runtime.sh +++ b/scripts/lib/runtime.sh @@ -117,14 +117,11 @@ find_podman_socket() { local socket_path if [ "$(uname -s)" = "Darwin" ]; then - for socket_path in \ - "$home_dir/.local/share/containers/podman/machine/podman.sock" - do - if socket_exists "$socket_path"; then - printf '%s\n' "$socket_path" - return 0 - fi - done + socket_path="$home_dir/.local/share/containers/podman/machine/podman.sock" + if socket_exists "$socket_path"; then + printf '%s\n' "$socket_path" + return 0 + fi else local uid uid="$(id -u 2>/dev/null || echo 1000)" diff --git a/scripts/smoke-macos-install.sh b/scripts/smoke-macos-install.sh index 414fb8287..7119eced2 100755 --- a/scripts/smoke-macos-install.sh +++ b/scripts/smoke-macos-install.sh @@ -55,7 +55,7 @@ Usage: ./scripts/smoke-macos-install.sh [options] Options: --sandbox-name Sandbox name to feed into install.sh --log-dir Directory for install/uninstall logs - --runtime Select runtime: colima or docker-desktop + --runtime Select runtime: colima, podman, or docker-desktop --allow-existing-state Allow running even if NemoClaw/OpenShell state already exists --keep-logs Preserve log files after success --remove-openshell Allow uninstall.sh to remove openshell From ac1989d9bb88d3f270c371acc34aee314133c670 Mon Sep 17 00:00:00 2001 From: Dhaval Dave Date: Tue, 24 Mar 2026 23:04:58 +0530 Subject: [PATCH 4/7] style: format JS/TS files with Prettier Auto-format files modified in the Podman support PR to match the project's Prettier configuration. Made-with: Cursor --- bin/lib/onboard.js | 1333 ++++++++++++++++++++---------- bin/lib/platform.js | 11 +- test/platform.test.js | 110 ++- test/runtime-shell.test.js | 77 +- test/smoke-macos-install.test.js | 61 +- 5 files changed, 1064 insertions(+), 528 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 4bd5c2866..438791557 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -25,12 +25,14 @@ const { DEFAULT_CLOUD_MODEL, getProviderSelectionConfig, } = require("./inference-config"); -const { - inferContainerRuntime, - shouldPatchCoredns, -} = require("./platform"); +const { inferContainerRuntime, shouldPatchCoredns } = require("./platform"); const { resolveOpenshell } = require("./resolve-openshell"); -const { prompt, ensureApiKey, getCredential, saveCredential } = require("./credentials"); +const { + prompt, + ensureApiKey, + getCredential, + saveCredential, +} = require("./credentials"); const registry = require("./registry"); const nim = require("./nim"); const policies = require("./policies"); @@ -45,7 +47,8 @@ const GATEWAY_NAME = "nemoclaw"; const BUILD_ENDPOINT_URL = "https://integrate.api.nvidia.com/v1"; const OPENAI_ENDPOINT_URL = "https://api.openai.com/v1"; const ANTHROPIC_ENDPOINT_URL = "https://api.anthropic.com"; -const GEMINI_ENDPOINT_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"; +const GEMINI_ENDPOINT_URL = + "https://generativelanguage.googleapis.com/v1beta/openai/"; const REMOTE_PROVIDER_CONFIG = { build: { @@ -114,17 +117,8 @@ const REMOTE_PROVIDER_CONFIG = { }; const REMOTE_MODEL_OPTIONS = { - openai: [ - "gpt-5.4", - "gpt-5.4-mini", - "gpt-5.4-nano", - "gpt-5.4-pro-2026-03-05", - ], - anthropic: [ - "claude-sonnet-4-6", - "claude-haiku-4-5", - "claude-opus-4-6", - ], + openai: ["gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano", "gpt-5.4-pro-2026-03-05"], + anthropic: ["claude-sonnet-4-6", "claude-haiku-4-5", "claude-opus-4-6"], gemini: [ "gemini-3.1-pro-preview", "gemini-3.1-flash-lite-preview", @@ -169,7 +163,11 @@ function isSandboxReady(output, sandboxName) { const clean = output.replace(/\x1b\[[0-9;]*m/g, ""); return clean.split("\n").some((l) => { const cols = l.trim().split(/\s+/); - return cols[0] === sandboxName && cols.includes("Ready") && !cols.includes("NotReady"); + return ( + cols[0] === sandboxName && + cols.includes("Ready") && + !cols.includes("NotReady") + ); }); } @@ -180,7 +178,11 @@ function isSandboxReady(output, sandboxName) { * @returns {boolean} */ function hasStaleGateway(gwInfoOutput) { - return typeof gwInfoOutput === "string" && gwInfoOutput.length > 0 && gwInfoOutput.includes(GATEWAY_NAME); + return ( + typeof gwInfoOutput === "string" && + gwInfoOutput.length > 0 && + gwInfoOutput.includes(GATEWAY_NAME) + ); } function streamSandboxCreate(command, env = process.env) { @@ -238,9 +240,10 @@ function streamSandboxCreate(command, env = process.env) { if (settled) return; settled = true; if (pending) flushLine(pending); - const detail = error && error.code - ? `spawn failed: ${error.message} (${error.code})` - : `spawn failed: ${error.message}`; + const detail = + error && error.code + ? `spawn failed: ${error.message} (${error.code})` + : `spawn failed: ${error.message}`; lines.push(detail); resolve({ status: 1, output: lines.join("\n"), sawProgress: false }); }); @@ -261,7 +264,9 @@ function step(n, total, msg) { } function getInstalledOpenshellVersion(versionOutput = null) { - const output = String(versionOutput ?? runCapture("openshell -V", { ignoreError: true })).trim(); + const output = String( + versionOutput ?? runCapture("openshell -V", { ignoreError: true }), + ).trim(); const match = output.match(/openshell\s+([0-9]+\.[0-9]+\.[0-9]+)/i); if (!match) return null; return match[1]; @@ -278,7 +283,9 @@ function getOpenshellBinary() { const resolved = resolveOpenshell(); if (!resolved) { console.error(" openshell CLI not found."); - console.error(" Install manually: https://github.com/NVIDIA/OpenShell/releases"); + console.error( + " Install manually: https://github.com/NVIDIA/OpenShell/releases", + ); process.exit(1); } OPENSHELL_BIN = resolved; @@ -286,7 +293,10 @@ function getOpenshellBinary() { } function openshellShellCommand(args) { - return [shellQuote(getOpenshellBinary()), ...args.map((arg) => shellQuote(arg))].join(" "); + return [ + shellQuote(getOpenshellBinary()), + ...args.map((arg) => shellQuote(arg)), + ].join(" "); } function runOpenshell(args, opts = {}) { @@ -308,7 +318,16 @@ function getCurlTimingArgs() { function buildProviderArgs(action, name, type, credentialEnv, baseUrl) { const args = action === "create" - ? ["provider", "create", "--name", name, "--type", type, "--credential", credentialEnv] + ? [ + "provider", + "create", + "--name", + name, + "--type", + type, + "--credential", + credentialEnv, + ] : ["provider", "update", name, "--credential", credentialEnv]; if (baseUrl && type === "openai") { args.push("--config", `OPENAI_BASE_URL=${baseUrl}`); @@ -319,11 +338,23 @@ function buildProviderArgs(action, name, type, credentialEnv, baseUrl) { } function upsertProvider(name, type, credentialEnv, baseUrl, env = {}) { - const createArgs = buildProviderArgs("create", name, type, credentialEnv, baseUrl); + const createArgs = buildProviderArgs( + "create", + name, + type, + credentialEnv, + baseUrl, + ); const createResult = runOpenshell(createArgs, { ignoreError: true, env }); if (createResult.status === 0) return; - const updateArgs = buildProviderArgs("update", name, type, credentialEnv, baseUrl); + const updateArgs = buildProviderArgs( + "update", + name, + type, + credentialEnv, + baseUrl, + ); const updateResult = runOpenshell(updateArgs, { ignoreError: true, env }); if (updateResult.status !== 0) { console.error(` Failed to create or update provider '${name}'.`); @@ -332,15 +363,22 @@ function upsertProvider(name, type, credentialEnv, baseUrl, env = {}) { } function verifyInferenceRoute(provider, model) { - const output = runCaptureOpenshell(["inference", "get"], { ignoreError: true }); - if (!output || /Gateway inference:\s*[\r\n]+\s*Not configured/i.test(output)) { + const output = runCaptureOpenshell(["inference", "get"], { + ignoreError: true, + }); + if ( + !output || + /Gateway inference:\s*[\r\n]+\s*Not configured/i.test(output) + ) { console.error(" OpenShell inference route was not configured."); process.exit(1); } } function sandboxExistsInGateway(sandboxName) { - const output = runCaptureOpenshell(["sandbox", "get", sandboxName], { ignoreError: true }); + const output = runCaptureOpenshell(["sandbox", "get", sandboxName], { + ignoreError: true, + }); return Boolean(output); } @@ -372,7 +410,11 @@ exit `.trim(); } -function writeSandboxConfigSyncFile(script, tmpDir = os.tmpdir(), now = Date.now()) { +function writeSandboxConfigSyncFile( + script, + tmpDir = os.tmpdir(), + now = Date.now(), +) { const scriptFile = path.join(tmpDir, `nemoclaw-sync-${now}.sh`); fs.writeFileSync(scriptFile, `${script}\n`, { mode: 0o600 }); return scriptFile; @@ -382,7 +424,11 @@ function encodeDockerJsonArg(value) { return Buffer.from(JSON.stringify(value || {}), "utf8").toString("base64"); } -function getSandboxInferenceConfig(model, provider = null, preferredInferenceApi = null) { +function getSandboxInferenceConfig( + model, + provider = null, + preferredInferenceApi = null, +) { let providerKey = "inference"; let primaryModelRef = model; let inferenceBaseUrl = "https://inference.local/v1"; @@ -423,10 +469,23 @@ function getSandboxInferenceConfig(model, provider = null, preferredInferenceApi break; } - return { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat }; + return { + providerKey, + primaryModelRef, + inferenceBaseUrl, + inferenceApi, + inferenceCompat, + }; } -function patchStagedDockerfile(dockerfilePath, model, chatUiUrl, buildId = String(Date.now()), provider = null, preferredInferenceApi = null) { +function patchStagedDockerfile( + dockerfilePath, + model, + chatUiUrl, + buildId = String(Date.now()), + provider = null, + preferredInferenceApi = null, +) { const { providerKey, primaryModelRef, @@ -437,35 +496,35 @@ function patchStagedDockerfile(dockerfilePath, model, chatUiUrl, buildId = Strin let dockerfile = fs.readFileSync(dockerfilePath, "utf8"); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_MODEL=.*$/m, - `ARG NEMOCLAW_MODEL=${model}` + `ARG NEMOCLAW_MODEL=${model}`, ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_PROVIDER_KEY=.*$/m, - `ARG NEMOCLAW_PROVIDER_KEY=${providerKey}` + `ARG NEMOCLAW_PROVIDER_KEY=${providerKey}`, ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_PRIMARY_MODEL_REF=.*$/m, - `ARG NEMOCLAW_PRIMARY_MODEL_REF=${primaryModelRef}` + `ARG NEMOCLAW_PRIMARY_MODEL_REF=${primaryModelRef}`, ); dockerfile = dockerfile.replace( /^ARG CHAT_UI_URL=.*$/m, - `ARG CHAT_UI_URL=${chatUiUrl}` + `ARG CHAT_UI_URL=${chatUiUrl}`, ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_INFERENCE_BASE_URL=.*$/m, - `ARG NEMOCLAW_INFERENCE_BASE_URL=${inferenceBaseUrl}` + `ARG NEMOCLAW_INFERENCE_BASE_URL=${inferenceBaseUrl}`, ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_INFERENCE_API=.*$/m, - `ARG NEMOCLAW_INFERENCE_API=${inferenceApi}` + `ARG NEMOCLAW_INFERENCE_API=${inferenceApi}`, ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_INFERENCE_COMPAT_B64=.*$/m, - `ARG NEMOCLAW_INFERENCE_COMPAT_B64=${encodeDockerJsonArg(inferenceCompat)}` + `ARG NEMOCLAW_INFERENCE_COMPAT_B64=${encodeDockerJsonArg(inferenceCompat)}`, ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_BUILD_ID=.*$/m, - `ARG NEMOCLAW_BUILD_ID=${buildId}` + `ARG NEMOCLAW_BUILD_ID=${buildId}`, ); fs.writeFileSync(dockerfilePath, dockerfile); } @@ -503,16 +562,17 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey) { url: `${String(endpointUrl).replace(/\/+$/, "")}/chat/completions`, body: JSON.stringify({ model, - messages: [ - { role: "user", content: "Reply with exactly: OK" }, - ], + messages: [{ role: "user", content: "Reply with exactly: OK" }], }), }, ]; const failures = []; for (const probe of probes) { - const bodyFile = path.join(os.tmpdir(), `nemoclaw-probe-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + const bodyFile = path.join( + os.tmpdir(), + `nemoclaw-probe-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, + ); try { const cmd = [ "curl -sS", @@ -520,7 +580,9 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey) { `-o ${shellQuote(bodyFile)}`, "-w '%{http_code}'", "-H 'Content-Type: application/json'", - ...(apiKey ? ['-H "Authorization: Bearer $NEMOCLAW_PROBE_API_KEY"'] : []), + ...(apiKey + ? ['-H "Authorization: Bearer $NEMOCLAW_PROBE_API_KEY"'] + : []), `-d ${shellQuote(probe.body)}`, shellQuote(probe.url), ].join(" "); @@ -532,7 +594,9 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey) { NEMOCLAW_PROBE_API_KEY: apiKey, }, }); - const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; + const body = fs.existsSync(bodyFile) + ? fs.readFileSync(bodyFile, "utf8") + : ""; const status = Number(String(result.stdout || "").trim()); if (result.status === 0 && status >= 200 && status < 300) { return { ok: true, api: probe.api, label: probe.name }; @@ -550,13 +614,18 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey) { return { ok: false, - message: failures.map((failure) => `${failure.name}: ${failure.message}`).join(" | "), + message: failures + .map((failure) => `${failure.name}: ${failure.message}`) + .join(" | "), failures, }; } function probeAnthropicEndpoint(endpointUrl, model, apiKey) { - const bodyFile = path.join(os.tmpdir(), `nemoclaw-anthropic-probe-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + const bodyFile = path.join( + os.tmpdir(), + `nemoclaw-anthropic-probe-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, + ); try { const cmd = [ "curl -sS", @@ -566,11 +635,13 @@ function probeAnthropicEndpoint(endpointUrl, model, apiKey) { '-H "x-api-key: $NEMOCLAW_PROBE_API_KEY"', "-H 'anthropic-version: 2023-06-01'", "-H 'content-type: application/json'", - `-d ${shellQuote(JSON.stringify({ - model, - max_tokens: 16, - messages: [{ role: "user", content: "Reply with exactly: OK" }], - }))}`, + `-d ${shellQuote( + JSON.stringify({ + model, + max_tokens: 16, + messages: [{ role: "user", content: "Reply with exactly: OK" }], + }), + )}`, shellQuote(`${String(endpointUrl).replace(/\/+$/, "")}/v1/messages`), ].join(" "); const result = spawnSync("bash", ["-c", cmd], { @@ -581,10 +652,16 @@ function probeAnthropicEndpoint(endpointUrl, model, apiKey) { NEMOCLAW_PROBE_API_KEY: apiKey, }, }); - const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; + const body = fs.existsSync(bodyFile) + ? fs.readFileSync(bodyFile, "utf8") + : ""; const status = Number(String(result.stdout || "").trim()); if (result.status === 0 && status >= 200 && status < 300) { - return { ok: true, api: "anthropic-messages", label: "Anthropic Messages API" }; + return { + ok: true, + api: "anthropic-messages", + label: "Anthropic Messages API", + }; } return { ok: false, @@ -616,7 +693,7 @@ async function validateOpenAiLikeSelection( endpointUrl, model, credentialEnv = null, - retryMessage = "Please choose a provider/model again." + retryMessage = "Please choose a provider/model again.", ) { const apiKey = credentialEnv ? getCredential(credentialEnv) : ""; const probe = probeOpenAiLikeEndpoint(endpointUrl, model, apiKey); @@ -634,7 +711,12 @@ async function validateOpenAiLikeSelection( return probe.api; } -async function validateAnthropicSelection(label, endpointUrl, model, credentialEnv) { +async function validateAnthropicSelection( + label, + endpointUrl, + model, + credentialEnv, +) { const apiKey = getCredential(credentialEnv); const probe = probeAnthropicEndpoint(endpointUrl, model, apiKey); if (!probe.ok) { @@ -656,7 +738,7 @@ async function validateAnthropicSelectionWithRetryMessage( endpointUrl, model, credentialEnv, - retryMessage = "Please choose a provider/model again." + retryMessage = "Please choose a provider/model again.", ) { const apiKey = getCredential(credentialEnv); const probe = probeAnthropicEndpoint(endpointUrl, model, apiKey); @@ -674,7 +756,12 @@ async function validateAnthropicSelectionWithRetryMessage( return probe.api; } -async function validateCustomOpenAiLikeSelection(label, endpointUrl, model, credentialEnv) { +async function validateCustomOpenAiLikeSelection( + label, + endpointUrl, + model, + credentialEnv, +) { const apiKey = getCredential(credentialEnv); const probe = probeOpenAiLikeEndpoint(endpointUrl, model, apiKey); if (probe.ok) { @@ -696,7 +783,12 @@ async function validateCustomOpenAiLikeSelection(label, endpointUrl, model, cred return { ok: false, retry: "model" }; } -async function validateCustomAnthropicSelection(label, endpointUrl, model, credentialEnv) { +async function validateCustomAnthropicSelection( + label, + endpointUrl, + model, + credentialEnv, +) { const apiKey = getCredential(credentialEnv); const probe = probeAnthropicEndpoint(endpointUrl, model, apiKey); if (probe.ok) { @@ -719,7 +811,10 @@ async function validateCustomAnthropicSelection(label, endpointUrl, model, crede } function fetchNvidiaEndpointModels(apiKey) { - const bodyFile = path.join(os.tmpdir(), `nemoclaw-nvidia-models-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + const bodyFile = path.join( + os.tmpdir(), + `nemoclaw-nvidia-models-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, + ); try { const cmd = [ "curl -sS", @@ -738,10 +833,15 @@ function fetchNvidiaEndpointModels(apiKey) { NEMOCLAW_PROBE_API_KEY: apiKey, }, }); - const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; + const body = fs.existsSync(bodyFile) + ? fs.readFileSync(bodyFile, "utf8") + : ""; const status = Number(String(result.stdout || "").trim()); if (result.status !== 0 || !(status >= 200 && status < 300)) { - return { ok: false, message: summarizeProbeError(body, status || result.status || 0) }; + return { + ok: false, + message: summarizeProbeError(body, status || result.status || 0), + }; } const parsed = JSON.parse(body); const ids = Array.isArray(parsed?.data) @@ -773,7 +873,10 @@ function validateNvidiaEndpointModel(model, apiKey) { } function fetchOpenAiLikeModels(endpointUrl, apiKey) { - const bodyFile = path.join(os.tmpdir(), `nemoclaw-openai-models-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + const bodyFile = path.join( + os.tmpdir(), + `nemoclaw-openai-models-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, + ); try { const cmd = [ "curl -sS", @@ -791,10 +894,16 @@ function fetchOpenAiLikeModels(endpointUrl, apiKey) { NEMOCLAW_PROBE_API_KEY: apiKey, }, }); - const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; + const body = fs.existsSync(bodyFile) + ? fs.readFileSync(bodyFile, "utf8") + : ""; const status = Number(String(result.stdout || "").trim()); if (result.status !== 0 || !(status >= 200 && status < 300)) { - return { ok: false, status, message: summarizeProbeError(body, status || result.status || 0) }; + return { + ok: false, + status, + message: summarizeProbeError(body, status || result.status || 0), + }; } const parsed = JSON.parse(body); const ids = Array.isArray(parsed?.data) @@ -809,7 +918,10 @@ function fetchOpenAiLikeModels(endpointUrl, apiKey) { } function fetchAnthropicModels(endpointUrl, apiKey) { - const bodyFile = path.join(os.tmpdir(), `nemoclaw-anthropic-models-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + const bodyFile = path.join( + os.tmpdir(), + `nemoclaw-anthropic-models-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, + ); try { const cmd = [ "curl -sS", @@ -828,14 +940,22 @@ function fetchAnthropicModels(endpointUrl, apiKey) { NEMOCLAW_PROBE_API_KEY: apiKey, }, }); - const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; + const body = fs.existsSync(bodyFile) + ? fs.readFileSync(bodyFile, "utf8") + : ""; const status = Number(String(result.stdout || "").trim()); if (result.status !== 0 || !(status >= 200 && status < 300)) { - return { ok: false, status, message: summarizeProbeError(body, status || result.status || 0) }; + return { + ok: false, + status, + message: summarizeProbeError(body, status || result.status || 0), + }; } const parsed = JSON.parse(body); const ids = Array.isArray(parsed?.data) - ? parsed.data.map((item) => item && (item.id || item.name)).filter(Boolean) + ? parsed.data + .map((item) => item && (item.id || item.name)) + .filter(Boolean) : []; return { ok: true, ids }; } catch (error) { @@ -922,11 +1042,17 @@ async function promptCloudModel() { return promptManualModelId( " NVIDIA Endpoints model id: ", "NVIDIA Endpoints", - (model) => validateNvidiaEndpointModel(model, getCredential("NVIDIA_API_KEY")) + (model) => + validateNvidiaEndpointModel(model, getCredential("NVIDIA_API_KEY")), ); } -async function promptRemoteModel(label, providerKey, defaultModel, validator = null) { +async function promptRemoteModel( + label, + providerKey, + defaultModel, + validator = null, +) { const options = REMOTE_MODEL_OPTIONS[providerKey] || []; const defaultIndex = Math.max(0, options.indexOf(defaultModel)); @@ -968,19 +1094,24 @@ async function promptInputModel(label, defaultModel, validator = null) { async function promptOllamaModel(gpu = null) { const installed = getOllamaModelOptions(runCapture); - const options = installed.length > 0 ? installed : getBootstrapOllamaModelOptions(gpu); + const options = + installed.length > 0 ? installed : getBootstrapOllamaModelOptions(gpu); const defaultModel = getDefaultOllamaModel(runCapture, gpu); const defaultIndex = Math.max(0, options.indexOf(defaultModel)); console.log(""); - console.log(installed.length > 0 ? " Ollama models:" : " Ollama starter models:"); + console.log( + installed.length > 0 ? " Ollama models:" : " Ollama starter models:", + ); options.forEach((option, index) => { console.log(` ${index + 1}) ${option}`); }); console.log(` ${options.length + 1}) Other...`); if (installed.length === 0) { console.log(""); - console.log(" No local Ollama models are installed yet. Choose one to pull and load now."); + console.log( + " No local Ollama models are installed yet. Choose one to pull and load now.", + ); } console.log(""); @@ -1047,12 +1178,16 @@ function getFutureShellPathHint(binDir, pathValue = process.env.PATH || "") { } function installOpenshell() { - const result = spawnSync("bash", [path.join(SCRIPTS, "install-openshell.sh")], { - cwd: ROOT, - env: process.env, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); + const result = spawnSync( + "bash", + [path.join(SCRIPTS, "install-openshell.sh")], + { + cwd: ROOT, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }, + ); if (result.status !== 0) { const output = `${result.stdout || ""}${result.stderr || ""}`.trim(); if (output) { @@ -1060,7 +1195,9 @@ function installOpenshell() { } return { installed: false, localBin: null, futureShellPathHint: null }; } - const localBin = process.env.XDG_BIN_HOME || path.join(process.env.HOME || "", ".local", "bin"); + const localBin = + process.env.XDG_BIN_HOME || + path.join(process.env.HOME || "", ".local", "bin"); const openshellPath = path.join(localBin, "openshell"); const futureShellPathHint = fs.existsSync(openshellPath) ? getFutureShellPathHint(localBin, process.env.PATH) @@ -1114,7 +1251,9 @@ async function ensureNamedCredential(envName, label, helpUrl = null) { function waitForSandboxReady(sandboxName, attempts = 10, delaySeconds = 2) { for (let i = 0; i < attempts; i += 1) { - const exists = runCaptureOpenshell(["sandbox", "get", sandboxName], { ignoreError: true }); + const exists = runCaptureOpenshell(["sandbox", "get", sandboxName], { + ignoreError: true, + }); if (exists) return true; sleep(delaySeconds); } @@ -1133,7 +1272,9 @@ function isSafeModelId(value) { } function getNonInteractiveProvider() { - const providerKey = (process.env.NEMOCLAW_PROVIDER || "").trim().toLowerCase(); + const providerKey = (process.env.NEMOCLAW_PROVIDER || "") + .trim() + .toLowerCase(); if (!providerKey) return null; const aliases = { cloud: "build", @@ -1142,10 +1283,22 @@ function getNonInteractiveProvider() { anthropiccompatible: "anthropicCompatible", }; const normalized = aliases[providerKey] || providerKey; - const validProviders = new Set(["build", "openai", "anthropic", "anthropicCompatible", "gemini", "ollama", "custom", "nim-local", "vllm"]); + const validProviders = new Set([ + "build", + "openai", + "anthropic", + "anthropicCompatible", + "gemini", + "ollama", + "custom", + "nim-local", + "vllm", + ]); if (!validProviders.has(normalized)) { console.error(` Unsupported NEMOCLAW_PROVIDER: ${providerKey}`); - console.error(" Valid values: build, openai, anthropic, anthropicCompatible, gemini, ollama, custom, nim-local, vllm"); + console.error( + " Valid values: build, openai, anthropic, anthropicCompatible, gemini, ollama, custom, nim-local, vllm", + ); process.exit(1); } @@ -1156,8 +1309,12 @@ function getNonInteractiveModel(providerKey) { const model = (process.env.NEMOCLAW_MODEL || "").trim(); if (!model) return null; if (!isSafeModelId(model)) { - console.error(` Invalid NEMOCLAW_MODEL for provider '${providerKey}': ${model}`); - console.error(" Model values may only contain letters, numbers, '.', '_', ':', '/', and '-'."); + console.error( + ` Invalid NEMOCLAW_MODEL for provider '${providerKey}': ${model}`, + ); + console.error( + " Model values may only contain letters, numbers, '.', '_', ':', '/', and '-'.", + ); process.exit(1); } return model; @@ -1170,7 +1327,9 @@ async function preflight() { // Docker if (!isDockerRunning()) { - console.error(" Docker is not running. Please start Docker and try again."); + console.error( + " Docker is not running. Please start Docker and try again.", + ); process.exit(1); } console.log(" ✓ Docker is running"); @@ -1187,26 +1346,40 @@ async function preflight() { openshellInstall = installOpenshell(); if (!openshellInstall.installed) { console.error(" Failed to install openshell CLI."); - console.error(" Install manually: https://github.com/NVIDIA/OpenShell/releases"); + console.error( + " Install manually: https://github.com/NVIDIA/OpenShell/releases", + ); process.exit(1); } } - console.log(` ✓ openshell CLI: ${runCaptureOpenshell(["--version"], { ignoreError: true }) || "unknown"}`); + console.log( + ` ✓ openshell CLI: ${runCaptureOpenshell(["--version"], { ignoreError: true }) || "unknown"}`, + ); if (openshellInstall.futureShellPathHint) { - console.log(` Note: openshell was installed to ${openshellInstall.localBin} for this onboarding run.`); - console.log(` Future shells may still need: ${openshellInstall.futureShellPathHint}`); - console.log(" Add that export to your shell profile, or open a new terminal before running openshell directly."); + console.log( + ` Note: openshell was installed to ${openshellInstall.localBin} for this onboarding run.`, + ); + console.log( + ` Future shells may still need: ${openshellInstall.futureShellPathHint}`, + ); + console.log( + " Add that export to your shell profile, or open a new terminal before running openshell directly.", + ); } // Clean up stale NemoClaw session before checking ports. // A previous onboard run may have left the gateway container and port // forward running. If a NemoClaw-owned gateway is still present, tear // it down so the port check below doesn't fail on our own leftovers. - const gwInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { ignoreError: true }); + const gwInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { + ignoreError: true, + }); if (hasStaleGateway(gwInfo)) { console.log(" Cleaning up previous NemoClaw session..."); runOpenshell(["forward", "stop", "18789"], { ignoreError: true }); - runOpenshell(["gateway", "destroy", "-g", GATEWAY_NAME], { ignoreError: true }); + runOpenshell(["gateway", "destroy", "-g", GATEWAY_NAME], { + ignoreError: true, + }); console.log(" ✓ Previous session cleaned up"); } @@ -1223,7 +1396,9 @@ async function preflight() { console.error(` ${label} needs this port.`); console.error(""); if (portCheck.process && portCheck.process !== "unknown") { - console.error(` Blocked by: ${portCheck.process}${portCheck.pid ? ` (PID ${portCheck.pid})` : ""}`); + console.error( + ` Blocked by: ${portCheck.process}${portCheck.pid ? ` (PID ${portCheck.pid})` : ""}`, + ); console.error(""); console.error(" To fix, stop the conflicting process:"); console.error(""); @@ -1235,7 +1410,9 @@ async function preflight() { console.error(" # or, if it's a systemd service:"); console.error(" systemctl --user stop openclaw-gateway.service"); } else { - console.error(` Could not identify the process using port ${port}.`); + console.error( + ` Could not identify the process using port ${port}.`, + ); console.error(` Run: lsof -i :${port} -sTCP:LISTEN`); } console.error(""); @@ -1248,9 +1425,13 @@ async function preflight() { // GPU const gpu = nim.detectGpu(); if (gpu && gpu.type === "nvidia") { - console.log(` ✓ NVIDIA GPU detected: ${gpu.count} GPU(s), ${gpu.totalMemoryMB} MB VRAM`); + console.log( + ` ✓ NVIDIA GPU detected: ${gpu.count} GPU(s), ${gpu.totalMemoryMB} MB VRAM`, + ); } else if (gpu && gpu.type === "apple") { - console.log(` ✓ Apple GPU detected: ${gpu.name}${gpu.cores ? ` (${gpu.cores} cores)` : ""}, ${gpu.totalMemoryMB} MB unified memory`); + console.log( + ` ✓ Apple GPU detected: ${gpu.name}${gpu.cores ? ` (${gpu.cores} cores)` : ""}, ${gpu.totalMemoryMB} MB unified memory`, + ); console.log(" ⓘ NIM requires NVIDIA GPU — will use cloud inference"); } else { console.log(" ⓘ No GPU detected — will use cloud inference"); @@ -1265,7 +1446,9 @@ async function startGateway(gpu) { step(3, 7, "Starting OpenShell gateway"); // Destroy old gateway - runOpenshell(["gateway", "destroy", "-g", GATEWAY_NAME], { ignoreError: true }); + runOpenshell(["gateway", "destroy", "-g", GATEWAY_NAME], { + ignoreError: true, + }); const gwArgs = ["--name", GATEWAY_NAME]; // Do NOT pass --gpu here. On DGX Spark (and most GPU hosts), inference is @@ -1281,10 +1464,15 @@ async function startGateway(gpu) { if (stableGatewayImage && openshellVersion) { gatewayEnv.OPENSHELL_CLUSTER_IMAGE = stableGatewayImage; gatewayEnv.IMAGE_TAG = openshellVersion; - console.log(` Using pinned OpenShell gateway image: ${stableGatewayImage}`); + console.log( + ` Using pinned OpenShell gateway image: ${stableGatewayImage}`, + ); } - runOpenshell(["gateway", "start", ...gwArgs], { ignoreError: false, env: gatewayEnv }); + runOpenshell(["gateway", "start", ...gwArgs], { + ignoreError: false, + env: gatewayEnv, + }); // Verify health for (let i = 0; i < 5; i++) { @@ -1303,7 +1491,10 @@ async function startGateway(gpu) { const runtime = getContainerRuntime(); if (shouldPatchCoredns(runtime)) { console.log(` Patching CoreDNS for ${runtime}...`); - run(`bash "${path.join(SCRIPTS, "fix-coredns.sh")}" ${GATEWAY_NAME} 2>&1 || true`, { ignoreError: true }); + run( + `bash "${path.join(SCRIPTS, "fix-coredns.sh")}" ${GATEWAY_NAME} 2>&1 || true`, + { ignoreError: true }, + ); } // Give DNS a moment to propagate sleep(5); @@ -1313,12 +1504,18 @@ async function startGateway(gpu) { // ── Step 3: Sandbox ────────────────────────────────────────────── -async function createSandbox(gpu, model, provider, preferredInferenceApi = null) { +async function createSandbox( + gpu, + model, + provider, + preferredInferenceApi = null, +) { step(5, 7, "Creating sandbox"); const nameAnswer = await promptOrDefault( " Sandbox name (lowercase, numbers, hyphens) [my-assistant]: ", - "NEMOCLAW_SANDBOX_NAME", "my-assistant" + "NEMOCLAW_SANDBOX_NAME", + "my-assistant", ); const sandboxName = (nameAnswer || "my-assistant").trim().toLowerCase(); @@ -1326,7 +1523,9 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null) // must start and end with alphanumeric (required by Kubernetes/OpenShell) if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName)) { console.error(` Invalid sandbox name: '${sandboxName}'`); - console.error(" Names must be lowercase, contain only letters, numbers, and hyphens,"); + console.error( + " Names must be lowercase, contain only letters, numbers, and hyphens,", + ); console.error(" and must start and end with a letter or number."); process.exit(1); } @@ -1338,12 +1537,16 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null) if (isNonInteractive()) { if (process.env.NEMOCLAW_RECREATE_SANDBOX !== "1") { console.error(` Sandbox '${sandboxName}' already exists.`); - console.error(" Set NEMOCLAW_RECREATE_SANDBOX=1 to recreate it in non-interactive mode."); + console.error( + " Set NEMOCLAW_RECREATE_SANDBOX=1 to recreate it in non-interactive mode.", + ); process.exit(1); } note(` [non-interactive] Sandbox '${sandboxName}' exists — recreating`); } else { - const recreate = await prompt(` Sandbox '${sandboxName}' already exists. Recreate? [y/N]: `); + const recreate = await prompt( + ` Sandbox '${sandboxName}' already exists. Recreate? [y/N]: `, + ); if (recreate.toLowerCase() !== "y") { console.log(" Keeping existing sandbox."); return sandboxName; @@ -1361,33 +1564,54 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null) const stagedDockerfile = path.join(buildCtx, "Dockerfile"); fs.copyFileSync(path.join(ROOT, "Dockerfile"), stagedDockerfile); run(`cp -r "${path.join(ROOT, "nemoclaw")}" "${buildCtx}/nemoclaw"`); - run(`cp -r "${path.join(ROOT, "nemoclaw-blueprint")}" "${buildCtx}/nemoclaw-blueprint"`); + run( + `cp -r "${path.join(ROOT, "nemoclaw-blueprint")}" "${buildCtx}/nemoclaw-blueprint"`, + ); run(`cp -r "${path.join(ROOT, "scripts")}" "${buildCtx}/scripts"`); run(`rm -rf "${buildCtx}/nemoclaw/node_modules"`, { ignoreError: true }); // Create sandbox (use -- echo to avoid dropping into interactive shell) // Pass the base policy so sandbox starts in proxy mode (required for policy updates later) - const basePolicyPath = path.join(ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"); + const basePolicyPath = path.join( + ROOT, + "nemoclaw-blueprint", + "policies", + "openclaw-sandbox.yaml", + ); const createArgs = [ - "--from", `${buildCtx}/Dockerfile`, - "--name", sandboxName, - "--policy", basePolicyPath, + "--from", + `${buildCtx}/Dockerfile`, + "--name", + sandboxName, + "--policy", + basePolicyPath, ]; // --gpu is intentionally omitted. See comment in startGateway(). - console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); + console.log( + ` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`, + ); const chatUiUrl = process.env.CHAT_UI_URL || "http://127.0.0.1:18789"; - patchStagedDockerfile(stagedDockerfile, model, chatUiUrl, String(Date.now()), provider, preferredInferenceApi); + patchStagedDockerfile( + stagedDockerfile, + model, + chatUiUrl, + String(Date.now()), + provider, + preferredInferenceApi, + ); const envArgs = [formatEnvAssignment("CHAT_UI_URL", chatUiUrl)]; const sandboxEnv = { ...process.env }; if (process.env.NVIDIA_API_KEY) { sandboxEnv.NVIDIA_API_KEY = process.env.NVIDIA_API_KEY; } - const discordToken = getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN; + const discordToken = + getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN; if (discordToken) { sandboxEnv.DISCORD_BOT_TOKEN = discordToken; } - const slackToken = getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN; + const slackToken = + getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN; if (slackToken) { sandboxEnv.SLACK_BOT_TOKEN = slackToken; } @@ -1417,7 +1641,9 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null) console.error(""); console.error(createResult.output); } - console.error(" Try: openshell sandbox list # check gateway state"); + console.error( + " Try: openshell sandbox list # check gateway state", + ); console.error(" Try: nemoclaw onboard # retry from scratch"); process.exit(createResult.status || 1); } @@ -1429,7 +1655,9 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null) console.log(" Waiting for sandbox to become ready..."); let ready = false; for (let i = 0; i < 30; i++) { - const list = runCaptureOpenshell(["sandbox", "list"], { ignoreError: true }); + const list = runCaptureOpenshell(["sandbox", "list"], { + ignoreError: true, + }); if (isSandboxReady(list, sandboxName)) { ready = true; break; @@ -1440,11 +1668,17 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null) if (!ready) { // Clean up the orphaned sandbox so the next onboard retry with the same // name doesn't fail on "sandbox already exists". - const delResult = runOpenshell(["sandbox", "delete", sandboxName], { ignoreError: true }); + const delResult = runOpenshell(["sandbox", "delete", sandboxName], { + ignoreError: true, + }); console.error(""); - console.error(` Sandbox '${sandboxName}' was created but did not become ready within 60s.`); + console.error( + ` Sandbox '${sandboxName}' was created but did not become ready within 60s.`, + ); if (delResult.status === 0) { - console.error(" The orphaned sandbox has been removed — you can safely retry."); + console.error( + " The orphaned sandbox has been removed — you can safely retry.", + ); } else { console.error(` Could not remove the orphaned sandbox. Manual cleanup:`); console.error(` openshell sandbox delete "${sandboxName}"`); @@ -1458,7 +1692,9 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null) // which would silently prevent the new sandbox's dashboard from being reachable. runOpenshell(["forward", "stop", "18789"], { ignoreError: true }); // Forward dashboard port to the new sandbox - runOpenshell(["forward", "start", "--background", "18789", sandboxName], { ignoreError: true }); + runOpenshell(["forward", "start", "--background", "18789", sandboxName], { + ignoreError: true, + }); // Register only after confirmed ready — prevents phantom entries registry.registerSandbox({ @@ -1484,21 +1720,36 @@ async function setupNim(gpu) { // Detect local inference options const hasOllama = !!runCapture("command -v ollama", { ignoreError: true }); - const ollamaRunning = !!runCapture("curl -sf http://localhost:11434/api/tags 2>/dev/null", { ignoreError: true }); - const vllmRunning = !!runCapture("curl -sf http://localhost:8000/v1/models 2>/dev/null", { ignoreError: true }); - const requestedProvider = isNonInteractive() ? getNonInteractiveProvider() : null; - const requestedModel = isNonInteractive() ? getNonInteractiveModel(requestedProvider || "build") : null; + const ollamaRunning = !!runCapture( + "curl -sf http://localhost:11434/api/tags 2>/dev/null", + { ignoreError: true }, + ); + const vllmRunning = !!runCapture( + "curl -sf http://localhost:8000/v1/models 2>/dev/null", + { ignoreError: true }, + ); + const requestedProvider = isNonInteractive() + ? getNonInteractiveProvider() + : null; + const requestedModel = isNonInteractive() + ? getNonInteractiveModel(requestedProvider || "build") + : null; const options = []; options.push({ key: "build", label: "NVIDIA Endpoints" + - (!ollamaRunning && !(EXPERIMENTAL && vllmRunning) ? " (recommended)" : ""), + (!ollamaRunning && !(EXPERIMENTAL && vllmRunning) + ? " (recommended)" + : ""), }); options.push({ key: "openai", label: "OpenAI" }); options.push({ key: "custom", label: "Other OpenAI-compatible endpoint" }); options.push({ key: "anthropic", label: "Anthropic" }); - options.push({ key: "anthropicCompatible", label: "Other Anthropic-compatible endpoint" }); + options.push({ + key: "anthropicCompatible", + label: "Other Anthropic-compatible endpoint", + }); options.push({ key: "gemini", label: "Google Gemini" }); if (hasOllama || ollamaRunning) { options.push({ @@ -1509,7 +1760,10 @@ async function setupNim(gpu) { }); } if (EXPERIMENTAL && gpu && gpu.nimCapable) { - options.push({ key: "nim-local", label: "Local NVIDIA NIM [experimental]" }); + options.push({ + key: "nim-local", + label: "Local NVIDIA NIM [experimental]", + }); } if (EXPERIMENTAL && vllmRunning) { options.push({ @@ -1524,366 +1778,468 @@ async function setupNim(gpu) { } if (options.length > 1) { - selectionLoop: - while (true) { - let selected; - - if (isNonInteractive()) { - const providerKey = requestedProvider || "build"; - selected = options.find((o) => o.key === providerKey); - if (!selected) { - console.error(` Requested provider '${providerKey}' is not available in this environment.`); - process.exit(1); - } - note(` [non-interactive] Provider: ${selected.key}`); - } else { - const suggestions = []; - if (vllmRunning) suggestions.push("vLLM"); - if (ollamaRunning) suggestions.push("Ollama"); - if (suggestions.length > 0) { - console.log(` Detected local inference option${suggestions.length > 1 ? "s" : ""}: ${suggestions.join(", ")}`); - console.log(" Select one explicitly to use it. Press Enter to keep NVIDIA Endpoints."); - console.log(""); - } - - console.log(""); - console.log(" Inference options:"); - options.forEach((o, i) => { - console.log(` ${i + 1}) ${o.label}`); - }); - console.log(""); - - const defaultIdx = options.findIndex((o) => o.key === "build") + 1; - const choice = await prompt(` Choose [${defaultIdx}]: `); - const idx = parseInt(choice || String(defaultIdx), 10) - 1; - selected = options[idx] || options[defaultIdx - 1]; - } - - if (REMOTE_PROVIDER_CONFIG[selected.key]) { - const remoteConfig = REMOTE_PROVIDER_CONFIG[selected.key]; - provider = remoteConfig.providerName; - credentialEnv = remoteConfig.credentialEnv; - endpointUrl = remoteConfig.endpointUrl; - preferredInferenceApi = null; - - if (selected.key === "custom") { - endpointUrl = isNonInteractive() - ? (process.env.NEMOCLAW_ENDPOINT_URL || "").trim() - : await prompt(" OpenAI-compatible base URL (e.g., https://openrouter.ai/api/v1): "); - if (!endpointUrl) { - console.error(" Endpoint URL is required for Other OpenAI-compatible endpoint."); + selectionLoop: while (true) { + let selected; + + if (isNonInteractive()) { + const providerKey = requestedProvider || "build"; + selected = options.find((o) => o.key === providerKey); + if (!selected) { + console.error( + ` Requested provider '${providerKey}' is not available in this environment.`, + ); process.exit(1); } - } else if (selected.key === "anthropicCompatible") { - endpointUrl = isNonInteractive() - ? (process.env.NEMOCLAW_ENDPOINT_URL || "").trim() - : await prompt(" Anthropic-compatible base URL (e.g., https://proxy.example.com): "); - if (!endpointUrl) { - console.error(" Endpoint URL is required for Other Anthropic-compatible endpoint."); - process.exit(1); + note(` [non-interactive] Provider: ${selected.key}`); + } else { + const suggestions = []; + if (vllmRunning) suggestions.push("vLLM"); + if (ollamaRunning) suggestions.push("Ollama"); + if (suggestions.length > 0) { + console.log( + ` Detected local inference option${suggestions.length > 1 ? "s" : ""}: ${suggestions.join(", ")}`, + ); + console.log( + " Select one explicitly to use it. Press Enter to keep NVIDIA Endpoints.", + ); + console.log(""); } + + console.log(""); + console.log(" Inference options:"); + options.forEach((o, i) => { + console.log(` ${i + 1}) ${o.label}`); + }); + console.log(""); + + const defaultIdx = options.findIndex((o) => o.key === "build") + 1; + const choice = await prompt(` Choose [${defaultIdx}]: `); + const idx = parseInt(choice || String(defaultIdx), 10) - 1; + selected = options[idx] || options[defaultIdx - 1]; } - if (selected.key === "build") { - if (isNonInteractive()) { - if (!process.env.NVIDIA_API_KEY) { - console.error(" NVIDIA_API_KEY is required for NVIDIA Endpoints in non-interactive mode."); + if (REMOTE_PROVIDER_CONFIG[selected.key]) { + const remoteConfig = REMOTE_PROVIDER_CONFIG[selected.key]; + provider = remoteConfig.providerName; + credentialEnv = remoteConfig.credentialEnv; + endpointUrl = remoteConfig.endpointUrl; + preferredInferenceApi = null; + + if (selected.key === "custom") { + endpointUrl = isNonInteractive() + ? (process.env.NEMOCLAW_ENDPOINT_URL || "").trim() + : await prompt( + " OpenAI-compatible base URL (e.g., https://openrouter.ai/api/v1): ", + ); + if (!endpointUrl) { + console.error( + " Endpoint URL is required for Other OpenAI-compatible endpoint.", + ); process.exit(1); } - } else { - await ensureApiKey(); - } - model = requestedModel || (isNonInteractive() ? DEFAULT_CLOUD_MODEL : await promptCloudModel()) || DEFAULT_CLOUD_MODEL; - } else { - if (isNonInteractive()) { - if (!process.env[credentialEnv]) { - console.error(` ${credentialEnv} is required for ${remoteConfig.label} in non-interactive mode.`); + } else if (selected.key === "anthropicCompatible") { + endpointUrl = isNonInteractive() + ? (process.env.NEMOCLAW_ENDPOINT_URL || "").trim() + : await prompt( + " Anthropic-compatible base URL (e.g., https://proxy.example.com): ", + ); + if (!endpointUrl) { + console.error( + " Endpoint URL is required for Other Anthropic-compatible endpoint.", + ); process.exit(1); } - } else { - await ensureNamedCredential(credentialEnv, remoteConfig.label + " API key", remoteConfig.helpUrl); - } - const defaultModel = requestedModel || remoteConfig.defaultModel; - let modelValidator = null; - if (selected.key === "openai" || selected.key === "gemini") { - modelValidator = (candidate) => - validateOpenAiLikeModel(remoteConfig.label, endpointUrl, candidate, getCredential(credentialEnv)); - } else if (selected.key === "anthropic") { - modelValidator = (candidate) => - validateAnthropicModel(endpointUrl || ANTHROPIC_ENDPOINT_URL, candidate, getCredential(credentialEnv)); } - while (true) { + + if (selected.key === "build") { if (isNonInteractive()) { - model = defaultModel; - } else if (remoteConfig.modelMode === "curated") { - model = await promptRemoteModel(remoteConfig.label, selected.key, defaultModel, modelValidator); + if (!process.env.NVIDIA_API_KEY) { + console.error( + " NVIDIA_API_KEY is required for NVIDIA Endpoints in non-interactive mode.", + ); + process.exit(1); + } } else { - model = await promptInputModel(remoteConfig.label, defaultModel, modelValidator); + await ensureApiKey(); } - - if (selected.key === "custom") { - const validation = await validateCustomOpenAiLikeSelection( - remoteConfig.label, - endpointUrl, - model, - credentialEnv - ); - if (validation.ok) { - preferredInferenceApi = validation.api; - break; - } - if (validation.retry === "selection") { - continue selectionLoop; - } - } else if (selected.key === "anthropicCompatible") { - const validation = await validateCustomAnthropicSelection( - remoteConfig.label, - endpointUrl || ANTHROPIC_ENDPOINT_URL, - model, - credentialEnv - ); - if (validation.ok) { - preferredInferenceApi = validation.api; - break; - } - if (validation.retry === "selection") { - continue selectionLoop; + model = + requestedModel || + (isNonInteractive() + ? DEFAULT_CLOUD_MODEL + : await promptCloudModel()) || + DEFAULT_CLOUD_MODEL; + } else { + if (isNonInteractive()) { + if (!process.env[credentialEnv]) { + console.error( + ` ${credentialEnv} is required for ${remoteConfig.label} in non-interactive mode.`, + ); + process.exit(1); } } else { - const retryMessage = "Please choose a provider/model again."; - if (selected.key === "anthropic") { - preferredInferenceApi = await validateAnthropicSelectionWithRetryMessage( + await ensureNamedCredential( + credentialEnv, + remoteConfig.label + " API key", + remoteConfig.helpUrl, + ); + } + const defaultModel = requestedModel || remoteConfig.defaultModel; + let modelValidator = null; + if (selected.key === "openai" || selected.key === "gemini") { + modelValidator = (candidate) => + validateOpenAiLikeModel( remoteConfig.label, + endpointUrl, + candidate, + getCredential(credentialEnv), + ); + } else if (selected.key === "anthropic") { + modelValidator = (candidate) => + validateAnthropicModel( endpointUrl || ANTHROPIC_ENDPOINT_URL, - model, - credentialEnv, - retryMessage + candidate, + getCredential(credentialEnv), + ); + } + while (true) { + if (isNonInteractive()) { + model = defaultModel; + } else if (remoteConfig.modelMode === "curated") { + model = await promptRemoteModel( + remoteConfig.label, + selected.key, + defaultModel, + modelValidator, ); } else { - preferredInferenceApi = await validateOpenAiLikeSelection( + model = await promptInputModel( + remoteConfig.label, + defaultModel, + modelValidator, + ); + } + + if (selected.key === "custom") { + const validation = await validateCustomOpenAiLikeSelection( remoteConfig.label, endpointUrl, model, credentialEnv, - retryMessage ); + if (validation.ok) { + preferredInferenceApi = validation.api; + break; + } + if (validation.retry === "selection") { + continue selectionLoop; + } + } else if (selected.key === "anthropicCompatible") { + const validation = await validateCustomAnthropicSelection( + remoteConfig.label, + endpointUrl || ANTHROPIC_ENDPOINT_URL, + model, + credentialEnv, + ); + if (validation.ok) { + preferredInferenceApi = validation.api; + break; + } + if (validation.retry === "selection") { + continue selectionLoop; + } + } else { + const retryMessage = "Please choose a provider/model again."; + if (selected.key === "anthropic") { + preferredInferenceApi = + await validateAnthropicSelectionWithRetryMessage( + remoteConfig.label, + endpointUrl || ANTHROPIC_ENDPOINT_URL, + model, + credentialEnv, + retryMessage, + ); + } else { + preferredInferenceApi = await validateOpenAiLikeSelection( + remoteConfig.label, + endpointUrl, + model, + credentialEnv, + retryMessage, + ); + } + if (preferredInferenceApi) { + break; + } + continue selectionLoop; } - if (preferredInferenceApi) { - break; - } - continue selectionLoop; } } - } - if (selected.key === "build") { - preferredInferenceApi = await validateOpenAiLikeSelection( - remoteConfig.label, - endpointUrl, - model, - credentialEnv - ); - if (!preferredInferenceApi) { - continue selectionLoop; + if (selected.key === "build") { + preferredInferenceApi = await validateOpenAiLikeSelection( + remoteConfig.label, + endpointUrl, + model, + credentialEnv, + ); + if (!preferredInferenceApi) { + continue selectionLoop; + } } - } - console.log(` Using ${remoteConfig.label} with model: ${model}`); - break; - } else if (selected.key === "nim-local") { - // List models that fit GPU VRAM - const models = nim.listModels().filter((m) => m.minGpuMemoryMB <= gpu.totalMemoryMB); - if (models.length === 0) { - console.log(" No NIM models fit your GPU VRAM. Falling back to cloud API."); - } else { - let sel; - if (isNonInteractive()) { - if (requestedModel) { - sel = models.find((m) => m.name === requestedModel); - if (!sel) { - console.error(` Unsupported NEMOCLAW_MODEL for NIM: ${requestedModel}`); - process.exit(1); + console.log(` Using ${remoteConfig.label} with model: ${model}`); + break; + } else if (selected.key === "nim-local") { + // List models that fit GPU VRAM + const models = nim + .listModels() + .filter((m) => m.minGpuMemoryMB <= gpu.totalMemoryMB); + if (models.length === 0) { + console.log( + " No NIM models fit your GPU VRAM. Falling back to cloud API.", + ); + } else { + let sel; + if (isNonInteractive()) { + if (requestedModel) { + sel = models.find((m) => m.name === requestedModel); + if (!sel) { + console.error( + ` Unsupported NEMOCLAW_MODEL for NIM: ${requestedModel}`, + ); + process.exit(1); + } + } else { + sel = models[0]; } + note(` [non-interactive] NIM model: ${sel.name}`); } else { - sel = models[0]; - } - note(` [non-interactive] NIM model: ${sel.name}`); - } else { - console.log(""); - console.log(" Models that fit your GPU:"); - models.forEach((m, i) => { - console.log(` ${i + 1}) ${m.name} (min ${m.minGpuMemoryMB} MB)`); - }); - console.log(""); + console.log(""); + console.log(" Models that fit your GPU:"); + models.forEach((m, i) => { + console.log( + ` ${i + 1}) ${m.name} (min ${m.minGpuMemoryMB} MB)`, + ); + }); + console.log(""); - const modelChoice = await prompt(` Choose model [1]: `); - const midx = parseInt(modelChoice || "1", 10) - 1; - sel = models[midx] || models[0]; - } - model = sel.name; + const modelChoice = await prompt(` Choose model [1]: `); + const midx = parseInt(modelChoice || "1", 10) - 1; + sel = models[midx] || models[0]; + } + model = sel.name; - console.log(` Pulling NIM image for ${model}...`); - nim.pullNimImage(model); + console.log(` Pulling NIM image for ${model}...`); + nim.pullNimImage(model); - console.log(" Starting NIM container..."); - nimContainer = nim.startNimContainerByName(nim.containerName(GATEWAY_NAME), model); + console.log(" Starting NIM container..."); + nimContainer = nim.startNimContainerByName( + nim.containerName(GATEWAY_NAME), + model, + ); - console.log(" Waiting for NIM to become healthy..."); - if (!nim.waitForNimHealth()) { - console.error(" NIM failed to start. Falling back to cloud API."); - model = null; - nimContainer = null; - } else { - provider = "vllm-local"; - credentialEnv = "OPENAI_API_KEY"; - endpointUrl = getLocalProviderBaseUrl(provider); + console.log(" Waiting for NIM to become healthy..."); + if (!nim.waitForNimHealth()) { + console.error(" NIM failed to start. Falling back to cloud API."); + model = null; + nimContainer = null; + } else { + provider = "vllm-local"; + credentialEnv = "OPENAI_API_KEY"; + endpointUrl = getLocalProviderBaseUrl(provider); + preferredInferenceApi = await validateOpenAiLikeSelection( + "Local NVIDIA NIM", + endpointUrl, + model, + credentialEnv, + ); + if (!preferredInferenceApi) { + continue selectionLoop; + } + } + } + break; + } else if (selected.key === "ollama") { + if (!ollamaRunning) { + console.log(" Starting Ollama..."); + run("OLLAMA_HOST=0.0.0.0:11434 ollama serve > /dev/null 2>&1 &", { + ignoreError: true, + }); + sleep(2); + } + console.log(" ✓ Using Ollama on localhost:11434"); + provider = "ollama-local"; + credentialEnv = "OPENAI_API_KEY"; + endpointUrl = getLocalProviderBaseUrl(provider); + while (true) { + const installedModels = getOllamaModelOptions(runCapture); + if (isNonInteractive()) { + model = requestedModel || getDefaultOllamaModel(runCapture, gpu); + } else { + model = await promptOllamaModel(gpu); + } + const probe = prepareOllamaModel(model, installedModels); + if (!probe.ok) { + console.error(` ${probe.message}`); + if (isNonInteractive()) { + process.exit(1); + } + console.log(" Choose a different Ollama model or select Other."); + console.log(""); + continue; + } preferredInferenceApi = await validateOpenAiLikeSelection( - "Local NVIDIA NIM", - endpointUrl, + "Local Ollama", + getLocalProviderValidationBaseUrl(provider), model, - credentialEnv + null, + "Choose a different Ollama model or select Other.", ); if (!preferredInferenceApi) { - continue selectionLoop; + continue; } + break; } - } - break; - } else if (selected.key === "ollama") { - if (!ollamaRunning) { + break; + } else if (selected.key === "install-ollama") { + console.log(" Installing Ollama via Homebrew..."); + run("brew install ollama", { ignoreError: true }); console.log(" Starting Ollama..."); - run("OLLAMA_HOST=0.0.0.0:11434 ollama serve > /dev/null 2>&1 &", { ignoreError: true }); + run("OLLAMA_HOST=0.0.0.0:11434 ollama serve > /dev/null 2>&1 &", { + ignoreError: true, + }); sleep(2); - } - console.log(" ✓ Using Ollama on localhost:11434"); - provider = "ollama-local"; - credentialEnv = "OPENAI_API_KEY"; - endpointUrl = getLocalProviderBaseUrl(provider); - while (true) { - const installedModels = getOllamaModelOptions(runCapture); - if (isNonInteractive()) { - model = requestedModel || getDefaultOllamaModel(runCapture, gpu); - } else { - model = await promptOllamaModel(gpu); - } - const probe = prepareOllamaModel(model, installedModels); - if (!probe.ok) { - console.error(` ${probe.message}`); + console.log(" ✓ Using Ollama on localhost:11434"); + provider = "ollama-local"; + credentialEnv = "OPENAI_API_KEY"; + endpointUrl = getLocalProviderBaseUrl(provider); + while (true) { + const installedModels = getOllamaModelOptions(runCapture); if (isNonInteractive()) { - process.exit(1); + model = requestedModel || getDefaultOllamaModel(runCapture, gpu); + } else { + model = await promptOllamaModel(gpu); } - console.log(" Choose a different Ollama model or select Other."); - console.log(""); - continue; - } - preferredInferenceApi = await validateOpenAiLikeSelection( - "Local Ollama", - getLocalProviderValidationBaseUrl(provider), - model, - null, - "Choose a different Ollama model or select Other." - ); - if (!preferredInferenceApi) { - continue; + const probe = prepareOllamaModel(model, installedModels); + if (!probe.ok) { + console.error(` ${probe.message}`); + if (isNonInteractive()) { + process.exit(1); + } + console.log(" Choose a different Ollama model or select Other."); + console.log(""); + continue; + } + preferredInferenceApi = await validateOpenAiLikeSelection( + "Local Ollama", + getLocalProviderValidationBaseUrl(provider), + model, + null, + "Choose a different Ollama model or select Other.", + ); + if (!preferredInferenceApi) { + continue; + } + break; } break; - } - break; - } else if (selected.key === "install-ollama") { - console.log(" Installing Ollama via Homebrew..."); - run("brew install ollama", { ignoreError: true }); - console.log(" Starting Ollama..."); - run("OLLAMA_HOST=0.0.0.0:11434 ollama serve > /dev/null 2>&1 &", { ignoreError: true }); - sleep(2); - console.log(" ✓ Using Ollama on localhost:11434"); - provider = "ollama-local"; - credentialEnv = "OPENAI_API_KEY"; - endpointUrl = getLocalProviderBaseUrl(provider); - while (true) { - const installedModels = getOllamaModelOptions(runCapture); - if (isNonInteractive()) { - model = requestedModel || getDefaultOllamaModel(runCapture, gpu); - } else { - model = await promptOllamaModel(gpu); - } - const probe = prepareOllamaModel(model, installedModels); - if (!probe.ok) { - console.error(` ${probe.message}`); - if (isNonInteractive()) { + } else if (selected.key === "vllm") { + console.log(" ✓ Using existing vLLM on localhost:8000"); + provider = "vllm-local"; + credentialEnv = "OPENAI_API_KEY"; + endpointUrl = getLocalProviderBaseUrl(provider); + // Query vLLM for the actual model ID + const vllmModelsRaw = runCapture( + "curl -sf http://localhost:8000/v1/models 2>/dev/null", + { ignoreError: true }, + ); + try { + const vllmModels = JSON.parse(vllmModelsRaw); + if (vllmModels.data && vllmModels.data.length > 0) { + model = vllmModels.data[0].id; + if (!isSafeModelId(model)) { + console.error( + ` Detected model ID contains invalid characters: ${model}`, + ); + process.exit(1); + } + console.log(` Detected model: ${model}`); + } else { + console.error( + " Could not detect model from vLLM. Please specify manually.", + ); process.exit(1); } - console.log(" Choose a different Ollama model or select Other."); - console.log(""); - continue; + } catch { + console.error( + " Could not query vLLM models endpoint. Is vLLM running on localhost:8000?", + ); + process.exit(1); } preferredInferenceApi = await validateOpenAiLikeSelection( - "Local Ollama", + "Local vLLM", getLocalProviderValidationBaseUrl(provider), model, - null, - "Choose a different Ollama model or select Other." + credentialEnv, ); if (!preferredInferenceApi) { - continue; + continue selectionLoop; } break; } - break; - } else if (selected.key === "vllm") { - console.log(" ✓ Using existing vLLM on localhost:8000"); - provider = "vllm-local"; - credentialEnv = "OPENAI_API_KEY"; - endpointUrl = getLocalProviderBaseUrl(provider); - // Query vLLM for the actual model ID - const vllmModelsRaw = runCapture("curl -sf http://localhost:8000/v1/models 2>/dev/null", { ignoreError: true }); - try { - const vllmModels = JSON.parse(vllmModelsRaw); - if (vllmModels.data && vllmModels.data.length > 0) { - model = vllmModels.data[0].id; - if (!isSafeModelId(model)) { - console.error(` Detected model ID contains invalid characters: ${model}`); - process.exit(1); - } - console.log(` Detected model: ${model}`); - } else { - console.error(" Could not detect model from vLLM. Please specify manually."); - process.exit(1); - } - } catch { - console.error(" Could not query vLLM models endpoint. Is vLLM running on localhost:8000?"); - process.exit(1); - } - preferredInferenceApi = await validateOpenAiLikeSelection( - "Local vLLM", - getLocalProviderValidationBaseUrl(provider), - model, - credentialEnv - ); - if (!preferredInferenceApi) { - continue selectionLoop; - } - break; } } - } - return { model, provider, endpointUrl, credentialEnv, preferredInferenceApi, nimContainer }; + return { + model, + provider, + endpointUrl, + credentialEnv, + preferredInferenceApi, + nimContainer, + }; } // ── Step 5: Inference provider ─────────────────────────────────── -async function setupInference(sandboxName, model, provider, endpointUrl = null, credentialEnv = null) { +async function setupInference( + sandboxName, + model, + provider, + endpointUrl = null, + credentialEnv = null, +) { step(4, 7, "Setting up inference provider"); runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true }); - if (provider === "nvidia-prod" || provider === "nvidia-nim" || provider === "openai-api" || provider === "anthropic-prod" || provider === "compatible-anthropic-endpoint" || provider === "gemini-api" || provider === "compatible-endpoint") { - const config = provider === "nvidia-nim" - ? REMOTE_PROVIDER_CONFIG.build - : Object.values(REMOTE_PROVIDER_CONFIG).find((entry) => entry.providerName === provider); - const resolvedCredentialEnv = credentialEnv || (config && config.credentialEnv); + if ( + provider === "nvidia-prod" || + provider === "nvidia-nim" || + provider === "openai-api" || + provider === "anthropic-prod" || + provider === "compatible-anthropic-endpoint" || + provider === "gemini-api" || + provider === "compatible-endpoint" + ) { + const config = + provider === "nvidia-nim" + ? REMOTE_PROVIDER_CONFIG.build + : Object.values(REMOTE_PROVIDER_CONFIG).find( + (entry) => entry.providerName === provider, + ); + const resolvedCredentialEnv = + credentialEnv || (config && config.credentialEnv); const resolvedEndpointUrl = endpointUrl || (config && config.endpointUrl); - const env = resolvedCredentialEnv ? { [resolvedCredentialEnv]: process.env[resolvedCredentialEnv] } : {}; - upsertProvider(provider, config.providerType, resolvedCredentialEnv, resolvedEndpointUrl, env); + const env = resolvedCredentialEnv + ? { [resolvedCredentialEnv]: process.env[resolvedCredentialEnv] } + : {}; + upsertProvider( + provider, + config.providerType, + resolvedCredentialEnv, + resolvedEndpointUrl, + env, + ); const args = ["inference", "set"]; if (config.skipVerify) { args.push("--no-verify"); @@ -1900,19 +2256,37 @@ async function setupInference(sandboxName, model, provider, endpointUrl = null, upsertProvider("vllm-local", "openai", "OPENAI_API_KEY", baseUrl, { OPENAI_API_KEY: "dummy", }); - runOpenshell(["inference", "set", "--no-verify", "--provider", "vllm-local", "--model", model]); + runOpenshell([ + "inference", + "set", + "--no-verify", + "--provider", + "vllm-local", + "--model", + model, + ]); } else if (provider === "ollama-local") { const validation = validateLocalProvider(provider, runCapture); if (!validation.ok) { console.error(` ${validation.message}`); - console.error(" On macOS, local inference also depends on OpenShell host routing support."); + console.error( + " On macOS, local inference also depends on OpenShell host routing support.", + ); process.exit(1); } const baseUrl = getLocalProviderBaseUrl(provider); upsertProvider("ollama-local", "openai", "OPENAI_API_KEY", baseUrl, { OPENAI_API_KEY: "ollama", }); - runOpenshell(["inference", "set", "--no-verify", "--provider", "ollama-local", "--model", model]); + runOpenshell([ + "inference", + "set", + "--no-verify", + "--provider", + "ollama-local", + "--model", + model, + ]); console.log(` Priming Ollama model: ${model}`); run(getOllamaWarmupCommand(model), { ignoreError: true }); const probe = validateOllamaModel(model, runCapture); @@ -1943,7 +2317,7 @@ async function setupOpenclaw(sandboxName, model, provider) { try { run( `${openshellShellCommand(["sandbox", "connect", sandboxName])} < ${shellQuote(scriptFile)}`, - { stdio: ["ignore", "ignore", "inherit"] } + { stdio: ["ignore", "ignore", "inherit"] }, ); } finally { fs.unlinkSync(scriptFile); @@ -1963,7 +2337,9 @@ async function setupPolicies(sandboxName) { // Auto-detect based on env tokens if (getCredential("TELEGRAM_BOT_TOKEN")) { suggestions.push("telegram"); - console.log(" Auto-detected: TELEGRAM_BOT_TOKEN → suggesting telegram preset"); + console.log( + " Auto-detected: TELEGRAM_BOT_TOKEN → suggesting telegram preset", + ); } if (getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN) { suggestions.push("slack"); @@ -1971,14 +2347,18 @@ async function setupPolicies(sandboxName) { } if (getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN) { suggestions.push("discord"); - console.log(" Auto-detected: DISCORD_BOT_TOKEN → suggesting discord preset"); + console.log( + " Auto-detected: DISCORD_BOT_TOKEN → suggesting discord preset", + ); } const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); if (isNonInteractive()) { - const policyMode = (process.env.NEMOCLAW_POLICY_MODE || "suggested").trim().toLowerCase(); + const policyMode = (process.env.NEMOCLAW_POLICY_MODE || "suggested") + .trim() + .toLowerCase(); let selectedPresets = suggestions; if (policyMode === "skip" || policyMode === "none" || policyMode === "no") { @@ -1987,13 +2367,23 @@ async function setupPolicies(sandboxName) { } if (policyMode === "custom" || policyMode === "list") { - selectedPresets = parsePolicyPresetEnv(process.env.NEMOCLAW_POLICY_PRESETS); + selectedPresets = parsePolicyPresetEnv( + process.env.NEMOCLAW_POLICY_PRESETS, + ); if (selectedPresets.length === 0) { - console.error(" NEMOCLAW_POLICY_PRESETS is required when NEMOCLAW_POLICY_MODE=custom."); + console.error( + " NEMOCLAW_POLICY_PRESETS is required when NEMOCLAW_POLICY_MODE=custom.", + ); process.exit(1); } - } else if (policyMode === "suggested" || policyMode === "default" || policyMode === "auto") { - const envPresets = parsePolicyPresetEnv(process.env.NEMOCLAW_POLICY_PRESETS); + } else if ( + policyMode === "suggested" || + policyMode === "default" || + policyMode === "auto" + ) { + const envPresets = parsePolicyPresetEnv( + process.env.NEMOCLAW_POLICY_PRESETS, + ); if (envPresets.length > 0) { selectedPresets = envPresets; } @@ -2004,17 +2394,23 @@ async function setupPolicies(sandboxName) { } const knownPresets = new Set(allPresets.map((p) => p.name)); - const invalidPresets = selectedPresets.filter((name) => !knownPresets.has(name)); + const invalidPresets = selectedPresets.filter( + (name) => !knownPresets.has(name), + ); if (invalidPresets.length > 0) { console.error(` Unknown policy preset(s): ${invalidPresets.join(", ")}`); process.exit(1); } if (!waitForSandboxReady(sandboxName)) { - console.error(` Sandbox '${sandboxName}' was not ready for policy application.`); + console.error( + ` Sandbox '${sandboxName}' was not ready for policy application.`, + ); process.exit(1); } - note(` [non-interactive] Applying policy presets: ${selectedPresets.join(", ")}`); + note( + ` [non-interactive] Applying policy presets: ${selectedPresets.join(", ")}`, + ); for (const name of selectedPresets) { for (let attempt = 0; attempt < 3; attempt += 1) { try { @@ -2023,7 +2419,9 @@ async function setupPolicies(sandboxName) { } catch (err) { const message = err && err.message ? err.message : String(err); if (message.includes("Unimplemented")) { - console.error(" OpenShell policy updates are not supported by this gateway build."); + console.error( + " OpenShell policy updates are not supported by this gateway build.", + ); console.error(" This is a known issue tracked in NemoClaw #536."); throw err; } @@ -2044,7 +2442,9 @@ async function setupPolicies(sandboxName) { }); console.log(""); - const answer = await prompt(` Apply suggested presets (${suggestions.join(", ")})? [Y/n/list]: `); + const answer = await prompt( + ` Apply suggested presets (${suggestions.join(", ")})? [Y/n/list]: `, + ); if (answer.toLowerCase() === "n") { console.log(" Skipping policy presets."); @@ -2054,14 +2454,19 @@ async function setupPolicies(sandboxName) { if (answer.toLowerCase() === "list") { // Let user pick const picks = await prompt(" Enter preset names (comma-separated): "); - const selected = picks.split(",").map((s) => s.trim()).filter(Boolean); + const selected = picks + .split(",") + .map((s) => s.trim()) + .filter(Boolean); for (const name of selected) { try { policies.applyPreset(sandboxName, name); } catch (err) { const message = err && err.message ? err.message : String(err); if (message.includes("Unimplemented")) { - console.error(" OpenShell policy updates are not supported by this gateway build."); + console.error( + " OpenShell policy updates are not supported by this gateway build.", + ); console.error(" This is a known issue tracked in NemoClaw #536."); } throw err; @@ -2075,7 +2480,9 @@ async function setupPolicies(sandboxName) { } catch (err) { const message = err && err.message ? err.message : String(err); if (message.includes("Unimplemented")) { - console.error(" OpenShell policy updates are not supported by this gateway build."); + console.error( + " OpenShell policy updates are not supported by this gateway build.", + ); console.error(" This is a known issue tracked in NemoClaw #536."); } throw err; @@ -2090,16 +2497,21 @@ async function setupPolicies(sandboxName) { // ── Dashboard ──────────────────────────────────────────────────── function printDashboard(sandboxName, model, provider, nimContainer = null) { - const nimStat = nimContainer ? nim.nimStatusByName(nimContainer) : nim.nimStatus(sandboxName); + const nimStat = nimContainer + ? nim.nimStatusByName(nimContainer) + : nim.nimStatus(sandboxName); const nimLabel = nimStat.running ? "running" : "not running"; let providerLabel = provider; - if (provider === "nvidia-prod" || provider === "nvidia-nim") providerLabel = "NVIDIA Endpoints"; + if (provider === "nvidia-prod" || provider === "nvidia-nim") + providerLabel = "NVIDIA Endpoints"; else if (provider === "openai-api") providerLabel = "OpenAI"; else if (provider === "anthropic-prod") providerLabel = "Anthropic"; - else if (provider === "compatible-anthropic-endpoint") providerLabel = "Other Anthropic-compatible endpoint"; + else if (provider === "compatible-anthropic-endpoint") + providerLabel = "Other Anthropic-compatible endpoint"; else if (provider === "gemini-api") providerLabel = "Google Gemini"; - else if (provider === "compatible-endpoint") providerLabel = "Other OpenAI-compatible endpoint"; + else if (provider === "compatible-endpoint") + providerLabel = "Other OpenAI-compatible endpoint"; else if (provider === "vllm-local") providerLabel = "Local vLLM"; else if (provider === "ollama-local") providerLabel = "Local Ollama"; @@ -2121,7 +2533,8 @@ function printDashboard(sandboxName, model, provider, nimContainer = null) { // ── Main ───────────────────────────────────────────────────────── async function onboard(opts = {}) { - NON_INTERACTIVE = opts.nonInteractive || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; + NON_INTERACTIVE = + opts.nonInteractive || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; delete process.env.OPENSHELL_GATEWAY; console.log(""); @@ -2130,11 +2543,29 @@ async function onboard(opts = {}) { console.log(" ==================="); const gpu = await preflight(); - const { model, provider, endpointUrl, credentialEnv, preferredInferenceApi, nimContainer } = await setupNim(gpu); + const { + model, + provider, + endpointUrl, + credentialEnv, + preferredInferenceApi, + nimContainer, + } = await setupNim(gpu); process.env.NEMOCLAW_OPENSHELL_BIN = getOpenshellBinary(); await startGateway(gpu); - await setupInference(GATEWAY_NAME, model, provider, endpointUrl, credentialEnv); - const sandboxName = await createSandbox(gpu, model, provider, preferredInferenceApi); + await setupInference( + GATEWAY_NAME, + model, + provider, + endpointUrl, + credentialEnv, + ); + const sandboxName = await createSandbox( + gpu, + model, + provider, + preferredInferenceApi, + ); if (nimContainer) { registry.updateSandbox(sandboxName, { nimContainer }); } diff --git a/bin/lib/platform.js b/bin/lib/platform.js index f57155d50..24ed8d8b5 100644 --- a/bin/lib/platform.js +++ b/bin/lib/platform.js @@ -44,7 +44,11 @@ function getColimaDockerSocketCandidates(opts = {}) { function findColimaDockerSocket(opts = {}) { const existsSync = opts.existsSync ?? require("fs").existsSync; - return getColimaDockerSocketCandidates(opts).find((socketPath) => existsSync(socketPath)) ?? null; + return ( + getColimaDockerSocketCandidates(opts).find((socketPath) => + existsSync(socketPath), + ) ?? null + ); } function getPodmanSocketCandidates(opts = {}) { @@ -58,10 +62,7 @@ function getPodmanSocketCandidates(opts = {}) { ]; } - return [ - `/run/user/${uid}/podman/podman.sock`, - "/run/podman/podman.sock", - ]; + return [`/run/user/${uid}/podman/podman.sock`, "/run/podman/podman.sock"]; } function getDockerSocketCandidates(opts = {}) { diff --git a/test/platform.test.js b/test/platform.test.js index dda7f0159..ecd86043c 100644 --- a/test/platform.test.js +++ b/test/platform.test.js @@ -17,19 +17,23 @@ import { describe("platform helpers", () => { describe("isWsl", () => { it("detects WSL from environment", () => { - expect(isWsl({ - platform: "linux", - env: { WSL_DISTRO_NAME: "Ubuntu" }, - release: "6.6.87.2-microsoft-standard-WSL2", - })).toBe(true); + expect( + isWsl({ + platform: "linux", + env: { WSL_DISTRO_NAME: "Ubuntu" }, + release: "6.6.87.2-microsoft-standard-WSL2", + }), + ).toBe(true); }); it("does not treat macOS as WSL", () => { - expect(isWsl({ - platform: "darwin", - env: {}, - release: "24.6.0", - })).toBe(false); + expect( + isWsl({ + platform: "darwin", + env: {}, + release: "24.6.0", + }), + ).toBe(false); }); }); @@ -42,7 +46,13 @@ describe("platform helpers", () => { }); it("returns Linux Podman socket paths with uid", () => { - expect(getPodmanSocketCandidates({ platform: "linux", home: "/tmp/test-home", uid: 1001 })).toEqual([ + expect( + getPodmanSocketCandidates({ + platform: "linux", + home: "/tmp/test-home", + uid: 1001, + }), + ).toEqual([ "/run/user/1001/podman/podman.sock", "/run/podman/podman.sock", ]); @@ -61,7 +71,13 @@ describe("platform helpers", () => { }); it("returns Linux candidates (Podman > native Docker)", () => { - expect(getDockerSocketCandidates({ platform: "linux", home: "/tmp/test-home", uid: 1000 })).toEqual([ + expect( + getDockerSocketCandidates({ + platform: "linux", + home: "/tmp/test-home", + uid: 1000, + }), + ).toEqual([ "/run/user/1000/podman/podman.sock", "/run/podman/podman.sock", "/run/docker.sock", @@ -73,21 +89,27 @@ describe("platform helpers", () => { describe("findColimaDockerSocket", () => { it("finds the first available Colima socket", () => { const home = "/tmp/test-home"; - const sockets = new Set([path.join(home, ".config/colima/default/docker.sock")]); + const sockets = new Set([ + path.join(home, ".config/colima/default/docker.sock"), + ]); const existsSync = (socketPath) => sockets.has(socketPath); - expect(findColimaDockerSocket({ home, existsSync })).toBe(path.join(home, ".config/colima/default/docker.sock")); + expect(findColimaDockerSocket({ home, existsSync })).toBe( + path.join(home, ".config/colima/default/docker.sock"), + ); }); }); describe("detectDockerHost", () => { it("respects an existing DOCKER_HOST", () => { - expect(detectDockerHost({ - env: { DOCKER_HOST: "unix:///custom/docker.sock" }, - platform: "darwin", - home: "/tmp/test-home", - existsSync: () => false, - })).toEqual({ + expect( + detectDockerHost({ + env: { DOCKER_HOST: "unix:///custom/docker.sock" }, + platform: "darwin", + home: "/tmp/test-home", + existsSync: () => false, + }), + ).toEqual({ dockerHost: "unix:///custom/docker.sock", source: "env", socketPath: null, @@ -102,7 +124,9 @@ describe("platform helpers", () => { ]); const existsSync = (socketPath) => sockets.has(socketPath); - expect(detectDockerHost({ env: {}, platform: "darwin", home, existsSync })).toEqual({ + expect( + detectDockerHost({ env: {}, platform: "darwin", home, existsSync }), + ).toEqual({ dockerHost: `unix://${path.join(home, ".colima/default/docker.sock")}`, source: "socket", socketPath: path.join(home, ".colima/default/docker.sock"), @@ -114,7 +138,9 @@ describe("platform helpers", () => { const socketPath = path.join(home, ".docker/run/docker.sock"); const existsSync = (candidate) => candidate === socketPath; - expect(detectDockerHost({ env: {}, platform: "darwin", home, existsSync })).toEqual({ + expect( + detectDockerHost({ env: {}, platform: "darwin", home, existsSync }), + ).toEqual({ dockerHost: `unix://${socketPath}`, source: "socket", socketPath, @@ -122,12 +148,14 @@ describe("platform helpers", () => { }); it("returns null when no auto-detected socket is available", () => { - expect(detectDockerHost({ - env: {}, - platform: "linux", - home: "/tmp/test-home", - existsSync: () => false, - })).toBe(null); + expect( + detectDockerHost({ + env: {}, + platform: "linux", + home: "/tmp/test-home", + existsSync: () => false, + }), + ).toBe(null); }); }); @@ -137,11 +165,15 @@ describe("platform helpers", () => { }); it("detects Docker Desktop", () => { - expect(inferContainerRuntime("Docker Desktop 4.42.0 (190636)")).toBe("docker-desktop"); + expect(inferContainerRuntime("Docker Desktop 4.42.0 (190636)")).toBe( + "docker-desktop", + ); }); it("detects Colima", () => { - expect(inferContainerRuntime("Server: Colima\n Docker Engine - Community")).toBe("colima"); + expect( + inferContainerRuntime("Server: Colima\n Docker Engine - Community"), + ).toBe("colima"); }); }); @@ -157,10 +189,15 @@ describe("platform helpers", () => { describe("detectDockerHost with Podman", () => { it("detects Podman socket on macOS when Colima is absent", () => { const home = "/tmp/test-home"; - const podmanSocket = path.join(home, ".local/share/containers/podman/machine/podman.sock"); + const podmanSocket = path.join( + home, + ".local/share/containers/podman/machine/podman.sock", + ); const existsSync = (candidate) => candidate === podmanSocket; - expect(detectDockerHost({ env: {}, platform: "darwin", home, existsSync })).toEqual({ + expect( + detectDockerHost({ env: {}, platform: "darwin", home, existsSync }), + ).toEqual({ dockerHost: `unix://${podmanSocket}`, source: "socket", socketPath: podmanSocket, @@ -170,11 +207,16 @@ describe("platform helpers", () => { it("prefers Colima over Podman on macOS", () => { const home = "/tmp/test-home"; const colimaSocket = path.join(home, ".colima/default/docker.sock"); - const podmanSocket = path.join(home, ".local/share/containers/podman/machine/podman.sock"); + const podmanSocket = path.join( + home, + ".local/share/containers/podman/machine/podman.sock", + ); const sockets = new Set([colimaSocket, podmanSocket]); const existsSync = (candidate) => sockets.has(candidate); - expect(detectDockerHost({ env: {}, platform: "darwin", home, existsSync })).toEqual({ + expect( + detectDockerHost({ env: {}, platform: "darwin", home, existsSync }), + ).toEqual({ dockerHost: `unix://${colimaSocket}`, source: "socket", socketPath: colimaSocket, diff --git a/test/runtime-shell.test.js b/test/runtime-shell.test.js index 799d4d5df..8d067a0ca 100644 --- a/test/runtime-shell.test.js +++ b/test/runtime-shell.test.js @@ -7,7 +7,13 @@ import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; -const RUNTIME_SH = path.join(import.meta.dirname, "..", "scripts", "lib", "runtime.sh"); +const RUNTIME_SH = path.join( + import.meta.dirname, + "..", + "scripts", + "lib", + "runtime.sh", +); function runShell(script, env = {}) { return spawnSync("bash", ["-lc", script], { @@ -29,7 +35,9 @@ describe("shell runtime helpers", () => { }); it("prefers Colima over Docker Desktop", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-shell-")); + const home = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-runtime-shell-"), + ); const colimaSocket = path.join(home, ".colima/default/docker.sock"); const dockerDesktopSocket = path.join(home, ".docker/run/docker.sock"); @@ -45,7 +53,9 @@ describe("shell runtime helpers", () => { }); it("detects Docker Desktop when Colima is absent", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-shell-")); + const home = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-runtime-shell-"), + ); const dockerDesktopSocket = path.join(home, ".docker/run/docker.sock"); const result = runShell(`source "${RUNTIME_SH}"; detect_docker_host`, { @@ -60,7 +70,9 @@ describe("shell runtime helpers", () => { }); it("classifies a Docker Desktop DOCKER_HOST correctly", () => { - const result = runShell(`source "${RUNTIME_SH}"; docker_host_runtime "unix:///Users/test/.docker/run/docker.sock"`); + const result = runShell( + `source "${RUNTIME_SH}"; docker_host_runtime "unix:///Users/test/.docker/run/docker.sock"`, + ); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe("docker-desktop"); @@ -86,13 +98,21 @@ describe("shell runtime helpers", () => { }); it("finds the XDG Colima socket", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-shell-")); - const xdgColimaSocket = path.join(home, ".config/colima/default/docker.sock"); + const home = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-runtime-shell-"), + ); + const xdgColimaSocket = path.join( + home, + ".config/colima/default/docker.sock", + ); - const result = runShell(`source "${RUNTIME_SH}"; find_colima_docker_socket`, { - HOME: home, - NEMOCLAW_TEST_SOCKET_PATHS: xdgColimaSocket, - }); + const result = runShell( + `source "${RUNTIME_SH}"; find_colima_docker_socket`, + { + HOME: home, + NEMOCLAW_TEST_SOCKET_PATHS: xdgColimaSocket, + }, + ); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe(xdgColimaSocket); @@ -100,14 +120,21 @@ describe("shell runtime helpers", () => { }); it("detects podman from docker info output", () => { - const result = runShell(`source "${RUNTIME_SH}"; infer_container_runtime_from_info "podman version 5.4.1"`); + const result = runShell( + `source "${RUNTIME_SH}"; infer_container_runtime_from_info "podman version 5.4.1"`, + ); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe("podman"); }); it("detects Podman socket on macOS", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-shell-")); - const podmanSocket = path.join(home, ".local/share/containers/podman/machine/podman.sock"); + const home = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-runtime-shell-"), + ); + const podmanSocket = path.join( + home, + ".local/share/containers/podman/machine/podman.sock", + ); const result = runShell( `uname() { printf 'Darwin\\n'; }; source "${RUNTIME_SH}"; find_podman_socket`, @@ -123,31 +150,43 @@ describe("shell runtime helpers", () => { }); it("classifies a Podman DOCKER_HOST correctly", () => { - const result = runShell(`source "${RUNTIME_SH}"; docker_host_runtime "unix:///run/user/1000/podman/podman.sock"`); + const result = runShell( + `source "${RUNTIME_SH}"; docker_host_runtime "unix:///run/user/1000/podman/podman.sock"`, + ); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe("podman"); }); it("classifies a Podman machine socket correctly", () => { - const result = runShell(`source "${RUNTIME_SH}"; docker_host_runtime "unix:///Users/test/.local/share/containers/podman/machine/podman.sock"`); + const result = runShell( + `source "${RUNTIME_SH}"; docker_host_runtime "unix:///Users/test/.local/share/containers/podman/machine/podman.sock"`, + ); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe("podman"); }); it("returns the vllm-local base URL", () => { - const result = runShell(`source "${RUNTIME_SH}"; get_local_provider_base_url vllm-local`); + const result = runShell( + `source "${RUNTIME_SH}"; get_local_provider_base_url vllm-local`, + ); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe("http://host.openshell.internal:8000/v1"); }); it("returns the ollama-local base URL", () => { - const result = runShell(`source "${RUNTIME_SH}"; get_local_provider_base_url ollama-local`); + const result = runShell( + `source "${RUNTIME_SH}"; get_local_provider_base_url ollama-local`, + ); expect(result.status).toBe(0); - expect(result.stdout.trim()).toBe("http://host.openshell.internal:11434/v1"); + expect(result.stdout.trim()).toBe( + "http://host.openshell.internal:11434/v1", + ); }); it("rejects unknown local providers", () => { - const result = runShell(`source "${RUNTIME_SH}"; get_local_provider_base_url bogus-provider`); + const result = runShell( + `source "${RUNTIME_SH}"; get_local_provider_base_url bogus-provider`, + ); expect(result.status).not.toBe(0); }); diff --git a/test/smoke-macos-install.test.js b/test/smoke-macos-install.test.js index 1fcd7a49f..5f952066a 100644 --- a/test/smoke-macos-install.test.js +++ b/test/smoke-macos-install.test.js @@ -5,7 +5,12 @@ import { describe, it, expect } from "vitest"; import path from "node:path"; import { spawnSync } from "node:child_process"; -const SMOKE_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "smoke-macos-install.sh"); +const SMOKE_SCRIPT = path.join( + import.meta.dirname, + "..", + "scripts", + "smoke-macos-install.sh", +); describe("macOS smoke install script guardrails", () => { it("prints help", () => { @@ -15,7 +20,9 @@ describe("macOS smoke install script guardrails", () => { }); expect(result.status).toBe(0); - expect(result.stdout).toMatch(/Usage: \.\/scripts\/smoke-macos-install\.sh/); + expect(result.stdout).toMatch( + /Usage: \.\/scripts\/smoke-macos-install\.sh/, + ); }); it("requires NVIDIA_API_KEY", () => { @@ -26,15 +33,21 @@ describe("macOS smoke install script guardrails", () => { }); expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toMatch(/NVIDIA_API_KEY must be set/); + expect(`${result.stdout}${result.stderr}`).toMatch( + /NVIDIA_API_KEY must be set/, + ); }); it("rejects invalid sandbox names", () => { - const result = spawnSync("bash", [SMOKE_SCRIPT, "--sandbox-name", "Bad Name"], { - cwd: path.join(import.meta.dirname, ".."), - encoding: "utf-8", - env: { ...process.env, NVIDIA_API_KEY: "nvapi-test" }, - }); + const result = spawnSync( + "bash", + [SMOKE_SCRIPT, "--sandbox-name", "Bad Name"], + { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { ...process.env, NVIDIA_API_KEY: "nvapi-test" }, + }, + ); expect(result.status).not.toBe(0); expect(`${result.stdout}${result.stderr}`).toMatch(/Invalid sandbox name/); @@ -48,7 +61,9 @@ describe("macOS smoke install script guardrails", () => { }); expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toMatch(/Unsupported runtime 'lxc'/); + expect(`${result.stdout}${result.stderr}`).toMatch( + /Unsupported runtime 'lxc'/, + ); }); it("accepts podman as a runtime option", () => { @@ -63,22 +78,30 @@ describe("macOS smoke install script guardrails", () => { }); expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toMatch(/no Podman socket was found/); + expect(`${result.stdout}${result.stderr}`).toMatch( + /no Podman socket was found/, + ); }); it("fails when a requested runtime socket is unavailable", () => { - const result = spawnSync("bash", [SMOKE_SCRIPT, "--runtime", "docker-desktop"], { - cwd: path.join(import.meta.dirname, ".."), - encoding: "utf-8", - env: { - ...process.env, - NVIDIA_API_KEY: "nvapi-test", - HOME: "/tmp/nemoclaw-smoke-no-runtime", + const result = spawnSync( + "bash", + [SMOKE_SCRIPT, "--runtime", "docker-desktop"], + { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { + ...process.env, + NVIDIA_API_KEY: "nvapi-test", + HOME: "/tmp/nemoclaw-smoke-no-runtime", + }, }, - }); + ); expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toMatch(/no Docker Desktop socket was found/); + expect(`${result.stdout}${result.stderr}`).toMatch( + /no Docker Desktop socket was found/, + ); }); it("stages the policy preset no answer after sandbox setup", () => { From 70b58da66f64fe043618f5362d76b82f7ed88976 Mon Sep 17 00:00:00 2001 From: Dhaval Dave Date: Tue, 24 Mar 2026 23:08:33 +0530 Subject: [PATCH 5/7] chore: add SPDX license header to .shellcheckrc Made-with: Cursor --- .shellcheckrc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.shellcheckrc b/.shellcheckrc index e4772505c..006348446 100644 --- a/.shellcheckrc +++ b/.shellcheckrc @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + # SC2059: Don't use variables in the printf format string. # Our info/warn/ok/error helpers and banner intentionally embed ANSI color # variables in printf format strings. This is safe because the variables From 461232fcdae0ac7111c9760aa26b4b1fab5b75a8 Mon Sep 17 00:00:00 2001 From: Dhaval Dave Date: Tue, 24 Mar 2026 23:37:59 +0530 Subject: [PATCH 6/7] Revert "style: format JS/TS files with Prettier" This reverts commit ac1989d9bb88d3f270c371acc34aee314133c670. --- bin/lib/onboard.js | 1333 ++++++++++-------------------- bin/lib/platform.js | 11 +- test/platform.test.js | 110 +-- test/runtime-shell.test.js | 77 +- test/smoke-macos-install.test.js | 61 +- 5 files changed, 528 insertions(+), 1064 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 438791557..4bd5c2866 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -25,14 +25,12 @@ const { DEFAULT_CLOUD_MODEL, getProviderSelectionConfig, } = require("./inference-config"); -const { inferContainerRuntime, shouldPatchCoredns } = require("./platform"); -const { resolveOpenshell } = require("./resolve-openshell"); const { - prompt, - ensureApiKey, - getCredential, - saveCredential, -} = require("./credentials"); + inferContainerRuntime, + shouldPatchCoredns, +} = require("./platform"); +const { resolveOpenshell } = require("./resolve-openshell"); +const { prompt, ensureApiKey, getCredential, saveCredential } = require("./credentials"); const registry = require("./registry"); const nim = require("./nim"); const policies = require("./policies"); @@ -47,8 +45,7 @@ const GATEWAY_NAME = "nemoclaw"; const BUILD_ENDPOINT_URL = "https://integrate.api.nvidia.com/v1"; const OPENAI_ENDPOINT_URL = "https://api.openai.com/v1"; const ANTHROPIC_ENDPOINT_URL = "https://api.anthropic.com"; -const GEMINI_ENDPOINT_URL = - "https://generativelanguage.googleapis.com/v1beta/openai/"; +const GEMINI_ENDPOINT_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"; const REMOTE_PROVIDER_CONFIG = { build: { @@ -117,8 +114,17 @@ const REMOTE_PROVIDER_CONFIG = { }; const REMOTE_MODEL_OPTIONS = { - openai: ["gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano", "gpt-5.4-pro-2026-03-05"], - anthropic: ["claude-sonnet-4-6", "claude-haiku-4-5", "claude-opus-4-6"], + openai: [ + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.4-pro-2026-03-05", + ], + anthropic: [ + "claude-sonnet-4-6", + "claude-haiku-4-5", + "claude-opus-4-6", + ], gemini: [ "gemini-3.1-pro-preview", "gemini-3.1-flash-lite-preview", @@ -163,11 +169,7 @@ function isSandboxReady(output, sandboxName) { const clean = output.replace(/\x1b\[[0-9;]*m/g, ""); return clean.split("\n").some((l) => { const cols = l.trim().split(/\s+/); - return ( - cols[0] === sandboxName && - cols.includes("Ready") && - !cols.includes("NotReady") - ); + return cols[0] === sandboxName && cols.includes("Ready") && !cols.includes("NotReady"); }); } @@ -178,11 +180,7 @@ function isSandboxReady(output, sandboxName) { * @returns {boolean} */ function hasStaleGateway(gwInfoOutput) { - return ( - typeof gwInfoOutput === "string" && - gwInfoOutput.length > 0 && - gwInfoOutput.includes(GATEWAY_NAME) - ); + return typeof gwInfoOutput === "string" && gwInfoOutput.length > 0 && gwInfoOutput.includes(GATEWAY_NAME); } function streamSandboxCreate(command, env = process.env) { @@ -240,10 +238,9 @@ function streamSandboxCreate(command, env = process.env) { if (settled) return; settled = true; if (pending) flushLine(pending); - const detail = - error && error.code - ? `spawn failed: ${error.message} (${error.code})` - : `spawn failed: ${error.message}`; + const detail = error && error.code + ? `spawn failed: ${error.message} (${error.code})` + : `spawn failed: ${error.message}`; lines.push(detail); resolve({ status: 1, output: lines.join("\n"), sawProgress: false }); }); @@ -264,9 +261,7 @@ function step(n, total, msg) { } function getInstalledOpenshellVersion(versionOutput = null) { - const output = String( - versionOutput ?? runCapture("openshell -V", { ignoreError: true }), - ).trim(); + const output = String(versionOutput ?? runCapture("openshell -V", { ignoreError: true })).trim(); const match = output.match(/openshell\s+([0-9]+\.[0-9]+\.[0-9]+)/i); if (!match) return null; return match[1]; @@ -283,9 +278,7 @@ function getOpenshellBinary() { const resolved = resolveOpenshell(); if (!resolved) { console.error(" openshell CLI not found."); - console.error( - " Install manually: https://github.com/NVIDIA/OpenShell/releases", - ); + console.error(" Install manually: https://github.com/NVIDIA/OpenShell/releases"); process.exit(1); } OPENSHELL_BIN = resolved; @@ -293,10 +286,7 @@ function getOpenshellBinary() { } function openshellShellCommand(args) { - return [ - shellQuote(getOpenshellBinary()), - ...args.map((arg) => shellQuote(arg)), - ].join(" "); + return [shellQuote(getOpenshellBinary()), ...args.map((arg) => shellQuote(arg))].join(" "); } function runOpenshell(args, opts = {}) { @@ -318,16 +308,7 @@ function getCurlTimingArgs() { function buildProviderArgs(action, name, type, credentialEnv, baseUrl) { const args = action === "create" - ? [ - "provider", - "create", - "--name", - name, - "--type", - type, - "--credential", - credentialEnv, - ] + ? ["provider", "create", "--name", name, "--type", type, "--credential", credentialEnv] : ["provider", "update", name, "--credential", credentialEnv]; if (baseUrl && type === "openai") { args.push("--config", `OPENAI_BASE_URL=${baseUrl}`); @@ -338,23 +319,11 @@ function buildProviderArgs(action, name, type, credentialEnv, baseUrl) { } function upsertProvider(name, type, credentialEnv, baseUrl, env = {}) { - const createArgs = buildProviderArgs( - "create", - name, - type, - credentialEnv, - baseUrl, - ); + const createArgs = buildProviderArgs("create", name, type, credentialEnv, baseUrl); const createResult = runOpenshell(createArgs, { ignoreError: true, env }); if (createResult.status === 0) return; - const updateArgs = buildProviderArgs( - "update", - name, - type, - credentialEnv, - baseUrl, - ); + const updateArgs = buildProviderArgs("update", name, type, credentialEnv, baseUrl); const updateResult = runOpenshell(updateArgs, { ignoreError: true, env }); if (updateResult.status !== 0) { console.error(` Failed to create or update provider '${name}'.`); @@ -363,22 +332,15 @@ function upsertProvider(name, type, credentialEnv, baseUrl, env = {}) { } function verifyInferenceRoute(provider, model) { - const output = runCaptureOpenshell(["inference", "get"], { - ignoreError: true, - }); - if ( - !output || - /Gateway inference:\s*[\r\n]+\s*Not configured/i.test(output) - ) { + const output = runCaptureOpenshell(["inference", "get"], { ignoreError: true }); + if (!output || /Gateway inference:\s*[\r\n]+\s*Not configured/i.test(output)) { console.error(" OpenShell inference route was not configured."); process.exit(1); } } function sandboxExistsInGateway(sandboxName) { - const output = runCaptureOpenshell(["sandbox", "get", sandboxName], { - ignoreError: true, - }); + const output = runCaptureOpenshell(["sandbox", "get", sandboxName], { ignoreError: true }); return Boolean(output); } @@ -410,11 +372,7 @@ exit `.trim(); } -function writeSandboxConfigSyncFile( - script, - tmpDir = os.tmpdir(), - now = Date.now(), -) { +function writeSandboxConfigSyncFile(script, tmpDir = os.tmpdir(), now = Date.now()) { const scriptFile = path.join(tmpDir, `nemoclaw-sync-${now}.sh`); fs.writeFileSync(scriptFile, `${script}\n`, { mode: 0o600 }); return scriptFile; @@ -424,11 +382,7 @@ function encodeDockerJsonArg(value) { return Buffer.from(JSON.stringify(value || {}), "utf8").toString("base64"); } -function getSandboxInferenceConfig( - model, - provider = null, - preferredInferenceApi = null, -) { +function getSandboxInferenceConfig(model, provider = null, preferredInferenceApi = null) { let providerKey = "inference"; let primaryModelRef = model; let inferenceBaseUrl = "https://inference.local/v1"; @@ -469,23 +423,10 @@ function getSandboxInferenceConfig( break; } - return { - providerKey, - primaryModelRef, - inferenceBaseUrl, - inferenceApi, - inferenceCompat, - }; + return { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat }; } -function patchStagedDockerfile( - dockerfilePath, - model, - chatUiUrl, - buildId = String(Date.now()), - provider = null, - preferredInferenceApi = null, -) { +function patchStagedDockerfile(dockerfilePath, model, chatUiUrl, buildId = String(Date.now()), provider = null, preferredInferenceApi = null) { const { providerKey, primaryModelRef, @@ -496,35 +437,35 @@ function patchStagedDockerfile( let dockerfile = fs.readFileSync(dockerfilePath, "utf8"); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_MODEL=.*$/m, - `ARG NEMOCLAW_MODEL=${model}`, + `ARG NEMOCLAW_MODEL=${model}` ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_PROVIDER_KEY=.*$/m, - `ARG NEMOCLAW_PROVIDER_KEY=${providerKey}`, + `ARG NEMOCLAW_PROVIDER_KEY=${providerKey}` ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_PRIMARY_MODEL_REF=.*$/m, - `ARG NEMOCLAW_PRIMARY_MODEL_REF=${primaryModelRef}`, + `ARG NEMOCLAW_PRIMARY_MODEL_REF=${primaryModelRef}` ); dockerfile = dockerfile.replace( /^ARG CHAT_UI_URL=.*$/m, - `ARG CHAT_UI_URL=${chatUiUrl}`, + `ARG CHAT_UI_URL=${chatUiUrl}` ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_INFERENCE_BASE_URL=.*$/m, - `ARG NEMOCLAW_INFERENCE_BASE_URL=${inferenceBaseUrl}`, + `ARG NEMOCLAW_INFERENCE_BASE_URL=${inferenceBaseUrl}` ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_INFERENCE_API=.*$/m, - `ARG NEMOCLAW_INFERENCE_API=${inferenceApi}`, + `ARG NEMOCLAW_INFERENCE_API=${inferenceApi}` ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_INFERENCE_COMPAT_B64=.*$/m, - `ARG NEMOCLAW_INFERENCE_COMPAT_B64=${encodeDockerJsonArg(inferenceCompat)}`, + `ARG NEMOCLAW_INFERENCE_COMPAT_B64=${encodeDockerJsonArg(inferenceCompat)}` ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_BUILD_ID=.*$/m, - `ARG NEMOCLAW_BUILD_ID=${buildId}`, + `ARG NEMOCLAW_BUILD_ID=${buildId}` ); fs.writeFileSync(dockerfilePath, dockerfile); } @@ -562,17 +503,16 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey) { url: `${String(endpointUrl).replace(/\/+$/, "")}/chat/completions`, body: JSON.stringify({ model, - messages: [{ role: "user", content: "Reply with exactly: OK" }], + messages: [ + { role: "user", content: "Reply with exactly: OK" }, + ], }), }, ]; const failures = []; for (const probe of probes) { - const bodyFile = path.join( - os.tmpdir(), - `nemoclaw-probe-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, - ); + const bodyFile = path.join(os.tmpdir(), `nemoclaw-probe-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); try { const cmd = [ "curl -sS", @@ -580,9 +520,7 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey) { `-o ${shellQuote(bodyFile)}`, "-w '%{http_code}'", "-H 'Content-Type: application/json'", - ...(apiKey - ? ['-H "Authorization: Bearer $NEMOCLAW_PROBE_API_KEY"'] - : []), + ...(apiKey ? ['-H "Authorization: Bearer $NEMOCLAW_PROBE_API_KEY"'] : []), `-d ${shellQuote(probe.body)}`, shellQuote(probe.url), ].join(" "); @@ -594,9 +532,7 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey) { NEMOCLAW_PROBE_API_KEY: apiKey, }, }); - const body = fs.existsSync(bodyFile) - ? fs.readFileSync(bodyFile, "utf8") - : ""; + const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; const status = Number(String(result.stdout || "").trim()); if (result.status === 0 && status >= 200 && status < 300) { return { ok: true, api: probe.api, label: probe.name }; @@ -614,18 +550,13 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey) { return { ok: false, - message: failures - .map((failure) => `${failure.name}: ${failure.message}`) - .join(" | "), + message: failures.map((failure) => `${failure.name}: ${failure.message}`).join(" | "), failures, }; } function probeAnthropicEndpoint(endpointUrl, model, apiKey) { - const bodyFile = path.join( - os.tmpdir(), - `nemoclaw-anthropic-probe-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, - ); + const bodyFile = path.join(os.tmpdir(), `nemoclaw-anthropic-probe-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); try { const cmd = [ "curl -sS", @@ -635,13 +566,11 @@ function probeAnthropicEndpoint(endpointUrl, model, apiKey) { '-H "x-api-key: $NEMOCLAW_PROBE_API_KEY"', "-H 'anthropic-version: 2023-06-01'", "-H 'content-type: application/json'", - `-d ${shellQuote( - JSON.stringify({ - model, - max_tokens: 16, - messages: [{ role: "user", content: "Reply with exactly: OK" }], - }), - )}`, + `-d ${shellQuote(JSON.stringify({ + model, + max_tokens: 16, + messages: [{ role: "user", content: "Reply with exactly: OK" }], + }))}`, shellQuote(`${String(endpointUrl).replace(/\/+$/, "")}/v1/messages`), ].join(" "); const result = spawnSync("bash", ["-c", cmd], { @@ -652,16 +581,10 @@ function probeAnthropicEndpoint(endpointUrl, model, apiKey) { NEMOCLAW_PROBE_API_KEY: apiKey, }, }); - const body = fs.existsSync(bodyFile) - ? fs.readFileSync(bodyFile, "utf8") - : ""; + const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; const status = Number(String(result.stdout || "").trim()); if (result.status === 0 && status >= 200 && status < 300) { - return { - ok: true, - api: "anthropic-messages", - label: "Anthropic Messages API", - }; + return { ok: true, api: "anthropic-messages", label: "Anthropic Messages API" }; } return { ok: false, @@ -693,7 +616,7 @@ async function validateOpenAiLikeSelection( endpointUrl, model, credentialEnv = null, - retryMessage = "Please choose a provider/model again.", + retryMessage = "Please choose a provider/model again." ) { const apiKey = credentialEnv ? getCredential(credentialEnv) : ""; const probe = probeOpenAiLikeEndpoint(endpointUrl, model, apiKey); @@ -711,12 +634,7 @@ async function validateOpenAiLikeSelection( return probe.api; } -async function validateAnthropicSelection( - label, - endpointUrl, - model, - credentialEnv, -) { +async function validateAnthropicSelection(label, endpointUrl, model, credentialEnv) { const apiKey = getCredential(credentialEnv); const probe = probeAnthropicEndpoint(endpointUrl, model, apiKey); if (!probe.ok) { @@ -738,7 +656,7 @@ async function validateAnthropicSelectionWithRetryMessage( endpointUrl, model, credentialEnv, - retryMessage = "Please choose a provider/model again.", + retryMessage = "Please choose a provider/model again." ) { const apiKey = getCredential(credentialEnv); const probe = probeAnthropicEndpoint(endpointUrl, model, apiKey); @@ -756,12 +674,7 @@ async function validateAnthropicSelectionWithRetryMessage( return probe.api; } -async function validateCustomOpenAiLikeSelection( - label, - endpointUrl, - model, - credentialEnv, -) { +async function validateCustomOpenAiLikeSelection(label, endpointUrl, model, credentialEnv) { const apiKey = getCredential(credentialEnv); const probe = probeOpenAiLikeEndpoint(endpointUrl, model, apiKey); if (probe.ok) { @@ -783,12 +696,7 @@ async function validateCustomOpenAiLikeSelection( return { ok: false, retry: "model" }; } -async function validateCustomAnthropicSelection( - label, - endpointUrl, - model, - credentialEnv, -) { +async function validateCustomAnthropicSelection(label, endpointUrl, model, credentialEnv) { const apiKey = getCredential(credentialEnv); const probe = probeAnthropicEndpoint(endpointUrl, model, apiKey); if (probe.ok) { @@ -811,10 +719,7 @@ async function validateCustomAnthropicSelection( } function fetchNvidiaEndpointModels(apiKey) { - const bodyFile = path.join( - os.tmpdir(), - `nemoclaw-nvidia-models-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, - ); + const bodyFile = path.join(os.tmpdir(), `nemoclaw-nvidia-models-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); try { const cmd = [ "curl -sS", @@ -833,15 +738,10 @@ function fetchNvidiaEndpointModels(apiKey) { NEMOCLAW_PROBE_API_KEY: apiKey, }, }); - const body = fs.existsSync(bodyFile) - ? fs.readFileSync(bodyFile, "utf8") - : ""; + const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; const status = Number(String(result.stdout || "").trim()); if (result.status !== 0 || !(status >= 200 && status < 300)) { - return { - ok: false, - message: summarizeProbeError(body, status || result.status || 0), - }; + return { ok: false, message: summarizeProbeError(body, status || result.status || 0) }; } const parsed = JSON.parse(body); const ids = Array.isArray(parsed?.data) @@ -873,10 +773,7 @@ function validateNvidiaEndpointModel(model, apiKey) { } function fetchOpenAiLikeModels(endpointUrl, apiKey) { - const bodyFile = path.join( - os.tmpdir(), - `nemoclaw-openai-models-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, - ); + const bodyFile = path.join(os.tmpdir(), `nemoclaw-openai-models-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); try { const cmd = [ "curl -sS", @@ -894,16 +791,10 @@ function fetchOpenAiLikeModels(endpointUrl, apiKey) { NEMOCLAW_PROBE_API_KEY: apiKey, }, }); - const body = fs.existsSync(bodyFile) - ? fs.readFileSync(bodyFile, "utf8") - : ""; + const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; const status = Number(String(result.stdout || "").trim()); if (result.status !== 0 || !(status >= 200 && status < 300)) { - return { - ok: false, - status, - message: summarizeProbeError(body, status || result.status || 0), - }; + return { ok: false, status, message: summarizeProbeError(body, status || result.status || 0) }; } const parsed = JSON.parse(body); const ids = Array.isArray(parsed?.data) @@ -918,10 +809,7 @@ function fetchOpenAiLikeModels(endpointUrl, apiKey) { } function fetchAnthropicModels(endpointUrl, apiKey) { - const bodyFile = path.join( - os.tmpdir(), - `nemoclaw-anthropic-models-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, - ); + const bodyFile = path.join(os.tmpdir(), `nemoclaw-anthropic-models-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); try { const cmd = [ "curl -sS", @@ -940,22 +828,14 @@ function fetchAnthropicModels(endpointUrl, apiKey) { NEMOCLAW_PROBE_API_KEY: apiKey, }, }); - const body = fs.existsSync(bodyFile) - ? fs.readFileSync(bodyFile, "utf8") - : ""; + const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; const status = Number(String(result.stdout || "").trim()); if (result.status !== 0 || !(status >= 200 && status < 300)) { - return { - ok: false, - status, - message: summarizeProbeError(body, status || result.status || 0), - }; + return { ok: false, status, message: summarizeProbeError(body, status || result.status || 0) }; } const parsed = JSON.parse(body); const ids = Array.isArray(parsed?.data) - ? parsed.data - .map((item) => item && (item.id || item.name)) - .filter(Boolean) + ? parsed.data.map((item) => item && (item.id || item.name)).filter(Boolean) : []; return { ok: true, ids }; } catch (error) { @@ -1042,17 +922,11 @@ async function promptCloudModel() { return promptManualModelId( " NVIDIA Endpoints model id: ", "NVIDIA Endpoints", - (model) => - validateNvidiaEndpointModel(model, getCredential("NVIDIA_API_KEY")), + (model) => validateNvidiaEndpointModel(model, getCredential("NVIDIA_API_KEY")) ); } -async function promptRemoteModel( - label, - providerKey, - defaultModel, - validator = null, -) { +async function promptRemoteModel(label, providerKey, defaultModel, validator = null) { const options = REMOTE_MODEL_OPTIONS[providerKey] || []; const defaultIndex = Math.max(0, options.indexOf(defaultModel)); @@ -1094,24 +968,19 @@ async function promptInputModel(label, defaultModel, validator = null) { async function promptOllamaModel(gpu = null) { const installed = getOllamaModelOptions(runCapture); - const options = - installed.length > 0 ? installed : getBootstrapOllamaModelOptions(gpu); + const options = installed.length > 0 ? installed : getBootstrapOllamaModelOptions(gpu); const defaultModel = getDefaultOllamaModel(runCapture, gpu); const defaultIndex = Math.max(0, options.indexOf(defaultModel)); console.log(""); - console.log( - installed.length > 0 ? " Ollama models:" : " Ollama starter models:", - ); + console.log(installed.length > 0 ? " Ollama models:" : " Ollama starter models:"); options.forEach((option, index) => { console.log(` ${index + 1}) ${option}`); }); console.log(` ${options.length + 1}) Other...`); if (installed.length === 0) { console.log(""); - console.log( - " No local Ollama models are installed yet. Choose one to pull and load now.", - ); + console.log(" No local Ollama models are installed yet. Choose one to pull and load now."); } console.log(""); @@ -1178,16 +1047,12 @@ function getFutureShellPathHint(binDir, pathValue = process.env.PATH || "") { } function installOpenshell() { - const result = spawnSync( - "bash", - [path.join(SCRIPTS, "install-openshell.sh")], - { - cwd: ROOT, - env: process.env, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }, - ); + const result = spawnSync("bash", [path.join(SCRIPTS, "install-openshell.sh")], { + cwd: ROOT, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }); if (result.status !== 0) { const output = `${result.stdout || ""}${result.stderr || ""}`.trim(); if (output) { @@ -1195,9 +1060,7 @@ function installOpenshell() { } return { installed: false, localBin: null, futureShellPathHint: null }; } - const localBin = - process.env.XDG_BIN_HOME || - path.join(process.env.HOME || "", ".local", "bin"); + const localBin = process.env.XDG_BIN_HOME || path.join(process.env.HOME || "", ".local", "bin"); const openshellPath = path.join(localBin, "openshell"); const futureShellPathHint = fs.existsSync(openshellPath) ? getFutureShellPathHint(localBin, process.env.PATH) @@ -1251,9 +1114,7 @@ async function ensureNamedCredential(envName, label, helpUrl = null) { function waitForSandboxReady(sandboxName, attempts = 10, delaySeconds = 2) { for (let i = 0; i < attempts; i += 1) { - const exists = runCaptureOpenshell(["sandbox", "get", sandboxName], { - ignoreError: true, - }); + const exists = runCaptureOpenshell(["sandbox", "get", sandboxName], { ignoreError: true }); if (exists) return true; sleep(delaySeconds); } @@ -1272,9 +1133,7 @@ function isSafeModelId(value) { } function getNonInteractiveProvider() { - const providerKey = (process.env.NEMOCLAW_PROVIDER || "") - .trim() - .toLowerCase(); + const providerKey = (process.env.NEMOCLAW_PROVIDER || "").trim().toLowerCase(); if (!providerKey) return null; const aliases = { cloud: "build", @@ -1283,22 +1142,10 @@ function getNonInteractiveProvider() { anthropiccompatible: "anthropicCompatible", }; const normalized = aliases[providerKey] || providerKey; - const validProviders = new Set([ - "build", - "openai", - "anthropic", - "anthropicCompatible", - "gemini", - "ollama", - "custom", - "nim-local", - "vllm", - ]); + const validProviders = new Set(["build", "openai", "anthropic", "anthropicCompatible", "gemini", "ollama", "custom", "nim-local", "vllm"]); if (!validProviders.has(normalized)) { console.error(` Unsupported NEMOCLAW_PROVIDER: ${providerKey}`); - console.error( - " Valid values: build, openai, anthropic, anthropicCompatible, gemini, ollama, custom, nim-local, vllm", - ); + console.error(" Valid values: build, openai, anthropic, anthropicCompatible, gemini, ollama, custom, nim-local, vllm"); process.exit(1); } @@ -1309,12 +1156,8 @@ function getNonInteractiveModel(providerKey) { const model = (process.env.NEMOCLAW_MODEL || "").trim(); if (!model) return null; if (!isSafeModelId(model)) { - console.error( - ` Invalid NEMOCLAW_MODEL for provider '${providerKey}': ${model}`, - ); - console.error( - " Model values may only contain letters, numbers, '.', '_', ':', '/', and '-'.", - ); + console.error(` Invalid NEMOCLAW_MODEL for provider '${providerKey}': ${model}`); + console.error(" Model values may only contain letters, numbers, '.', '_', ':', '/', and '-'."); process.exit(1); } return model; @@ -1327,9 +1170,7 @@ async function preflight() { // Docker if (!isDockerRunning()) { - console.error( - " Docker is not running. Please start Docker and try again.", - ); + console.error(" Docker is not running. Please start Docker and try again."); process.exit(1); } console.log(" ✓ Docker is running"); @@ -1346,40 +1187,26 @@ async function preflight() { openshellInstall = installOpenshell(); if (!openshellInstall.installed) { console.error(" Failed to install openshell CLI."); - console.error( - " Install manually: https://github.com/NVIDIA/OpenShell/releases", - ); + console.error(" Install manually: https://github.com/NVIDIA/OpenShell/releases"); process.exit(1); } } - console.log( - ` ✓ openshell CLI: ${runCaptureOpenshell(["--version"], { ignoreError: true }) || "unknown"}`, - ); + console.log(` ✓ openshell CLI: ${runCaptureOpenshell(["--version"], { ignoreError: true }) || "unknown"}`); if (openshellInstall.futureShellPathHint) { - console.log( - ` Note: openshell was installed to ${openshellInstall.localBin} for this onboarding run.`, - ); - console.log( - ` Future shells may still need: ${openshellInstall.futureShellPathHint}`, - ); - console.log( - " Add that export to your shell profile, or open a new terminal before running openshell directly.", - ); + console.log(` Note: openshell was installed to ${openshellInstall.localBin} for this onboarding run.`); + console.log(` Future shells may still need: ${openshellInstall.futureShellPathHint}`); + console.log(" Add that export to your shell profile, or open a new terminal before running openshell directly."); } // Clean up stale NemoClaw session before checking ports. // A previous onboard run may have left the gateway container and port // forward running. If a NemoClaw-owned gateway is still present, tear // it down so the port check below doesn't fail on our own leftovers. - const gwInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { - ignoreError: true, - }); + const gwInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { ignoreError: true }); if (hasStaleGateway(gwInfo)) { console.log(" Cleaning up previous NemoClaw session..."); runOpenshell(["forward", "stop", "18789"], { ignoreError: true }); - runOpenshell(["gateway", "destroy", "-g", GATEWAY_NAME], { - ignoreError: true, - }); + runOpenshell(["gateway", "destroy", "-g", GATEWAY_NAME], { ignoreError: true }); console.log(" ✓ Previous session cleaned up"); } @@ -1396,9 +1223,7 @@ async function preflight() { console.error(` ${label} needs this port.`); console.error(""); if (portCheck.process && portCheck.process !== "unknown") { - console.error( - ` Blocked by: ${portCheck.process}${portCheck.pid ? ` (PID ${portCheck.pid})` : ""}`, - ); + console.error(` Blocked by: ${portCheck.process}${portCheck.pid ? ` (PID ${portCheck.pid})` : ""}`); console.error(""); console.error(" To fix, stop the conflicting process:"); console.error(""); @@ -1410,9 +1235,7 @@ async function preflight() { console.error(" # or, if it's a systemd service:"); console.error(" systemctl --user stop openclaw-gateway.service"); } else { - console.error( - ` Could not identify the process using port ${port}.`, - ); + console.error(` Could not identify the process using port ${port}.`); console.error(` Run: lsof -i :${port} -sTCP:LISTEN`); } console.error(""); @@ -1425,13 +1248,9 @@ async function preflight() { // GPU const gpu = nim.detectGpu(); if (gpu && gpu.type === "nvidia") { - console.log( - ` ✓ NVIDIA GPU detected: ${gpu.count} GPU(s), ${gpu.totalMemoryMB} MB VRAM`, - ); + console.log(` ✓ NVIDIA GPU detected: ${gpu.count} GPU(s), ${gpu.totalMemoryMB} MB VRAM`); } else if (gpu && gpu.type === "apple") { - console.log( - ` ✓ Apple GPU detected: ${gpu.name}${gpu.cores ? ` (${gpu.cores} cores)` : ""}, ${gpu.totalMemoryMB} MB unified memory`, - ); + console.log(` ✓ Apple GPU detected: ${gpu.name}${gpu.cores ? ` (${gpu.cores} cores)` : ""}, ${gpu.totalMemoryMB} MB unified memory`); console.log(" ⓘ NIM requires NVIDIA GPU — will use cloud inference"); } else { console.log(" ⓘ No GPU detected — will use cloud inference"); @@ -1446,9 +1265,7 @@ async function startGateway(gpu) { step(3, 7, "Starting OpenShell gateway"); // Destroy old gateway - runOpenshell(["gateway", "destroy", "-g", GATEWAY_NAME], { - ignoreError: true, - }); + runOpenshell(["gateway", "destroy", "-g", GATEWAY_NAME], { ignoreError: true }); const gwArgs = ["--name", GATEWAY_NAME]; // Do NOT pass --gpu here. On DGX Spark (and most GPU hosts), inference is @@ -1464,15 +1281,10 @@ async function startGateway(gpu) { if (stableGatewayImage && openshellVersion) { gatewayEnv.OPENSHELL_CLUSTER_IMAGE = stableGatewayImage; gatewayEnv.IMAGE_TAG = openshellVersion; - console.log( - ` Using pinned OpenShell gateway image: ${stableGatewayImage}`, - ); + console.log(` Using pinned OpenShell gateway image: ${stableGatewayImage}`); } - runOpenshell(["gateway", "start", ...gwArgs], { - ignoreError: false, - env: gatewayEnv, - }); + runOpenshell(["gateway", "start", ...gwArgs], { ignoreError: false, env: gatewayEnv }); // Verify health for (let i = 0; i < 5; i++) { @@ -1491,10 +1303,7 @@ async function startGateway(gpu) { const runtime = getContainerRuntime(); if (shouldPatchCoredns(runtime)) { console.log(` Patching CoreDNS for ${runtime}...`); - run( - `bash "${path.join(SCRIPTS, "fix-coredns.sh")}" ${GATEWAY_NAME} 2>&1 || true`, - { ignoreError: true }, - ); + run(`bash "${path.join(SCRIPTS, "fix-coredns.sh")}" ${GATEWAY_NAME} 2>&1 || true`, { ignoreError: true }); } // Give DNS a moment to propagate sleep(5); @@ -1504,18 +1313,12 @@ async function startGateway(gpu) { // ── Step 3: Sandbox ────────────────────────────────────────────── -async function createSandbox( - gpu, - model, - provider, - preferredInferenceApi = null, -) { +async function createSandbox(gpu, model, provider, preferredInferenceApi = null) { step(5, 7, "Creating sandbox"); const nameAnswer = await promptOrDefault( " Sandbox name (lowercase, numbers, hyphens) [my-assistant]: ", - "NEMOCLAW_SANDBOX_NAME", - "my-assistant", + "NEMOCLAW_SANDBOX_NAME", "my-assistant" ); const sandboxName = (nameAnswer || "my-assistant").trim().toLowerCase(); @@ -1523,9 +1326,7 @@ async function createSandbox( // must start and end with alphanumeric (required by Kubernetes/OpenShell) if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName)) { console.error(` Invalid sandbox name: '${sandboxName}'`); - console.error( - " Names must be lowercase, contain only letters, numbers, and hyphens,", - ); + console.error(" Names must be lowercase, contain only letters, numbers, and hyphens,"); console.error(" and must start and end with a letter or number."); process.exit(1); } @@ -1537,16 +1338,12 @@ async function createSandbox( if (isNonInteractive()) { if (process.env.NEMOCLAW_RECREATE_SANDBOX !== "1") { console.error(` Sandbox '${sandboxName}' already exists.`); - console.error( - " Set NEMOCLAW_RECREATE_SANDBOX=1 to recreate it in non-interactive mode.", - ); + console.error(" Set NEMOCLAW_RECREATE_SANDBOX=1 to recreate it in non-interactive mode."); process.exit(1); } note(` [non-interactive] Sandbox '${sandboxName}' exists — recreating`); } else { - const recreate = await prompt( - ` Sandbox '${sandboxName}' already exists. Recreate? [y/N]: `, - ); + const recreate = await prompt(` Sandbox '${sandboxName}' already exists. Recreate? [y/N]: `); if (recreate.toLowerCase() !== "y") { console.log(" Keeping existing sandbox."); return sandboxName; @@ -1564,54 +1361,33 @@ async function createSandbox( const stagedDockerfile = path.join(buildCtx, "Dockerfile"); fs.copyFileSync(path.join(ROOT, "Dockerfile"), stagedDockerfile); run(`cp -r "${path.join(ROOT, "nemoclaw")}" "${buildCtx}/nemoclaw"`); - run( - `cp -r "${path.join(ROOT, "nemoclaw-blueprint")}" "${buildCtx}/nemoclaw-blueprint"`, - ); + run(`cp -r "${path.join(ROOT, "nemoclaw-blueprint")}" "${buildCtx}/nemoclaw-blueprint"`); run(`cp -r "${path.join(ROOT, "scripts")}" "${buildCtx}/scripts"`); run(`rm -rf "${buildCtx}/nemoclaw/node_modules"`, { ignoreError: true }); // Create sandbox (use -- echo to avoid dropping into interactive shell) // Pass the base policy so sandbox starts in proxy mode (required for policy updates later) - const basePolicyPath = path.join( - ROOT, - "nemoclaw-blueprint", - "policies", - "openclaw-sandbox.yaml", - ); + const basePolicyPath = path.join(ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"); const createArgs = [ - "--from", - `${buildCtx}/Dockerfile`, - "--name", - sandboxName, - "--policy", - basePolicyPath, + "--from", `${buildCtx}/Dockerfile`, + "--name", sandboxName, + "--policy", basePolicyPath, ]; // --gpu is intentionally omitted. See comment in startGateway(). - console.log( - ` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`, - ); + console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); const chatUiUrl = process.env.CHAT_UI_URL || "http://127.0.0.1:18789"; - patchStagedDockerfile( - stagedDockerfile, - model, - chatUiUrl, - String(Date.now()), - provider, - preferredInferenceApi, - ); + patchStagedDockerfile(stagedDockerfile, model, chatUiUrl, String(Date.now()), provider, preferredInferenceApi); const envArgs = [formatEnvAssignment("CHAT_UI_URL", chatUiUrl)]; const sandboxEnv = { ...process.env }; if (process.env.NVIDIA_API_KEY) { sandboxEnv.NVIDIA_API_KEY = process.env.NVIDIA_API_KEY; } - const discordToken = - getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN; + const discordToken = getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN; if (discordToken) { sandboxEnv.DISCORD_BOT_TOKEN = discordToken; } - const slackToken = - getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN; + const slackToken = getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN; if (slackToken) { sandboxEnv.SLACK_BOT_TOKEN = slackToken; } @@ -1641,9 +1417,7 @@ async function createSandbox( console.error(""); console.error(createResult.output); } - console.error( - " Try: openshell sandbox list # check gateway state", - ); + console.error(" Try: openshell sandbox list # check gateway state"); console.error(" Try: nemoclaw onboard # retry from scratch"); process.exit(createResult.status || 1); } @@ -1655,9 +1429,7 @@ async function createSandbox( console.log(" Waiting for sandbox to become ready..."); let ready = false; for (let i = 0; i < 30; i++) { - const list = runCaptureOpenshell(["sandbox", "list"], { - ignoreError: true, - }); + const list = runCaptureOpenshell(["sandbox", "list"], { ignoreError: true }); if (isSandboxReady(list, sandboxName)) { ready = true; break; @@ -1668,17 +1440,11 @@ async function createSandbox( if (!ready) { // Clean up the orphaned sandbox so the next onboard retry with the same // name doesn't fail on "sandbox already exists". - const delResult = runOpenshell(["sandbox", "delete", sandboxName], { - ignoreError: true, - }); + const delResult = runOpenshell(["sandbox", "delete", sandboxName], { ignoreError: true }); console.error(""); - console.error( - ` Sandbox '${sandboxName}' was created but did not become ready within 60s.`, - ); + console.error(` Sandbox '${sandboxName}' was created but did not become ready within 60s.`); if (delResult.status === 0) { - console.error( - " The orphaned sandbox has been removed — you can safely retry.", - ); + console.error(" The orphaned sandbox has been removed — you can safely retry."); } else { console.error(` Could not remove the orphaned sandbox. Manual cleanup:`); console.error(` openshell sandbox delete "${sandboxName}"`); @@ -1692,9 +1458,7 @@ async function createSandbox( // which would silently prevent the new sandbox's dashboard from being reachable. runOpenshell(["forward", "stop", "18789"], { ignoreError: true }); // Forward dashboard port to the new sandbox - runOpenshell(["forward", "start", "--background", "18789", sandboxName], { - ignoreError: true, - }); + runOpenshell(["forward", "start", "--background", "18789", sandboxName], { ignoreError: true }); // Register only after confirmed ready — prevents phantom entries registry.registerSandbox({ @@ -1720,36 +1484,21 @@ async function setupNim(gpu) { // Detect local inference options const hasOllama = !!runCapture("command -v ollama", { ignoreError: true }); - const ollamaRunning = !!runCapture( - "curl -sf http://localhost:11434/api/tags 2>/dev/null", - { ignoreError: true }, - ); - const vllmRunning = !!runCapture( - "curl -sf http://localhost:8000/v1/models 2>/dev/null", - { ignoreError: true }, - ); - const requestedProvider = isNonInteractive() - ? getNonInteractiveProvider() - : null; - const requestedModel = isNonInteractive() - ? getNonInteractiveModel(requestedProvider || "build") - : null; + const ollamaRunning = !!runCapture("curl -sf http://localhost:11434/api/tags 2>/dev/null", { ignoreError: true }); + const vllmRunning = !!runCapture("curl -sf http://localhost:8000/v1/models 2>/dev/null", { ignoreError: true }); + const requestedProvider = isNonInteractive() ? getNonInteractiveProvider() : null; + const requestedModel = isNonInteractive() ? getNonInteractiveModel(requestedProvider || "build") : null; const options = []; options.push({ key: "build", label: "NVIDIA Endpoints" + - (!ollamaRunning && !(EXPERIMENTAL && vllmRunning) - ? " (recommended)" - : ""), + (!ollamaRunning && !(EXPERIMENTAL && vllmRunning) ? " (recommended)" : ""), }); options.push({ key: "openai", label: "OpenAI" }); options.push({ key: "custom", label: "Other OpenAI-compatible endpoint" }); options.push({ key: "anthropic", label: "Anthropic" }); - options.push({ - key: "anthropicCompatible", - label: "Other Anthropic-compatible endpoint", - }); + options.push({ key: "anthropicCompatible", label: "Other Anthropic-compatible endpoint" }); options.push({ key: "gemini", label: "Google Gemini" }); if (hasOllama || ollamaRunning) { options.push({ @@ -1760,10 +1509,7 @@ async function setupNim(gpu) { }); } if (EXPERIMENTAL && gpu && gpu.nimCapable) { - options.push({ - key: "nim-local", - label: "Local NVIDIA NIM [experimental]", - }); + options.push({ key: "nim-local", label: "Local NVIDIA NIM [experimental]" }); } if (EXPERIMENTAL && vllmRunning) { options.push({ @@ -1778,468 +1524,366 @@ async function setupNim(gpu) { } if (options.length > 1) { - selectionLoop: while (true) { - let selected; - - if (isNonInteractive()) { - const providerKey = requestedProvider || "build"; - selected = options.find((o) => o.key === providerKey); - if (!selected) { - console.error( - ` Requested provider '${providerKey}' is not available in this environment.`, - ); - process.exit(1); - } - note(` [non-interactive] Provider: ${selected.key}`); - } else { - const suggestions = []; - if (vllmRunning) suggestions.push("vLLM"); - if (ollamaRunning) suggestions.push("Ollama"); - if (suggestions.length > 0) { - console.log( - ` Detected local inference option${suggestions.length > 1 ? "s" : ""}: ${suggestions.join(", ")}`, - ); - console.log( - " Select one explicitly to use it. Press Enter to keep NVIDIA Endpoints.", - ); - console.log(""); - } + selectionLoop: + while (true) { + let selected; + if (isNonInteractive()) { + const providerKey = requestedProvider || "build"; + selected = options.find((o) => o.key === providerKey); + if (!selected) { + console.error(` Requested provider '${providerKey}' is not available in this environment.`); + process.exit(1); + } + note(` [non-interactive] Provider: ${selected.key}`); + } else { + const suggestions = []; + if (vllmRunning) suggestions.push("vLLM"); + if (ollamaRunning) suggestions.push("Ollama"); + if (suggestions.length > 0) { + console.log(` Detected local inference option${suggestions.length > 1 ? "s" : ""}: ${suggestions.join(", ")}`); + console.log(" Select one explicitly to use it. Press Enter to keep NVIDIA Endpoints."); console.log(""); - console.log(" Inference options:"); - options.forEach((o, i) => { - console.log(` ${i + 1}) ${o.label}`); - }); - console.log(""); + } - const defaultIdx = options.findIndex((o) => o.key === "build") + 1; - const choice = await prompt(` Choose [${defaultIdx}]: `); - const idx = parseInt(choice || String(defaultIdx), 10) - 1; - selected = options[idx] || options[defaultIdx - 1]; + console.log(""); + console.log(" Inference options:"); + options.forEach((o, i) => { + console.log(` ${i + 1}) ${o.label}`); + }); + console.log(""); + + const defaultIdx = options.findIndex((o) => o.key === "build") + 1; + const choice = await prompt(` Choose [${defaultIdx}]: `); + const idx = parseInt(choice || String(defaultIdx), 10) - 1; + selected = options[idx] || options[defaultIdx - 1]; + } + + if (REMOTE_PROVIDER_CONFIG[selected.key]) { + const remoteConfig = REMOTE_PROVIDER_CONFIG[selected.key]; + provider = remoteConfig.providerName; + credentialEnv = remoteConfig.credentialEnv; + endpointUrl = remoteConfig.endpointUrl; + preferredInferenceApi = null; + + if (selected.key === "custom") { + endpointUrl = isNonInteractive() + ? (process.env.NEMOCLAW_ENDPOINT_URL || "").trim() + : await prompt(" OpenAI-compatible base URL (e.g., https://openrouter.ai/api/v1): "); + if (!endpointUrl) { + console.error(" Endpoint URL is required for Other OpenAI-compatible endpoint."); + process.exit(1); + } + } else if (selected.key === "anthropicCompatible") { + endpointUrl = isNonInteractive() + ? (process.env.NEMOCLAW_ENDPOINT_URL || "").trim() + : await prompt(" Anthropic-compatible base URL (e.g., https://proxy.example.com): "); + if (!endpointUrl) { + console.error(" Endpoint URL is required for Other Anthropic-compatible endpoint."); + process.exit(1); + } } - if (REMOTE_PROVIDER_CONFIG[selected.key]) { - const remoteConfig = REMOTE_PROVIDER_CONFIG[selected.key]; - provider = remoteConfig.providerName; - credentialEnv = remoteConfig.credentialEnv; - endpointUrl = remoteConfig.endpointUrl; - preferredInferenceApi = null; - - if (selected.key === "custom") { - endpointUrl = isNonInteractive() - ? (process.env.NEMOCLAW_ENDPOINT_URL || "").trim() - : await prompt( - " OpenAI-compatible base URL (e.g., https://openrouter.ai/api/v1): ", - ); - if (!endpointUrl) { - console.error( - " Endpoint URL is required for Other OpenAI-compatible endpoint.", - ); + if (selected.key === "build") { + if (isNonInteractive()) { + if (!process.env.NVIDIA_API_KEY) { + console.error(" NVIDIA_API_KEY is required for NVIDIA Endpoints in non-interactive mode."); process.exit(1); } - } else if (selected.key === "anthropicCompatible") { - endpointUrl = isNonInteractive() - ? (process.env.NEMOCLAW_ENDPOINT_URL || "").trim() - : await prompt( - " Anthropic-compatible base URL (e.g., https://proxy.example.com): ", - ); - if (!endpointUrl) { - console.error( - " Endpoint URL is required for Other Anthropic-compatible endpoint.", - ); + } else { + await ensureApiKey(); + } + model = requestedModel || (isNonInteractive() ? DEFAULT_CLOUD_MODEL : await promptCloudModel()) || DEFAULT_CLOUD_MODEL; + } else { + if (isNonInteractive()) { + if (!process.env[credentialEnv]) { + console.error(` ${credentialEnv} is required for ${remoteConfig.label} in non-interactive mode.`); process.exit(1); } + } else { + await ensureNamedCredential(credentialEnv, remoteConfig.label + " API key", remoteConfig.helpUrl); } - - if (selected.key === "build") { + const defaultModel = requestedModel || remoteConfig.defaultModel; + let modelValidator = null; + if (selected.key === "openai" || selected.key === "gemini") { + modelValidator = (candidate) => + validateOpenAiLikeModel(remoteConfig.label, endpointUrl, candidate, getCredential(credentialEnv)); + } else if (selected.key === "anthropic") { + modelValidator = (candidate) => + validateAnthropicModel(endpointUrl || ANTHROPIC_ENDPOINT_URL, candidate, getCredential(credentialEnv)); + } + while (true) { if (isNonInteractive()) { - if (!process.env.NVIDIA_API_KEY) { - console.error( - " NVIDIA_API_KEY is required for NVIDIA Endpoints in non-interactive mode.", - ); - process.exit(1); - } + model = defaultModel; + } else if (remoteConfig.modelMode === "curated") { + model = await promptRemoteModel(remoteConfig.label, selected.key, defaultModel, modelValidator); } else { - await ensureApiKey(); + model = await promptInputModel(remoteConfig.label, defaultModel, modelValidator); } - model = - requestedModel || - (isNonInteractive() - ? DEFAULT_CLOUD_MODEL - : await promptCloudModel()) || - DEFAULT_CLOUD_MODEL; - } else { - if (isNonInteractive()) { - if (!process.env[credentialEnv]) { - console.error( - ` ${credentialEnv} is required for ${remoteConfig.label} in non-interactive mode.`, - ); - process.exit(1); + + if (selected.key === "custom") { + const validation = await validateCustomOpenAiLikeSelection( + remoteConfig.label, + endpointUrl, + model, + credentialEnv + ); + if (validation.ok) { + preferredInferenceApi = validation.api; + break; } - } else { - await ensureNamedCredential( - credentialEnv, - remoteConfig.label + " API key", - remoteConfig.helpUrl, + if (validation.retry === "selection") { + continue selectionLoop; + } + } else if (selected.key === "anthropicCompatible") { + const validation = await validateCustomAnthropicSelection( + remoteConfig.label, + endpointUrl || ANTHROPIC_ENDPOINT_URL, + model, + credentialEnv ); - } - const defaultModel = requestedModel || remoteConfig.defaultModel; - let modelValidator = null; - if (selected.key === "openai" || selected.key === "gemini") { - modelValidator = (candidate) => - validateOpenAiLikeModel( - remoteConfig.label, - endpointUrl, - candidate, - getCredential(credentialEnv), - ); - } else if (selected.key === "anthropic") { - modelValidator = (candidate) => - validateAnthropicModel( - endpointUrl || ANTHROPIC_ENDPOINT_URL, - candidate, - getCredential(credentialEnv), - ); - } - while (true) { - if (isNonInteractive()) { - model = defaultModel; - } else if (remoteConfig.modelMode === "curated") { - model = await promptRemoteModel( - remoteConfig.label, - selected.key, - defaultModel, - modelValidator, - ); - } else { - model = await promptInputModel( - remoteConfig.label, - defaultModel, - modelValidator, - ); + if (validation.ok) { + preferredInferenceApi = validation.api; + break; } - - if (selected.key === "custom") { - const validation = await validateCustomOpenAiLikeSelection( + if (validation.retry === "selection") { + continue selectionLoop; + } + } else { + const retryMessage = "Please choose a provider/model again."; + if (selected.key === "anthropic") { + preferredInferenceApi = await validateAnthropicSelectionWithRetryMessage( remoteConfig.label, - endpointUrl, + endpointUrl || ANTHROPIC_ENDPOINT_URL, model, credentialEnv, + retryMessage ); - if (validation.ok) { - preferredInferenceApi = validation.api; - break; - } - if (validation.retry === "selection") { - continue selectionLoop; - } - } else if (selected.key === "anthropicCompatible") { - const validation = await validateCustomAnthropicSelection( + } else { + preferredInferenceApi = await validateOpenAiLikeSelection( remoteConfig.label, - endpointUrl || ANTHROPIC_ENDPOINT_URL, + endpointUrl, model, credentialEnv, + retryMessage ); - if (validation.ok) { - preferredInferenceApi = validation.api; - break; - } - if (validation.retry === "selection") { - continue selectionLoop; - } - } else { - const retryMessage = "Please choose a provider/model again."; - if (selected.key === "anthropic") { - preferredInferenceApi = - await validateAnthropicSelectionWithRetryMessage( - remoteConfig.label, - endpointUrl || ANTHROPIC_ENDPOINT_URL, - model, - credentialEnv, - retryMessage, - ); - } else { - preferredInferenceApi = await validateOpenAiLikeSelection( - remoteConfig.label, - endpointUrl, - model, - credentialEnv, - retryMessage, - ); - } - if (preferredInferenceApi) { - break; - } - continue selectionLoop; } + if (preferredInferenceApi) { + break; + } + continue selectionLoop; } } + } - if (selected.key === "build") { - preferredInferenceApi = await validateOpenAiLikeSelection( - remoteConfig.label, - endpointUrl, - model, - credentialEnv, - ); - if (!preferredInferenceApi) { - continue selectionLoop; - } + if (selected.key === "build") { + preferredInferenceApi = await validateOpenAiLikeSelection( + remoteConfig.label, + endpointUrl, + model, + credentialEnv + ); + if (!preferredInferenceApi) { + continue selectionLoop; } + } - console.log(` Using ${remoteConfig.label} with model: ${model}`); - break; - } else if (selected.key === "nim-local") { - // List models that fit GPU VRAM - const models = nim - .listModels() - .filter((m) => m.minGpuMemoryMB <= gpu.totalMemoryMB); - if (models.length === 0) { - console.log( - " No NIM models fit your GPU VRAM. Falling back to cloud API.", - ); - } else { - let sel; - if (isNonInteractive()) { - if (requestedModel) { - sel = models.find((m) => m.name === requestedModel); - if (!sel) { - console.error( - ` Unsupported NEMOCLAW_MODEL for NIM: ${requestedModel}`, - ); - process.exit(1); - } - } else { - sel = models[0]; + console.log(` Using ${remoteConfig.label} with model: ${model}`); + break; + } else if (selected.key === "nim-local") { + // List models that fit GPU VRAM + const models = nim.listModels().filter((m) => m.minGpuMemoryMB <= gpu.totalMemoryMB); + if (models.length === 0) { + console.log(" No NIM models fit your GPU VRAM. Falling back to cloud API."); + } else { + let sel; + if (isNonInteractive()) { + if (requestedModel) { + sel = models.find((m) => m.name === requestedModel); + if (!sel) { + console.error(` Unsupported NEMOCLAW_MODEL for NIM: ${requestedModel}`); + process.exit(1); } - note(` [non-interactive] NIM model: ${sel.name}`); } else { - console.log(""); - console.log(" Models that fit your GPU:"); - models.forEach((m, i) => { - console.log( - ` ${i + 1}) ${m.name} (min ${m.minGpuMemoryMB} MB)`, - ); - }); - console.log(""); - - const modelChoice = await prompt(` Choose model [1]: `); - const midx = parseInt(modelChoice || "1", 10) - 1; - sel = models[midx] || models[0]; + sel = models[0]; } - model = sel.name; + note(` [non-interactive] NIM model: ${sel.name}`); + } else { + console.log(""); + console.log(" Models that fit your GPU:"); + models.forEach((m, i) => { + console.log(` ${i + 1}) ${m.name} (min ${m.minGpuMemoryMB} MB)`); + }); + console.log(""); - console.log(` Pulling NIM image for ${model}...`); - nim.pullNimImage(model); + const modelChoice = await prompt(` Choose model [1]: `); + const midx = parseInt(modelChoice || "1", 10) - 1; + sel = models[midx] || models[0]; + } + model = sel.name; - console.log(" Starting NIM container..."); - nimContainer = nim.startNimContainerByName( - nim.containerName(GATEWAY_NAME), - model, - ); + console.log(` Pulling NIM image for ${model}...`); + nim.pullNimImage(model); - console.log(" Waiting for NIM to become healthy..."); - if (!nim.waitForNimHealth()) { - console.error(" NIM failed to start. Falling back to cloud API."); - model = null; - nimContainer = null; - } else { - provider = "vllm-local"; - credentialEnv = "OPENAI_API_KEY"; - endpointUrl = getLocalProviderBaseUrl(provider); - preferredInferenceApi = await validateOpenAiLikeSelection( - "Local NVIDIA NIM", - endpointUrl, - model, - credentialEnv, - ); - if (!preferredInferenceApi) { - continue selectionLoop; - } - } - } - break; - } else if (selected.key === "ollama") { - if (!ollamaRunning) { - console.log(" Starting Ollama..."); - run("OLLAMA_HOST=0.0.0.0:11434 ollama serve > /dev/null 2>&1 &", { - ignoreError: true, - }); - sleep(2); - } - console.log(" ✓ Using Ollama on localhost:11434"); - provider = "ollama-local"; - credentialEnv = "OPENAI_API_KEY"; - endpointUrl = getLocalProviderBaseUrl(provider); - while (true) { - const installedModels = getOllamaModelOptions(runCapture); - if (isNonInteractive()) { - model = requestedModel || getDefaultOllamaModel(runCapture, gpu); - } else { - model = await promptOllamaModel(gpu); - } - const probe = prepareOllamaModel(model, installedModels); - if (!probe.ok) { - console.error(` ${probe.message}`); - if (isNonInteractive()) { - process.exit(1); - } - console.log(" Choose a different Ollama model or select Other."); - console.log(""); - continue; - } + console.log(" Starting NIM container..."); + nimContainer = nim.startNimContainerByName(nim.containerName(GATEWAY_NAME), model); + + console.log(" Waiting for NIM to become healthy..."); + if (!nim.waitForNimHealth()) { + console.error(" NIM failed to start. Falling back to cloud API."); + model = null; + nimContainer = null; + } else { + provider = "vllm-local"; + credentialEnv = "OPENAI_API_KEY"; + endpointUrl = getLocalProviderBaseUrl(provider); preferredInferenceApi = await validateOpenAiLikeSelection( - "Local Ollama", - getLocalProviderValidationBaseUrl(provider), + "Local NVIDIA NIM", + endpointUrl, model, - null, - "Choose a different Ollama model or select Other.", + credentialEnv ); if (!preferredInferenceApi) { - continue; + continue selectionLoop; } - break; } - break; - } else if (selected.key === "install-ollama") { - console.log(" Installing Ollama via Homebrew..."); - run("brew install ollama", { ignoreError: true }); + } + break; + } else if (selected.key === "ollama") { + if (!ollamaRunning) { console.log(" Starting Ollama..."); - run("OLLAMA_HOST=0.0.0.0:11434 ollama serve > /dev/null 2>&1 &", { - ignoreError: true, - }); + run("OLLAMA_HOST=0.0.0.0:11434 ollama serve > /dev/null 2>&1 &", { ignoreError: true }); sleep(2); - console.log(" ✓ Using Ollama on localhost:11434"); - provider = "ollama-local"; - credentialEnv = "OPENAI_API_KEY"; - endpointUrl = getLocalProviderBaseUrl(provider); - while (true) { - const installedModels = getOllamaModelOptions(runCapture); + } + console.log(" ✓ Using Ollama on localhost:11434"); + provider = "ollama-local"; + credentialEnv = "OPENAI_API_KEY"; + endpointUrl = getLocalProviderBaseUrl(provider); + while (true) { + const installedModels = getOllamaModelOptions(runCapture); + if (isNonInteractive()) { + model = requestedModel || getDefaultOllamaModel(runCapture, gpu); + } else { + model = await promptOllamaModel(gpu); + } + const probe = prepareOllamaModel(model, installedModels); + if (!probe.ok) { + console.error(` ${probe.message}`); if (isNonInteractive()) { - model = requestedModel || getDefaultOllamaModel(runCapture, gpu); - } else { - model = await promptOllamaModel(gpu); - } - const probe = prepareOllamaModel(model, installedModels); - if (!probe.ok) { - console.error(` ${probe.message}`); - if (isNonInteractive()) { - process.exit(1); - } - console.log(" Choose a different Ollama model or select Other."); - console.log(""); - continue; - } - preferredInferenceApi = await validateOpenAiLikeSelection( - "Local Ollama", - getLocalProviderValidationBaseUrl(provider), - model, - null, - "Choose a different Ollama model or select Other.", - ); - if (!preferredInferenceApi) { - continue; + process.exit(1); } - break; + console.log(" Choose a different Ollama model or select Other."); + console.log(""); + continue; } - break; - } else if (selected.key === "vllm") { - console.log(" ✓ Using existing vLLM on localhost:8000"); - provider = "vllm-local"; - credentialEnv = "OPENAI_API_KEY"; - endpointUrl = getLocalProviderBaseUrl(provider); - // Query vLLM for the actual model ID - const vllmModelsRaw = runCapture( - "curl -sf http://localhost:8000/v1/models 2>/dev/null", - { ignoreError: true }, + preferredInferenceApi = await validateOpenAiLikeSelection( + "Local Ollama", + getLocalProviderValidationBaseUrl(provider), + model, + null, + "Choose a different Ollama model or select Other." ); - try { - const vllmModels = JSON.parse(vllmModelsRaw); - if (vllmModels.data && vllmModels.data.length > 0) { - model = vllmModels.data[0].id; - if (!isSafeModelId(model)) { - console.error( - ` Detected model ID contains invalid characters: ${model}`, - ); - process.exit(1); - } - console.log(` Detected model: ${model}`); - } else { - console.error( - " Could not detect model from vLLM. Please specify manually.", - ); + if (!preferredInferenceApi) { + continue; + } + break; + } + break; + } else if (selected.key === "install-ollama") { + console.log(" Installing Ollama via Homebrew..."); + run("brew install ollama", { ignoreError: true }); + console.log(" Starting Ollama..."); + run("OLLAMA_HOST=0.0.0.0:11434 ollama serve > /dev/null 2>&1 &", { ignoreError: true }); + sleep(2); + console.log(" ✓ Using Ollama on localhost:11434"); + provider = "ollama-local"; + credentialEnv = "OPENAI_API_KEY"; + endpointUrl = getLocalProviderBaseUrl(provider); + while (true) { + const installedModels = getOllamaModelOptions(runCapture); + if (isNonInteractive()) { + model = requestedModel || getDefaultOllamaModel(runCapture, gpu); + } else { + model = await promptOllamaModel(gpu); + } + const probe = prepareOllamaModel(model, installedModels); + if (!probe.ok) { + console.error(` ${probe.message}`); + if (isNonInteractive()) { process.exit(1); } - } catch { - console.error( - " Could not query vLLM models endpoint. Is vLLM running on localhost:8000?", - ); - process.exit(1); + console.log(" Choose a different Ollama model or select Other."); + console.log(""); + continue; } preferredInferenceApi = await validateOpenAiLikeSelection( - "Local vLLM", + "Local Ollama", getLocalProviderValidationBaseUrl(provider), model, - credentialEnv, + null, + "Choose a different Ollama model or select Other." ); if (!preferredInferenceApi) { - continue selectionLoop; + continue; } break; } + break; + } else if (selected.key === "vllm") { + console.log(" ✓ Using existing vLLM on localhost:8000"); + provider = "vllm-local"; + credentialEnv = "OPENAI_API_KEY"; + endpointUrl = getLocalProviderBaseUrl(provider); + // Query vLLM for the actual model ID + const vllmModelsRaw = runCapture("curl -sf http://localhost:8000/v1/models 2>/dev/null", { ignoreError: true }); + try { + const vllmModels = JSON.parse(vllmModelsRaw); + if (vllmModels.data && vllmModels.data.length > 0) { + model = vllmModels.data[0].id; + if (!isSafeModelId(model)) { + console.error(` Detected model ID contains invalid characters: ${model}`); + process.exit(1); + } + console.log(` Detected model: ${model}`); + } else { + console.error(" Could not detect model from vLLM. Please specify manually."); + process.exit(1); + } + } catch { + console.error(" Could not query vLLM models endpoint. Is vLLM running on localhost:8000?"); + process.exit(1); + } + preferredInferenceApi = await validateOpenAiLikeSelection( + "Local vLLM", + getLocalProviderValidationBaseUrl(provider), + model, + credentialEnv + ); + if (!preferredInferenceApi) { + continue selectionLoop; + } + break; } } + } - return { - model, - provider, - endpointUrl, - credentialEnv, - preferredInferenceApi, - nimContainer, - }; + return { model, provider, endpointUrl, credentialEnv, preferredInferenceApi, nimContainer }; } // ── Step 5: Inference provider ─────────────────────────────────── -async function setupInference( - sandboxName, - model, - provider, - endpointUrl = null, - credentialEnv = null, -) { +async function setupInference(sandboxName, model, provider, endpointUrl = null, credentialEnv = null) { step(4, 7, "Setting up inference provider"); runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true }); - if ( - provider === "nvidia-prod" || - provider === "nvidia-nim" || - provider === "openai-api" || - provider === "anthropic-prod" || - provider === "compatible-anthropic-endpoint" || - provider === "gemini-api" || - provider === "compatible-endpoint" - ) { - const config = - provider === "nvidia-nim" - ? REMOTE_PROVIDER_CONFIG.build - : Object.values(REMOTE_PROVIDER_CONFIG).find( - (entry) => entry.providerName === provider, - ); - const resolvedCredentialEnv = - credentialEnv || (config && config.credentialEnv); + if (provider === "nvidia-prod" || provider === "nvidia-nim" || provider === "openai-api" || provider === "anthropic-prod" || provider === "compatible-anthropic-endpoint" || provider === "gemini-api" || provider === "compatible-endpoint") { + const config = provider === "nvidia-nim" + ? REMOTE_PROVIDER_CONFIG.build + : Object.values(REMOTE_PROVIDER_CONFIG).find((entry) => entry.providerName === provider); + const resolvedCredentialEnv = credentialEnv || (config && config.credentialEnv); const resolvedEndpointUrl = endpointUrl || (config && config.endpointUrl); - const env = resolvedCredentialEnv - ? { [resolvedCredentialEnv]: process.env[resolvedCredentialEnv] } - : {}; - upsertProvider( - provider, - config.providerType, - resolvedCredentialEnv, - resolvedEndpointUrl, - env, - ); + const env = resolvedCredentialEnv ? { [resolvedCredentialEnv]: process.env[resolvedCredentialEnv] } : {}; + upsertProvider(provider, config.providerType, resolvedCredentialEnv, resolvedEndpointUrl, env); const args = ["inference", "set"]; if (config.skipVerify) { args.push("--no-verify"); @@ -2256,37 +1900,19 @@ async function setupInference( upsertProvider("vllm-local", "openai", "OPENAI_API_KEY", baseUrl, { OPENAI_API_KEY: "dummy", }); - runOpenshell([ - "inference", - "set", - "--no-verify", - "--provider", - "vllm-local", - "--model", - model, - ]); + runOpenshell(["inference", "set", "--no-verify", "--provider", "vllm-local", "--model", model]); } else if (provider === "ollama-local") { const validation = validateLocalProvider(provider, runCapture); if (!validation.ok) { console.error(` ${validation.message}`); - console.error( - " On macOS, local inference also depends on OpenShell host routing support.", - ); + console.error(" On macOS, local inference also depends on OpenShell host routing support."); process.exit(1); } const baseUrl = getLocalProviderBaseUrl(provider); upsertProvider("ollama-local", "openai", "OPENAI_API_KEY", baseUrl, { OPENAI_API_KEY: "ollama", }); - runOpenshell([ - "inference", - "set", - "--no-verify", - "--provider", - "ollama-local", - "--model", - model, - ]); + runOpenshell(["inference", "set", "--no-verify", "--provider", "ollama-local", "--model", model]); console.log(` Priming Ollama model: ${model}`); run(getOllamaWarmupCommand(model), { ignoreError: true }); const probe = validateOllamaModel(model, runCapture); @@ -2317,7 +1943,7 @@ async function setupOpenclaw(sandboxName, model, provider) { try { run( `${openshellShellCommand(["sandbox", "connect", sandboxName])} < ${shellQuote(scriptFile)}`, - { stdio: ["ignore", "ignore", "inherit"] }, + { stdio: ["ignore", "ignore", "inherit"] } ); } finally { fs.unlinkSync(scriptFile); @@ -2337,9 +1963,7 @@ async function setupPolicies(sandboxName) { // Auto-detect based on env tokens if (getCredential("TELEGRAM_BOT_TOKEN")) { suggestions.push("telegram"); - console.log( - " Auto-detected: TELEGRAM_BOT_TOKEN → suggesting telegram preset", - ); + console.log(" Auto-detected: TELEGRAM_BOT_TOKEN → suggesting telegram preset"); } if (getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN) { suggestions.push("slack"); @@ -2347,18 +1971,14 @@ async function setupPolicies(sandboxName) { } if (getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN) { suggestions.push("discord"); - console.log( - " Auto-detected: DISCORD_BOT_TOKEN → suggesting discord preset", - ); + console.log(" Auto-detected: DISCORD_BOT_TOKEN → suggesting discord preset"); } const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); if (isNonInteractive()) { - const policyMode = (process.env.NEMOCLAW_POLICY_MODE || "suggested") - .trim() - .toLowerCase(); + const policyMode = (process.env.NEMOCLAW_POLICY_MODE || "suggested").trim().toLowerCase(); let selectedPresets = suggestions; if (policyMode === "skip" || policyMode === "none" || policyMode === "no") { @@ -2367,23 +1987,13 @@ async function setupPolicies(sandboxName) { } if (policyMode === "custom" || policyMode === "list") { - selectedPresets = parsePolicyPresetEnv( - process.env.NEMOCLAW_POLICY_PRESETS, - ); + selectedPresets = parsePolicyPresetEnv(process.env.NEMOCLAW_POLICY_PRESETS); if (selectedPresets.length === 0) { - console.error( - " NEMOCLAW_POLICY_PRESETS is required when NEMOCLAW_POLICY_MODE=custom.", - ); + console.error(" NEMOCLAW_POLICY_PRESETS is required when NEMOCLAW_POLICY_MODE=custom."); process.exit(1); } - } else if ( - policyMode === "suggested" || - policyMode === "default" || - policyMode === "auto" - ) { - const envPresets = parsePolicyPresetEnv( - process.env.NEMOCLAW_POLICY_PRESETS, - ); + } else if (policyMode === "suggested" || policyMode === "default" || policyMode === "auto") { + const envPresets = parsePolicyPresetEnv(process.env.NEMOCLAW_POLICY_PRESETS); if (envPresets.length > 0) { selectedPresets = envPresets; } @@ -2394,23 +2004,17 @@ async function setupPolicies(sandboxName) { } const knownPresets = new Set(allPresets.map((p) => p.name)); - const invalidPresets = selectedPresets.filter( - (name) => !knownPresets.has(name), - ); + const invalidPresets = selectedPresets.filter((name) => !knownPresets.has(name)); if (invalidPresets.length > 0) { console.error(` Unknown policy preset(s): ${invalidPresets.join(", ")}`); process.exit(1); } if (!waitForSandboxReady(sandboxName)) { - console.error( - ` Sandbox '${sandboxName}' was not ready for policy application.`, - ); + console.error(` Sandbox '${sandboxName}' was not ready for policy application.`); process.exit(1); } - note( - ` [non-interactive] Applying policy presets: ${selectedPresets.join(", ")}`, - ); + note(` [non-interactive] Applying policy presets: ${selectedPresets.join(", ")}`); for (const name of selectedPresets) { for (let attempt = 0; attempt < 3; attempt += 1) { try { @@ -2419,9 +2023,7 @@ async function setupPolicies(sandboxName) { } catch (err) { const message = err && err.message ? err.message : String(err); if (message.includes("Unimplemented")) { - console.error( - " OpenShell policy updates are not supported by this gateway build.", - ); + console.error(" OpenShell policy updates are not supported by this gateway build."); console.error(" This is a known issue tracked in NemoClaw #536."); throw err; } @@ -2442,9 +2044,7 @@ async function setupPolicies(sandboxName) { }); console.log(""); - const answer = await prompt( - ` Apply suggested presets (${suggestions.join(", ")})? [Y/n/list]: `, - ); + const answer = await prompt(` Apply suggested presets (${suggestions.join(", ")})? [Y/n/list]: `); if (answer.toLowerCase() === "n") { console.log(" Skipping policy presets."); @@ -2454,19 +2054,14 @@ async function setupPolicies(sandboxName) { if (answer.toLowerCase() === "list") { // Let user pick const picks = await prompt(" Enter preset names (comma-separated): "); - const selected = picks - .split(",") - .map((s) => s.trim()) - .filter(Boolean); + const selected = picks.split(",").map((s) => s.trim()).filter(Boolean); for (const name of selected) { try { policies.applyPreset(sandboxName, name); } catch (err) { const message = err && err.message ? err.message : String(err); if (message.includes("Unimplemented")) { - console.error( - " OpenShell policy updates are not supported by this gateway build.", - ); + console.error(" OpenShell policy updates are not supported by this gateway build."); console.error(" This is a known issue tracked in NemoClaw #536."); } throw err; @@ -2480,9 +2075,7 @@ async function setupPolicies(sandboxName) { } catch (err) { const message = err && err.message ? err.message : String(err); if (message.includes("Unimplemented")) { - console.error( - " OpenShell policy updates are not supported by this gateway build.", - ); + console.error(" OpenShell policy updates are not supported by this gateway build."); console.error(" This is a known issue tracked in NemoClaw #536."); } throw err; @@ -2497,21 +2090,16 @@ async function setupPolicies(sandboxName) { // ── Dashboard ──────────────────────────────────────────────────── function printDashboard(sandboxName, model, provider, nimContainer = null) { - const nimStat = nimContainer - ? nim.nimStatusByName(nimContainer) - : nim.nimStatus(sandboxName); + const nimStat = nimContainer ? nim.nimStatusByName(nimContainer) : nim.nimStatus(sandboxName); const nimLabel = nimStat.running ? "running" : "not running"; let providerLabel = provider; - if (provider === "nvidia-prod" || provider === "nvidia-nim") - providerLabel = "NVIDIA Endpoints"; + if (provider === "nvidia-prod" || provider === "nvidia-nim") providerLabel = "NVIDIA Endpoints"; else if (provider === "openai-api") providerLabel = "OpenAI"; else if (provider === "anthropic-prod") providerLabel = "Anthropic"; - else if (provider === "compatible-anthropic-endpoint") - providerLabel = "Other Anthropic-compatible endpoint"; + else if (provider === "compatible-anthropic-endpoint") providerLabel = "Other Anthropic-compatible endpoint"; else if (provider === "gemini-api") providerLabel = "Google Gemini"; - else if (provider === "compatible-endpoint") - providerLabel = "Other OpenAI-compatible endpoint"; + else if (provider === "compatible-endpoint") providerLabel = "Other OpenAI-compatible endpoint"; else if (provider === "vllm-local") providerLabel = "Local vLLM"; else if (provider === "ollama-local") providerLabel = "Local Ollama"; @@ -2533,8 +2121,7 @@ function printDashboard(sandboxName, model, provider, nimContainer = null) { // ── Main ───────────────────────────────────────────────────────── async function onboard(opts = {}) { - NON_INTERACTIVE = - opts.nonInteractive || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; + NON_INTERACTIVE = opts.nonInteractive || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; delete process.env.OPENSHELL_GATEWAY; console.log(""); @@ -2543,29 +2130,11 @@ async function onboard(opts = {}) { console.log(" ==================="); const gpu = await preflight(); - const { - model, - provider, - endpointUrl, - credentialEnv, - preferredInferenceApi, - nimContainer, - } = await setupNim(gpu); + const { model, provider, endpointUrl, credentialEnv, preferredInferenceApi, nimContainer } = await setupNim(gpu); process.env.NEMOCLAW_OPENSHELL_BIN = getOpenshellBinary(); await startGateway(gpu); - await setupInference( - GATEWAY_NAME, - model, - provider, - endpointUrl, - credentialEnv, - ); - const sandboxName = await createSandbox( - gpu, - model, - provider, - preferredInferenceApi, - ); + await setupInference(GATEWAY_NAME, model, provider, endpointUrl, credentialEnv); + const sandboxName = await createSandbox(gpu, model, provider, preferredInferenceApi); if (nimContainer) { registry.updateSandbox(sandboxName, { nimContainer }); } diff --git a/bin/lib/platform.js b/bin/lib/platform.js index 24ed8d8b5..f57155d50 100644 --- a/bin/lib/platform.js +++ b/bin/lib/platform.js @@ -44,11 +44,7 @@ function getColimaDockerSocketCandidates(opts = {}) { function findColimaDockerSocket(opts = {}) { const existsSync = opts.existsSync ?? require("fs").existsSync; - return ( - getColimaDockerSocketCandidates(opts).find((socketPath) => - existsSync(socketPath), - ) ?? null - ); + return getColimaDockerSocketCandidates(opts).find((socketPath) => existsSync(socketPath)) ?? null; } function getPodmanSocketCandidates(opts = {}) { @@ -62,7 +58,10 @@ function getPodmanSocketCandidates(opts = {}) { ]; } - return [`/run/user/${uid}/podman/podman.sock`, "/run/podman/podman.sock"]; + return [ + `/run/user/${uid}/podman/podman.sock`, + "/run/podman/podman.sock", + ]; } function getDockerSocketCandidates(opts = {}) { diff --git a/test/platform.test.js b/test/platform.test.js index ecd86043c..dda7f0159 100644 --- a/test/platform.test.js +++ b/test/platform.test.js @@ -17,23 +17,19 @@ import { describe("platform helpers", () => { describe("isWsl", () => { it("detects WSL from environment", () => { - expect( - isWsl({ - platform: "linux", - env: { WSL_DISTRO_NAME: "Ubuntu" }, - release: "6.6.87.2-microsoft-standard-WSL2", - }), - ).toBe(true); + expect(isWsl({ + platform: "linux", + env: { WSL_DISTRO_NAME: "Ubuntu" }, + release: "6.6.87.2-microsoft-standard-WSL2", + })).toBe(true); }); it("does not treat macOS as WSL", () => { - expect( - isWsl({ - platform: "darwin", - env: {}, - release: "24.6.0", - }), - ).toBe(false); + expect(isWsl({ + platform: "darwin", + env: {}, + release: "24.6.0", + })).toBe(false); }); }); @@ -46,13 +42,7 @@ describe("platform helpers", () => { }); it("returns Linux Podman socket paths with uid", () => { - expect( - getPodmanSocketCandidates({ - platform: "linux", - home: "/tmp/test-home", - uid: 1001, - }), - ).toEqual([ + expect(getPodmanSocketCandidates({ platform: "linux", home: "/tmp/test-home", uid: 1001 })).toEqual([ "/run/user/1001/podman/podman.sock", "/run/podman/podman.sock", ]); @@ -71,13 +61,7 @@ describe("platform helpers", () => { }); it("returns Linux candidates (Podman > native Docker)", () => { - expect( - getDockerSocketCandidates({ - platform: "linux", - home: "/tmp/test-home", - uid: 1000, - }), - ).toEqual([ + expect(getDockerSocketCandidates({ platform: "linux", home: "/tmp/test-home", uid: 1000 })).toEqual([ "/run/user/1000/podman/podman.sock", "/run/podman/podman.sock", "/run/docker.sock", @@ -89,27 +73,21 @@ describe("platform helpers", () => { describe("findColimaDockerSocket", () => { it("finds the first available Colima socket", () => { const home = "/tmp/test-home"; - const sockets = new Set([ - path.join(home, ".config/colima/default/docker.sock"), - ]); + const sockets = new Set([path.join(home, ".config/colima/default/docker.sock")]); const existsSync = (socketPath) => sockets.has(socketPath); - expect(findColimaDockerSocket({ home, existsSync })).toBe( - path.join(home, ".config/colima/default/docker.sock"), - ); + expect(findColimaDockerSocket({ home, existsSync })).toBe(path.join(home, ".config/colima/default/docker.sock")); }); }); describe("detectDockerHost", () => { it("respects an existing DOCKER_HOST", () => { - expect( - detectDockerHost({ - env: { DOCKER_HOST: "unix:///custom/docker.sock" }, - platform: "darwin", - home: "/tmp/test-home", - existsSync: () => false, - }), - ).toEqual({ + expect(detectDockerHost({ + env: { DOCKER_HOST: "unix:///custom/docker.sock" }, + platform: "darwin", + home: "/tmp/test-home", + existsSync: () => false, + })).toEqual({ dockerHost: "unix:///custom/docker.sock", source: "env", socketPath: null, @@ -124,9 +102,7 @@ describe("platform helpers", () => { ]); const existsSync = (socketPath) => sockets.has(socketPath); - expect( - detectDockerHost({ env: {}, platform: "darwin", home, existsSync }), - ).toEqual({ + expect(detectDockerHost({ env: {}, platform: "darwin", home, existsSync })).toEqual({ dockerHost: `unix://${path.join(home, ".colima/default/docker.sock")}`, source: "socket", socketPath: path.join(home, ".colima/default/docker.sock"), @@ -138,9 +114,7 @@ describe("platform helpers", () => { const socketPath = path.join(home, ".docker/run/docker.sock"); const existsSync = (candidate) => candidate === socketPath; - expect( - detectDockerHost({ env: {}, platform: "darwin", home, existsSync }), - ).toEqual({ + expect(detectDockerHost({ env: {}, platform: "darwin", home, existsSync })).toEqual({ dockerHost: `unix://${socketPath}`, source: "socket", socketPath, @@ -148,14 +122,12 @@ describe("platform helpers", () => { }); it("returns null when no auto-detected socket is available", () => { - expect( - detectDockerHost({ - env: {}, - platform: "linux", - home: "/tmp/test-home", - existsSync: () => false, - }), - ).toBe(null); + expect(detectDockerHost({ + env: {}, + platform: "linux", + home: "/tmp/test-home", + existsSync: () => false, + })).toBe(null); }); }); @@ -165,15 +137,11 @@ describe("platform helpers", () => { }); it("detects Docker Desktop", () => { - expect(inferContainerRuntime("Docker Desktop 4.42.0 (190636)")).toBe( - "docker-desktop", - ); + expect(inferContainerRuntime("Docker Desktop 4.42.0 (190636)")).toBe("docker-desktop"); }); it("detects Colima", () => { - expect( - inferContainerRuntime("Server: Colima\n Docker Engine - Community"), - ).toBe("colima"); + expect(inferContainerRuntime("Server: Colima\n Docker Engine - Community")).toBe("colima"); }); }); @@ -189,15 +157,10 @@ describe("platform helpers", () => { describe("detectDockerHost with Podman", () => { it("detects Podman socket on macOS when Colima is absent", () => { const home = "/tmp/test-home"; - const podmanSocket = path.join( - home, - ".local/share/containers/podman/machine/podman.sock", - ); + const podmanSocket = path.join(home, ".local/share/containers/podman/machine/podman.sock"); const existsSync = (candidate) => candidate === podmanSocket; - expect( - detectDockerHost({ env: {}, platform: "darwin", home, existsSync }), - ).toEqual({ + expect(detectDockerHost({ env: {}, platform: "darwin", home, existsSync })).toEqual({ dockerHost: `unix://${podmanSocket}`, source: "socket", socketPath: podmanSocket, @@ -207,16 +170,11 @@ describe("platform helpers", () => { it("prefers Colima over Podman on macOS", () => { const home = "/tmp/test-home"; const colimaSocket = path.join(home, ".colima/default/docker.sock"); - const podmanSocket = path.join( - home, - ".local/share/containers/podman/machine/podman.sock", - ); + const podmanSocket = path.join(home, ".local/share/containers/podman/machine/podman.sock"); const sockets = new Set([colimaSocket, podmanSocket]); const existsSync = (candidate) => sockets.has(candidate); - expect( - detectDockerHost({ env: {}, platform: "darwin", home, existsSync }), - ).toEqual({ + expect(detectDockerHost({ env: {}, platform: "darwin", home, existsSync })).toEqual({ dockerHost: `unix://${colimaSocket}`, source: "socket", socketPath: colimaSocket, diff --git a/test/runtime-shell.test.js b/test/runtime-shell.test.js index 8d067a0ca..799d4d5df 100644 --- a/test/runtime-shell.test.js +++ b/test/runtime-shell.test.js @@ -7,13 +7,7 @@ import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; -const RUNTIME_SH = path.join( - import.meta.dirname, - "..", - "scripts", - "lib", - "runtime.sh", -); +const RUNTIME_SH = path.join(import.meta.dirname, "..", "scripts", "lib", "runtime.sh"); function runShell(script, env = {}) { return spawnSync("bash", ["-lc", script], { @@ -35,9 +29,7 @@ describe("shell runtime helpers", () => { }); it("prefers Colima over Docker Desktop", () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-runtime-shell-"), - ); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-shell-")); const colimaSocket = path.join(home, ".colima/default/docker.sock"); const dockerDesktopSocket = path.join(home, ".docker/run/docker.sock"); @@ -53,9 +45,7 @@ describe("shell runtime helpers", () => { }); it("detects Docker Desktop when Colima is absent", () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-runtime-shell-"), - ); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-shell-")); const dockerDesktopSocket = path.join(home, ".docker/run/docker.sock"); const result = runShell(`source "${RUNTIME_SH}"; detect_docker_host`, { @@ -70,9 +60,7 @@ describe("shell runtime helpers", () => { }); it("classifies a Docker Desktop DOCKER_HOST correctly", () => { - const result = runShell( - `source "${RUNTIME_SH}"; docker_host_runtime "unix:///Users/test/.docker/run/docker.sock"`, - ); + const result = runShell(`source "${RUNTIME_SH}"; docker_host_runtime "unix:///Users/test/.docker/run/docker.sock"`); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe("docker-desktop"); @@ -98,21 +86,13 @@ describe("shell runtime helpers", () => { }); it("finds the XDG Colima socket", () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-runtime-shell-"), - ); - const xdgColimaSocket = path.join( - home, - ".config/colima/default/docker.sock", - ); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-shell-")); + const xdgColimaSocket = path.join(home, ".config/colima/default/docker.sock"); - const result = runShell( - `source "${RUNTIME_SH}"; find_colima_docker_socket`, - { - HOME: home, - NEMOCLAW_TEST_SOCKET_PATHS: xdgColimaSocket, - }, - ); + const result = runShell(`source "${RUNTIME_SH}"; find_colima_docker_socket`, { + HOME: home, + NEMOCLAW_TEST_SOCKET_PATHS: xdgColimaSocket, + }); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe(xdgColimaSocket); @@ -120,21 +100,14 @@ describe("shell runtime helpers", () => { }); it("detects podman from docker info output", () => { - const result = runShell( - `source "${RUNTIME_SH}"; infer_container_runtime_from_info "podman version 5.4.1"`, - ); + const result = runShell(`source "${RUNTIME_SH}"; infer_container_runtime_from_info "podman version 5.4.1"`); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe("podman"); }); it("detects Podman socket on macOS", () => { - const home = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-runtime-shell-"), - ); - const podmanSocket = path.join( - home, - ".local/share/containers/podman/machine/podman.sock", - ); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-shell-")); + const podmanSocket = path.join(home, ".local/share/containers/podman/machine/podman.sock"); const result = runShell( `uname() { printf 'Darwin\\n'; }; source "${RUNTIME_SH}"; find_podman_socket`, @@ -150,43 +123,31 @@ describe("shell runtime helpers", () => { }); it("classifies a Podman DOCKER_HOST correctly", () => { - const result = runShell( - `source "${RUNTIME_SH}"; docker_host_runtime "unix:///run/user/1000/podman/podman.sock"`, - ); + const result = runShell(`source "${RUNTIME_SH}"; docker_host_runtime "unix:///run/user/1000/podman/podman.sock"`); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe("podman"); }); it("classifies a Podman machine socket correctly", () => { - const result = runShell( - `source "${RUNTIME_SH}"; docker_host_runtime "unix:///Users/test/.local/share/containers/podman/machine/podman.sock"`, - ); + const result = runShell(`source "${RUNTIME_SH}"; docker_host_runtime "unix:///Users/test/.local/share/containers/podman/machine/podman.sock"`); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe("podman"); }); it("returns the vllm-local base URL", () => { - const result = runShell( - `source "${RUNTIME_SH}"; get_local_provider_base_url vllm-local`, - ); + const result = runShell(`source "${RUNTIME_SH}"; get_local_provider_base_url vllm-local`); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe("http://host.openshell.internal:8000/v1"); }); it("returns the ollama-local base URL", () => { - const result = runShell( - `source "${RUNTIME_SH}"; get_local_provider_base_url ollama-local`, - ); + const result = runShell(`source "${RUNTIME_SH}"; get_local_provider_base_url ollama-local`); expect(result.status).toBe(0); - expect(result.stdout.trim()).toBe( - "http://host.openshell.internal:11434/v1", - ); + expect(result.stdout.trim()).toBe("http://host.openshell.internal:11434/v1"); }); it("rejects unknown local providers", () => { - const result = runShell( - `source "${RUNTIME_SH}"; get_local_provider_base_url bogus-provider`, - ); + const result = runShell(`source "${RUNTIME_SH}"; get_local_provider_base_url bogus-provider`); expect(result.status).not.toBe(0); }); diff --git a/test/smoke-macos-install.test.js b/test/smoke-macos-install.test.js index 5f952066a..1fcd7a49f 100644 --- a/test/smoke-macos-install.test.js +++ b/test/smoke-macos-install.test.js @@ -5,12 +5,7 @@ import { describe, it, expect } from "vitest"; import path from "node:path"; import { spawnSync } from "node:child_process"; -const SMOKE_SCRIPT = path.join( - import.meta.dirname, - "..", - "scripts", - "smoke-macos-install.sh", -); +const SMOKE_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "smoke-macos-install.sh"); describe("macOS smoke install script guardrails", () => { it("prints help", () => { @@ -20,9 +15,7 @@ describe("macOS smoke install script guardrails", () => { }); expect(result.status).toBe(0); - expect(result.stdout).toMatch( - /Usage: \.\/scripts\/smoke-macos-install\.sh/, - ); + expect(result.stdout).toMatch(/Usage: \.\/scripts\/smoke-macos-install\.sh/); }); it("requires NVIDIA_API_KEY", () => { @@ -33,21 +26,15 @@ describe("macOS smoke install script guardrails", () => { }); expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toMatch( - /NVIDIA_API_KEY must be set/, - ); + expect(`${result.stdout}${result.stderr}`).toMatch(/NVIDIA_API_KEY must be set/); }); it("rejects invalid sandbox names", () => { - const result = spawnSync( - "bash", - [SMOKE_SCRIPT, "--sandbox-name", "Bad Name"], - { - cwd: path.join(import.meta.dirname, ".."), - encoding: "utf-8", - env: { ...process.env, NVIDIA_API_KEY: "nvapi-test" }, - }, - ); + const result = spawnSync("bash", [SMOKE_SCRIPT, "--sandbox-name", "Bad Name"], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { ...process.env, NVIDIA_API_KEY: "nvapi-test" }, + }); expect(result.status).not.toBe(0); expect(`${result.stdout}${result.stderr}`).toMatch(/Invalid sandbox name/); @@ -61,9 +48,7 @@ describe("macOS smoke install script guardrails", () => { }); expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toMatch( - /Unsupported runtime 'lxc'/, - ); + expect(`${result.stdout}${result.stderr}`).toMatch(/Unsupported runtime 'lxc'/); }); it("accepts podman as a runtime option", () => { @@ -78,30 +63,22 @@ describe("macOS smoke install script guardrails", () => { }); expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toMatch( - /no Podman socket was found/, - ); + expect(`${result.stdout}${result.stderr}`).toMatch(/no Podman socket was found/); }); it("fails when a requested runtime socket is unavailable", () => { - const result = spawnSync( - "bash", - [SMOKE_SCRIPT, "--runtime", "docker-desktop"], - { - cwd: path.join(import.meta.dirname, ".."), - encoding: "utf-8", - env: { - ...process.env, - NVIDIA_API_KEY: "nvapi-test", - HOME: "/tmp/nemoclaw-smoke-no-runtime", - }, + const result = spawnSync("bash", [SMOKE_SCRIPT, "--runtime", "docker-desktop"], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { + ...process.env, + NVIDIA_API_KEY: "nvapi-test", + HOME: "/tmp/nemoclaw-smoke-no-runtime", }, - ); + }); expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toMatch( - /no Docker Desktop socket was found/, - ); + expect(`${result.stdout}${result.stderr}`).toMatch(/no Docker Desktop socket was found/); }); it("stages the policy preset no answer after sandbox setup", () => { From fdcc6d6d95f0822ca6970bb5c24971c68e818524 Mon Sep 17 00:00:00 2001 From: Dhaval Dave Date: Tue, 24 Mar 2026 23:47:55 +0530 Subject: [PATCH 7/7] style: fix shfmt formatting in runtime.sh Add spaces around | in case pattern and move do onto the for line to match the project's shfmt config (-i 2 -ci -bn). Made-with: Cursor --- scripts/lib/runtime.sh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/lib/runtime.sh b/scripts/lib/runtime.sh index 7e4c24b15..bc0110119 100755 --- a/scripts/lib/runtime.sh +++ b/scripts/lib/runtime.sh @@ -77,7 +77,7 @@ docker_host_runtime() { unix://*"/.colima/default/docker.sock" | unix://*"/.config/colima/default/docker.sock") printf 'colima\n' ;; - unix://*"/podman/machine/podman.sock"|unix://*"/podman/podman.sock") + unix://*"/podman/machine/podman.sock" | unix://*"/podman/podman.sock") printf 'podman\n' ;; unix://*"/.docker/run/docker.sock") @@ -127,8 +127,7 @@ find_podman_socket() { uid="$(id -u 2>/dev/null || echo 1000)" for socket_path in \ "/run/user/$uid/podman/podman.sock" \ - "/run/podman/podman.sock" - do + "/run/podman/podman.sock"; do if socket_exists "$socket_path"; then printf '%s\n' "$socket_path" return 0