diff --git a/README.md b/README.md index fca28111f50..f7cb3b61561 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,18 @@ The sandbox image is approximately 2.4 GB compressed. During image push, the Doc | Linux | Ubuntu 22.04 LTS or later | | Node.js | 20 or later | | npm | 10 or later | -| Docker | Installed and running | +| Container runtime | Supported runtime installed and running | | [OpenShell](https://github.com/NVIDIA/OpenShell) | Installed | +#### Container Runtime Support + +| 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. | +| Windows WSL | Docker Desktop (WSL backend) | Supported target path | + ### Install NemoClaw and Onboard OpenClaw Agent Download and run the installer script. @@ -141,6 +150,8 @@ Inference requests from the agent never leave the sandbox directly. OpenShell in Get an API key from [build.nvidia.com](https://build.nvidia.com). The `nemoclaw onboard` command prompts for this key during setup. +Local inference options such as Ollama and vLLM are still experimental. On macOS, they also depend on OpenShell host-routing support in addition to the local service itself being reachable on the host. + --- ## Protection Layers diff --git a/bin/lib/credentials.js b/bin/lib/credentials.js index 1ac405bed33..b48c73c4ad2 100644 --- a/bin/lib/credentials.js +++ b/bin/lib/credentials.js @@ -36,6 +36,14 @@ function prompt(question) { const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); rl.question(question, (answer) => { rl.close(); + if (!process.stdin.isTTY) { + if (typeof process.stdin.pause === "function") { + process.stdin.pause(); + } + if (typeof process.stdin.unref === "function") { + process.stdin.unref(); + } + } resolve(answer.trim()); }); }); diff --git a/bin/lib/local-inference.js b/bin/lib/local-inference.js new file mode 100644 index 00000000000..474070a8dbb --- /dev/null +++ b/bin/lib/local-inference.js @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const HOST_GATEWAY_URL = "http://host.openshell.internal"; + +function getLocalProviderBaseUrl(provider) { + switch (provider) { + case "vllm-local": + return `${HOST_GATEWAY_URL}:8000/v1`; + case "ollama-local": + return `${HOST_GATEWAY_URL}:11434/v1`; + default: + return null; + } +} + +function getLocalProviderHealthCheck(provider) { + switch (provider) { + case "vllm-local": + return "curl -sf http://localhost:8000/v1/models 2>/dev/null"; + case "ollama-local": + return "curl -sf http://localhost:11434/api/tags 2>/dev/null"; + default: + return null; + } +} + +function validateLocalProvider(provider, runCapture) { + const command = getLocalProviderHealthCheck(provider); + if (!command) { + return { ok: true }; + } + + const output = runCapture(command, { ignoreError: true }); + if (output) { + return { ok: true }; + } + + switch (provider) { + case "vllm-local": + return { + ok: false, + message: "Local vLLM was selected, but nothing is responding on http://localhost:8000.", + }; + case "ollama-local": + return { + ok: false, + message: "Local Ollama was selected, but nothing is responding on http://localhost:11434.", + }; + default: + return { ok: false, message: "The selected local inference provider is unavailable." }; + } +} + +module.exports = { + HOST_GATEWAY_URL, + getLocalProviderBaseUrl, + getLocalProviderHealthCheck, + validateLocalProvider, +}; diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 79545cbc2ed..35fa8a13d8d 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -8,12 +8,20 @@ const fs = require("fs"); const path = require("path"); const { ROOT, SCRIPTS, run, runCapture } = require("./runner"); +const { + getLocalProviderBaseUrl, + validateLocalProvider, +} = require("./local-inference"); +const { + inferContainerRuntime, + isUnsupportedMacosRuntime, + shouldPatchCoredns, +} = require("./platform"); const { prompt, ensureApiKey, getCredential } = require("./credentials"); const registry = require("./registry"); const nim = require("./nim"); const policies = require("./policies"); const { checkPortAvailable } = require("./preflight"); -const HOST_GATEWAY_URL = "http://host.openshell.internal"; const EXPERIMENTAL = process.env.NEMOCLAW_EXPERIMENTAL === "1"; // Non-interactive mode: set by --non-interactive flag or env var. @@ -53,6 +61,11 @@ function isDockerRunning() { } } +function getContainerRuntime() { + const info = runCapture("docker info 2>/dev/null", { ignoreError: true }); + return inferContainerRuntime(info); +} + function isOpenshellInstalled() { try { runCapture("command -v openshell"); @@ -133,6 +146,17 @@ 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}`); + } + // OpenShell CLI if (!isOpenshellInstalled()) { console.log(" openshell CLI not found. Attempting to install..."); @@ -225,14 +249,10 @@ async function startGateway(gpu) { } // CoreDNS fix — always run. k3s-inside-Docker has broken DNS on all platforms. - const home = process.env.HOME || "/tmp"; - const colimaSocket = [ - path.join(home, ".colima/default/docker.sock"), - path.join(home, ".config/colima/default/docker.sock"), - ].find((s) => fs.existsSync(s)); - if (colimaSocket) { + const runtime = getContainerRuntime(); + if (shouldPatchCoredns(runtime)) { console.log(" Patching CoreDNS for Colima..."); - run(`bash "${path.join(SCRIPTS, "fix-coredns.sh")}" 2>&1 || true`, { ignoreError: true }); + run(`bash "${path.join(SCRIPTS, "fix-coredns.sh")}" nemoclaw 2>&1 || true`, { ignoreError: true }); } // Give DNS a moment to propagate sleep(5); @@ -553,12 +573,18 @@ async function setupInference(sandboxName, model, provider) { { ignoreError: true } ); } else if (provider === "vllm-local") { + const validation = validateLocalProvider(provider, runCapture); + if (!validation.ok) { + console.error(` ${validation.message}`); + process.exit(1); + } + const baseUrl = getLocalProviderBaseUrl(provider); run( `openshell provider create --name vllm-local --type openai ` + `--credential "OPENAI_API_KEY=dummy" ` + - `--config "OPENAI_BASE_URL=${HOST_GATEWAY_URL}:8000/v1" 2>&1 || ` + + `--config "OPENAI_BASE_URL=${baseUrl}" 2>&1 || ` + `openshell provider update vllm-local --credential "OPENAI_API_KEY=dummy" ` + - `--config "OPENAI_BASE_URL=${HOST_GATEWAY_URL}:8000/v1" 2>&1 || true`, + `--config "OPENAI_BASE_URL=${baseUrl}" 2>&1 || true`, { ignoreError: true } ); run( @@ -566,12 +592,19 @@ async function setupInference(sandboxName, model, provider) { { ignoreError: true } ); } 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."); + process.exit(1); + } + const baseUrl = getLocalProviderBaseUrl(provider); run( `openshell provider create --name ollama-local --type openai ` + `--credential "OPENAI_API_KEY=ollama" ` + - `--config "OPENAI_BASE_URL=${HOST_GATEWAY_URL}:11434/v1" 2>&1 || ` + + `--config "OPENAI_BASE_URL=${baseUrl}" 2>&1 || ` + `openshell provider update ollama-local --credential "OPENAI_API_KEY=ollama" ` + - `--config "OPENAI_BASE_URL=${HOST_GATEWAY_URL}:11434/v1" 2>&1 || true`, + `--config "OPENAI_BASE_URL=${baseUrl}" 2>&1 || true`, { ignoreError: true } ); run( diff --git a/bin/lib/platform.js b/bin/lib/platform.js new file mode 100644 index 00000000000..67c31a3f3e1 --- /dev/null +++ b/bin/lib/platform.js @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const os = require("os"); +const path = require("path"); + +function isWsl(opts = {}) { + const platform = opts.platform ?? process.platform; + if (platform !== "linux") return false; + + const env = opts.env ?? process.env; + const release = opts.release ?? os.release(); + const procVersion = opts.procVersion ?? ""; + + return ( + Boolean(env.WSL_DISTRO_NAME) || + Boolean(env.WSL_INTEROP) || + /microsoft/i.test(release) || + /microsoft/i.test(procVersion) + ); +} + +function inferContainerRuntime(info = "") { + const normalized = String(info).toLowerCase(); + if (!normalized.trim()) return "unknown"; + if (normalized.includes("podman")) return "podman"; + if (normalized.includes("colima")) return "colima"; + if (normalized.includes("docker desktop")) return "docker-desktop"; + if (normalized.includes("docker")) return "docker"; + return "unknown"; +} + +function isUnsupportedMacosRuntime(runtime, opts = {}) { + const platform = opts.platform ?? process.platform; + return platform === "darwin" && runtime === "podman"; +} + +function shouldPatchCoredns(runtime) { + return runtime === "colima"; +} + +function getColimaDockerSocketCandidates(opts = {}) { + const home = opts.home ?? process.env.HOME ?? "/tmp"; + return [ + path.join(home, ".colima/default/docker.sock"), + path.join(home, ".config/colima/default/docker.sock"), + ]; +} + +function findColimaDockerSocket(opts = {}) { + const existsSync = opts.existsSync ?? require("fs").existsSync; + return getColimaDockerSocketCandidates(opts).find((socketPath) => existsSync(socketPath)) ?? null; +} + +function getDockerSocketCandidates(opts = {}) { + const home = opts.home ?? process.env.HOME ?? "/tmp"; + const platform = opts.platform ?? process.platform; + + if (platform === "darwin") { + return [ + ...getColimaDockerSocketCandidates({ home }), + path.join(home, ".docker/run/docker.sock"), + ]; + } + + return []; +} + +function detectDockerHost(opts = {}) { + const env = opts.env ?? process.env; + if (env.DOCKER_HOST) { + return { + dockerHost: env.DOCKER_HOST, + source: "env", + socketPath: null, + }; + } + + const existsSync = opts.existsSync ?? require("fs").existsSync; + for (const socketPath of getDockerSocketCandidates(opts)) { + if (existsSync(socketPath)) { + return { + dockerHost: `unix://${socketPath}`, + source: "socket", + socketPath, + }; + } + } + + return null; +} + +module.exports = { + detectDockerHost, + findColimaDockerSocket, + getColimaDockerSocketCandidates, + getDockerSocketCandidates, + inferContainerRuntime, + isUnsupportedMacosRuntime, + isWsl, + shouldPatchCoredns, +}; diff --git a/bin/lib/runner.js b/bin/lib/runner.js index 3614dc80da2..53ec88996aa 100644 --- a/bin/lib/runner.js +++ b/bin/lib/runner.js @@ -3,29 +3,35 @@ const { execSync, spawnSync } = require("child_process"); const path = require("path"); -const fs = require("fs"); +const { detectDockerHost } = require("./platform"); const ROOT = path.resolve(__dirname, "..", ".."); const SCRIPTS = path.join(ROOT, "scripts"); -// Auto-detect Colima Docker socket (legacy ~/.colima or XDG ~/.config/colima) -if (!process.env.DOCKER_HOST) { - const home = process.env.HOME || "/tmp"; - const candidates = [ - path.join(home, ".colima/default/docker.sock"), - path.join(home, ".config/colima/default/docker.sock"), - ]; - for (const sock of candidates) { - if (fs.existsSync(sock)) { - process.env.DOCKER_HOST = `unix://${sock}`; - break; - } - } +const dockerHost = detectDockerHost(); +if (dockerHost) { + process.env.DOCKER_HOST = dockerHost.dockerHost; } function run(cmd, opts = {}) { + const stdio = opts.stdio ?? ["ignore", "inherit", "inherit"]; + const result = spawnSync("bash", ["-c", cmd], { + stdio, + cwd: ROOT, + env: { ...process.env, ...opts.env }, + ...opts, + }); + if (result.status !== 0 && !opts.ignoreError) { + console.error(` Command failed (exit ${result.status}): ${cmd.slice(0, 80)}`); + process.exit(result.status || 1); + } + return result; +} + +function runInteractive(cmd, opts = {}) { + const stdio = opts.stdio ?? "inherit"; const result = spawnSync("bash", ["-c", cmd], { - stdio: "inherit", + stdio, cwd: ROOT, env: { ...process.env, ...opts.env }, ...opts, @@ -52,4 +58,4 @@ function runCapture(cmd, opts = {}) { } } -module.exports = { ROOT, SCRIPTS, run, runCapture }; +module.exports = { ROOT, SCRIPTS, run, runCapture, runInteractive }; diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 3f22cba3f32..718d27cb65a 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -7,7 +7,7 @@ const path = require("path"); const fs = require("fs"); const os = require("os"); -const { ROOT, SCRIPTS, run, runCapture } = require("./lib/runner"); +const { ROOT, SCRIPTS, run, runCapture, runInteractive } = require("./lib/runner"); const { ensureApiKey, ensureGithubToken, @@ -127,7 +127,7 @@ async function deploy(instanceName) { fs.unlinkSync(envTmp); console.log(" Running setup..."); - run(`ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${name} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/brev-setup.sh'`); + runInteractive(`ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${name} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/brev-setup.sh'`); if (tgToken) { console.log(" Starting services..."); @@ -137,7 +137,7 @@ async function deploy(instanceName) { console.log(""); console.log(" Connecting to sandbox..."); console.log(""); - run(`ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${name} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && openshell sandbox connect nemoclaw'`); + runInteractive(`ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${name} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && openshell sandbox connect nemoclaw'`); } async function start() { @@ -200,7 +200,7 @@ function listSandboxes() { function sandboxConnect(sandboxName) { // Ensure port forward is alive before connecting run(`openshell forward start --background 18789 "${sandboxName}" 2>/dev/null || true`, { ignoreError: true }); - run(`openshell sandbox connect "${sandboxName}"`); + runInteractive(`openshell sandbox connect "${sandboxName}"`); } function sandboxStatus(sandboxName) { diff --git a/install.sh b/install.sh index 72b4689b05b..840d4bec52a 100755 --- a/install.sh +++ b/install.sh @@ -198,9 +198,9 @@ install_nemoclaw() { info "NemoClaw package.json found in current directory — installing from source…" npm install && npm link else - info "Installing NemoClaw from npm…" + info "Installing NemoClaw from GitHub…" # Revert once https://github.com/NVIDIA/NemoClaw/issues/71 is complete and the package is published - npm install -g git+ssh://git@github.com/nvidia/NemoClaw.git + npm install -g git+https://github.com/NVIDIA/NemoClaw.git fi refresh_path @@ -236,7 +236,7 @@ verify_nemoclaw() { return 0 else warn "Could not locate the nemoclaw executable." - warn "Try running: npm install -g nemoclaw" + warn "Try running: npm install -g git+https://github.com/NVIDIA/NemoClaw.git" fi error "Installation failed: nemoclaw binary not found." diff --git a/scripts/fix-coredns.sh b/scripts/fix-coredns.sh index 7afb101cff9..512ba851e66 100755 --- a/scripts/fix-coredns.sh +++ b/scripts/fix-coredns.sh @@ -19,16 +19,11 @@ set -euo pipefail GATEWAY_NAME="${1:-}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=./lib/runtime.sh +. "$SCRIPT_DIR/lib/runtime.sh" -# Find Colima socket (legacy or XDG path) -COLIMA_SOCKET="" -for _sock in "$HOME/.colima/default/docker.sock" "$HOME/.config/colima/default/docker.sock"; do - if [ -S "$_sock" ]; then - COLIMA_SOCKET="$_sock" - break - fi -done -unset _sock +COLIMA_SOCKET="$(find_colima_docker_socket || true)" if [ -z "${DOCKER_HOST:-}" ]; then if [ -n "$COLIMA_SOCKET" ]; then @@ -40,31 +35,29 @@ if [ -z "${DOCKER_HOST:-}" ]; then fi # Find the cluster container -CLUSTER=$(docker ps --filter "name=openshell-cluster" --format '{{.Names}}' | head -1) +CLUSTERS="$(docker ps --filter "name=openshell-cluster" --format '{{.Names}}')" +CLUSTER="$(select_openshell_cluster_container "$GATEWAY_NAME" "$CLUSTERS" || true)" if [ -z "$CLUSTER" ]; then - echo "ERROR: No openshell cluster container found." + if [ -n "$GATEWAY_NAME" ]; then + echo "ERROR: Could not uniquely determine the openshell cluster container for gateway '$GATEWAY_NAME'." + else + echo "ERROR: Could not uniquely determine the openshell cluster container." + fi exit 1 fi -# Get the container's upstream DNS from /etc/resolv.conf — this is the address -# the Docker/Colima VM uses for DNS and is reachable from k3s pods. -# The docker bridge gateway (172.17.0.1) does NOT serve DNS in Colima. -GATEWAY_IP=$(docker exec "$CLUSTER" grep nameserver /etc/resolv.conf | head -1 | awk '{print $2}') -if [ -z "$GATEWAY_IP" ]; then - echo "ERROR: Could not determine container gateway IP." - exit 1 -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)" -# Sanity check: don't use 127.x.x.x — it won't work from pods -if [[ "$GATEWAY_IP" == 127.* ]]; then - echo "ERROR: Gateway IP is $GATEWAY_IP (loopback). Cannot use from k3s pods." - echo "Falling back to public DNS (8.8.8.8)." - GATEWAY_IP="8.8.8.8" +if [ -z "$UPSTREAM_DNS" ]; then + echo "ERROR: Could not determine a non-loopback DNS upstream for Colima." + exit 1 fi -echo "Patching CoreDNS to forward to $GATEWAY_IP..." +echo "Patching CoreDNS to forward to $UPSTREAM_DNS..." -docker exec "$CLUSTER" kubectl patch configmap coredns -n kube-system --type merge -p "{\"data\":{\"Corefile\":\".:53 {\\n errors\\n health\\n ready\\n kubernetes cluster.local in-addr.arpa ip6.arpa {\\n pods insecure\\n fallthrough in-addr.arpa ip6.arpa\\n }\\n hosts /etc/coredns/NodeHosts {\\n ttl 60\\n reload 15s\\n fallthrough\\n }\\n prometheus :9153\\n cache 30\\n loop\\n reload\\n loadbalance\\n forward . $GATEWAY_IP\\n}\\n\"}}" > /dev/null +docker exec "$CLUSTER" kubectl patch configmap coredns -n kube-system --type merge -p "{\"data\":{\"Corefile\":\".:53 {\\n errors\\n health\\n ready\\n kubernetes cluster.local in-addr.arpa ip6.arpa {\\n pods insecure\\n fallthrough in-addr.arpa ip6.arpa\\n }\\n hosts /etc/coredns/NodeHosts {\\n ttl 60\\n reload 15s\\n fallthrough\\n }\\n prometheus :9153\\n cache 30\\n loop\\n reload\\n loadbalance\\n forward . $UPSTREAM_DNS\\n}\\n\"}}" > /dev/null docker exec "$CLUSTER" kubectl rollout restart deploy/coredns -n kube-system > /dev/null diff --git a/scripts/install.sh b/scripts/install.sh index 9bd5af4f651..fb51bcfae6b 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -18,6 +18,84 @@ info() { echo -e "${GREEN}[install]${NC} $1"; } warn() { echo -e "${YELLOW}[install]${NC} $1"; } fail() { echo -e "${RED}[install]${NC} $1"; exit 1; } +define_runtime_helpers() { + socket_exists() { + local socket_path="$1" + + if [ -n "${NEMOCLAW_TEST_SOCKET_PATHS:-}" ]; then + case ":$NEMOCLAW_TEST_SOCKET_PATHS:" in + *":$socket_path:"*) return 0 ;; + esac + fi + + [ -S "$socket_path" ] + } + + find_colima_docker_socket() { + local home_dir="${1:-${HOME:-/tmp}}" + local socket_path + + for socket_path in \ + "$home_dir/.colima/default/docker.sock" \ + "$home_dir/.config/colima/default/docker.sock" + do + if socket_exists "$socket_path"; then + printf '%s\n' "$socket_path" + return 0 + fi + done + + return 1 + } + + find_docker_desktop_socket() { + local home_dir="${1:-${HOME:-/tmp}}" + local socket_path="$home_dir/.docker/run/docker.sock" + + if socket_exists "$socket_path"; then + printf '%s\n' "$socket_path" + return 0 + fi + + return 1 + } + + detect_docker_host() { + if [ -n "${DOCKER_HOST:-}" ]; then + printf '%s\n' "$DOCKER_HOST" + return 0 + fi + + local home_dir="${1:-${HOME:-/tmp}}" + local socket_path + + if socket_path="$(find_colima_docker_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 + fi + + return 1 + } +} + +SCRIPT_PATH="${BASH_SOURCE[0]-}" +SCRIPT_DIR="" +if [ -n "$SCRIPT_PATH" ]; then + SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" +fi + +if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/lib/runtime.sh" ]; then + # shellcheck source=/dev/null + . "$SCRIPT_DIR/lib/runtime.sh" +else + define_runtime_helpers +fi + # Ensure nvm environment is loaded in the current shell. ensure_nvm_loaded() { if [ -z "${NVM_DIR:-}" ]; then @@ -177,6 +255,23 @@ install_docker() { if command -v docker > /dev/null 2>&1; then # Docker installed but not running if [ "$OS" = "Darwin" ]; then + local colima_socket="" + local docker_desktop_socket="" + colima_socket="$(find_colima_docker_socket || true)" + docker_desktop_socket="$(find_docker_desktop_socket || true)" + + if [ -n "${DOCKER_HOST:-}" ]; then + fail "Docker is installed but the selected runtime is not running. Start the runtime behind DOCKER_HOST (${DOCKER_HOST}) and re-run." + fi + + if [ -n "$colima_socket" ] && [ -n "$docker_desktop_socket" ]; then + fail "Both Colima and Docker Desktop are available on this Mac. Start the runtime you want explicitly and re-run, or set DOCKER_HOST to select one." + fi + + if [ -n "$docker_desktop_socket" ]; then + fail "Docker Desktop appears to be installed but is not running. Start Docker Desktop and re-run." + fi + if command -v colima > /dev/null 2>&1; then info "Starting Colima..." colima start @@ -268,9 +363,9 @@ install_openshell info "Installing nemoclaw CLI..." if [ "$NODE_MGR" = "nodesource" ]; then - sudo npm install -g nemoclaw + sudo npm install -g git+https://github.com/NVIDIA/NemoClaw.git else - npm install -g nemoclaw + npm install -g git+https://github.com/NVIDIA/NemoClaw.git fi if [ "$NEED_RESHIM" = true ]; then diff --git a/scripts/lib/runtime.sh b/scripts/lib/runtime.sh new file mode 100644 index 00000000000..3bf546847e8 --- /dev/null +++ b/scripts/lib/runtime.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +socket_exists() { + local socket_path="$1" + + if [ -n "${NEMOCLAW_TEST_SOCKET_PATHS:-}" ]; then + case ":$NEMOCLAW_TEST_SOCKET_PATHS:" in + *":$socket_path:"*) return 0 ;; + esac + fi + + [ -S "$socket_path" ] +} + +find_colima_docker_socket() { + local home_dir="${1:-${HOME:-/tmp}}" + local socket_path + + for socket_path in \ + "$home_dir/.colima/default/docker.sock" \ + "$home_dir/.config/colima/default/docker.sock" + do + if socket_exists "$socket_path"; then + printf '%s\n' "$socket_path" + return 0 + fi + done + + return 1 +} + +find_docker_desktop_socket() { + local home_dir="${1:-${HOME:-/tmp}}" + local socket_path="$home_dir/.docker/run/docker.sock" + + if socket_exists "$socket_path"; then + printf '%s\n' "$socket_path" + return 0 + fi + + return 1 +} + +detect_docker_host() { + if [ -n "${DOCKER_HOST:-}" ]; then + printf '%s\n' "$DOCKER_HOST" + return 0 + fi + + local home_dir="${1:-${HOME:-/tmp}}" + local socket_path + + if socket_path="$(find_colima_docker_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 + fi + + return 1 +} + +docker_host_runtime() { + local docker_host="${1:-${DOCKER_HOST:-}}" + + case "$docker_host" in + unix://*"/.colima/default/docker.sock"|unix://*"/.config/colima/default/docker.sock") + printf 'colima\n' + ;; + unix://*"/.docker/run/docker.sock") + printf 'docker-desktop\n' + ;; + "") + return 1 + ;; + *) + printf 'custom\n' + ;; + esac +} + +infer_container_runtime_from_info() { + local info="${1:-}" + local normalized + normalized="$(printf '%s' "$info" | tr '[:upper:]' '[:lower:]')" + + if [[ -z "${normalized// }" ]]; then + printf 'unknown\n' + elif [[ "$normalized" == *podman* ]]; then + printf 'podman\n' + elif [[ "$normalized" == *colima* ]]; then + printf 'colima\n' + elif [[ "$normalized" == *"docker desktop"* ]]; then + printf 'docker-desktop\n' + elif [[ "$normalized" == *docker* ]]; then + printf 'docker\n' + else + printf 'unknown\n' + fi +} + +is_unsupported_macos_runtime() { + local platform="${1:-$(uname -s)}" + local runtime="${2:-unknown}" + + [ "$platform" = "Darwin" ] && [ "$runtime" = "podman" ] +} + +is_loopback_ip() { + local ip="${1:-}" + [[ "$ip" == 127.* ]] +} + +first_non_loopback_nameserver() { + local resolv_conf="${1:-}" + + if [ -z "$resolv_conf" ]; then + return 1 + fi + + printf '%s\n' "$resolv_conf" \ + | awk '$1 == "nameserver" && $2 !~ /^127\./ { print $2; exit }' +} + +get_colima_vm_nameserver() { + if ! command -v colima > /dev/null 2>&1; then + return 1 + fi + + local profile="${COLIMA_PROFILE:-default}" + local resolv_conf + resolv_conf="$(colima ssh --profile "$profile" -- cat /etc/resolv.conf < /dev/null 2>/dev/null || true)" + first_non_loopback_nameserver "$resolv_conf" +} + +resolve_coredns_upstream() { + local container_resolv_conf="${1:-}" + local host_resolv_conf="${2:-}" + local runtime="${3:-unknown}" + local nameserver="" + + nameserver="$(first_non_loopback_nameserver "$container_resolv_conf" || true)" + if [ -n "$nameserver" ]; then + printf '%s\n' "$nameserver" + return 0 + fi + + if [ "$runtime" = "colima" ]; then + nameserver="$(get_colima_vm_nameserver || true)" + if [ -n "$nameserver" ]; then + printf '%s\n' "$nameserver" + return 0 + fi + fi + + nameserver="$(first_non_loopback_nameserver "$host_resolv_conf" || true)" + if [ -n "$nameserver" ]; then + printf '%s\n' "$nameserver" + return 0 + fi + + return 1 +} + +select_openshell_cluster_container() { + local gateway_name="${1:-}" + local containers="${2:-}" + local matches="" + local count=0 + local match_count=0 + + if [ -z "$containers" ]; then + return 1 + fi + + count="$(printf '%s\n' "$containers" | awk 'NF { count += 1 } END { print count + 0 }')" + + if [ -n "$gateway_name" ]; then + matches="$(printf '%s\n' "$containers" | grep -F -- "$gateway_name" || true)" + match_count="$(printf '%s\n' "$matches" | awk 'NF { count += 1 } END { print count + 0 }')" + + if [ "$match_count" -eq 1 ]; then + printf '%s\n' "$matches" + return 0 + fi + + if [ "$match_count" -gt 1 ]; then + return 1 + fi + fi + + if [ "$count" -eq 1 ]; then + printf '%s\n' "$containers" + return 0 + fi + + return 1 +} + +get_local_provider_base_url() { + local provider="${1:-}" + + case "$provider" in + vllm-local) printf 'http://host.openshell.internal:8000/v1\n' ;; + ollama-local) printf 'http://host.openshell.internal:11434/v1\n' ;; + *) return 1 ;; + esac +} + +check_local_provider_health() { + local provider="${1:-}" + + case "$provider" in + vllm-local) + curl -sf http://localhost:8000/v1/models > /dev/null 2>&1 + ;; + ollama-local) + curl -sf http://localhost:11434/api/tags > /dev/null 2>&1 + ;; + *) + return 1 + ;; + esac +} diff --git a/scripts/setup.sh b/scripts/setup.sh index 77d24a77ab6..cbb785fdb40 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -29,6 +29,11 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +# shellcheck source=./lib/runtime.sh +. "$SCRIPT_DIR/lib/runtime.sh" + info() { echo -e "${GREEN}>>>${NC} $1"; } warn() { echo -e "${YELLOW}>>>${NC} $1"; } fail() { echo -e "${RED}>>>${NC} $1"; exit 1; } @@ -51,16 +56,25 @@ upsert_provider() { fi } -# Resolve DOCKER_HOST for Colima if needed (legacy ~/.colima or XDG ~/.config/colima) -if [ -z "${DOCKER_HOST:-}" ]; then - for _sock in "$HOME/.colima/default/docker.sock" "$HOME/.config/colima/default/docker.sock"; do - if [ -S "$_sock" ]; then - export DOCKER_HOST="unix://$_sock" - warn "Using Colima Docker socket: $_sock" - break - fi - done - unset _sock +# Resolve DOCKER_HOST for macOS user-scoped runtimes when needed. +ORIGINAL_DOCKER_HOST="${DOCKER_HOST:-}" +if docker_host="$(detect_docker_host)"; then + export DOCKER_HOST="$docker_host" + if [ -n "$ORIGINAL_DOCKER_HOST" ]; then + warn "Using DOCKER_HOST from environment: $docker_host" + else + case "$(docker_host_runtime "$docker_host" || true)" in + colima) + warn "Using Colima Docker socket: ${docker_host#unix://}" + ;; + docker-desktop) + warn "Using Docker Desktop socket: ${docker_host#unix://}" + ;; + custom) + warn "Using Docker host: $docker_host" + ;; + esac + fi fi # Check prerequisites @@ -68,8 +82,13 @@ command -v openshell > /dev/null || fail "openshell CLI not found. Install the b 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" -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +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 # 1. Gateway — always start fresh to avoid stale state info "Starting OpenShell gateway..." @@ -89,9 +108,9 @@ done info "Gateway is healthy" # 2. CoreDNS fix (Colima only) -if [ -S "$HOME/.colima/default/docker.sock" ]; then +if [ "$CONTAINER_RUNTIME" = "colima" ]; then info "Patching CoreDNS for Colima..." - bash "$SCRIPT_DIR/fix-coredns.sh" 2>&1 || warn "CoreDNS patch failed (may not be needed)" + bash "$SCRIPT_DIR/fix-coredns.sh" nemoclaw 2>&1 || warn "CoreDNS patch failed (may not be needed)" fi # 3. Providers @@ -105,12 +124,13 @@ upsert_provider \ "OPENAI_BASE_URL=https://integrate.api.nvidia.com/v1" # vllm-local (if vLLM is installed or running) -if curl -s http://localhost:8000/v1/models > /dev/null 2>&1 || python3 -c "import vllm" 2>/dev/null; then +if check_local_provider_health "vllm-local" || python3 -c "import vllm" 2>/dev/null; then + VLLM_LOCAL_BASE_URL="$(get_local_provider_base_url "vllm-local")" upsert_provider \ "vllm-local" \ "openai" \ "OPENAI_API_KEY=dummy" \ - "OPENAI_BASE_URL=http://host.openshell.internal:8000/v1" + "OPENAI_BASE_URL=$VLLM_LOCAL_BASE_URL" fi # 4a. Ollama (macOS local inference) @@ -121,16 +141,17 @@ if [ "$(uname -s)" = "Darwin" ]; then fi if command -v ollama > /dev/null 2>&1; then # Start Ollama service if not running - if ! curl -s http://localhost:11434/api/tags > /dev/null 2>&1; then + if ! check_local_provider_health "ollama-local"; then info "Starting Ollama service..." OLLAMA_HOST=0.0.0.0:11434 ollama serve > /dev/null 2>&1 & sleep 2 fi + OLLAMA_LOCAL_BASE_URL="$(get_local_provider_base_url "ollama-local")" upsert_provider \ "ollama-local" \ "openai" \ "OPENAI_API_KEY=ollama" \ - "OPENAI_BASE_URL=http://host.openshell.internal:11434/v1" + "OPENAI_BASE_URL=$OLLAMA_LOCAL_BASE_URL" fi fi diff --git a/scripts/smoke-macos-install.sh b/scripts/smoke-macos-install.sh new file mode 100644 index 00000000000..443ef86a95d --- /dev/null +++ b/scripts/smoke-macos-install.sh @@ -0,0 +1,318 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Run the primary NemoClaw install flow on a local machine, capture logs, +# then uninstall and verify cleanup. Intended for manual smoke validation. + +set -euo pipefail + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +info() { echo -e "${GREEN}[smoke]${NC} $1"; } +warn() { echo -e "${YELLOW}[smoke]${NC} $1"; } +fail() { echo -e "${RED}[smoke]${NC} $1"; exit 1; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +# shellcheck source=./lib/runtime.sh +. "$SCRIPT_DIR/lib/runtime.sh" + +SANDBOX_NAME="smoke-$(date +%Y%m%d%H%M%S)" +LOG_DIR="${TMPDIR:-/tmp}/nemoclaw-smoke" +RUNTIME="" +ALLOW_EXISTING_STATE=false +KEEP_LOGS=false +KEEP_OPEN_SHELL=true +DELETE_MODELS=false + +INSTALL_LOG="" +UNINSTALL_LOG="" +INSTALL_STATUS=1 +UNINSTALL_STATUS=1 +ANSWERS_PIPE="" +ANSWER_WRITER_PID="" +LOG_FOLLOW_PID="" + +stop_answer_writer() { + if [ -n "$ANSWER_WRITER_PID" ] && kill -0 "$ANSWER_WRITER_PID" 2>/dev/null; then + kill "$ANSWER_WRITER_PID" 2>/dev/null || true + wait "$ANSWER_WRITER_PID" 2>/dev/null || true + fi + ANSWER_WRITER_PID="" +} + +usage() { + cat <<'EOF' +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 + --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 + --delete-models Allow uninstall.sh to delete Ollama models + -h, --help Show this help + +Environment: + NVIDIA_API_KEY Required for the cloud install path +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --sandbox-name) + SANDBOX_NAME="${2:-}" + [ -n "$SANDBOX_NAME" ] || fail "--sandbox-name requires a value" + shift 2 + ;; + --log-dir) + LOG_DIR="${2:-}" + [ -n "$LOG_DIR" ] || fail "--log-dir requires a value" + shift 2 + ;; + --runtime) + RUNTIME="${2:-}" + [ -n "$RUNTIME" ] || fail "--runtime requires a value" + shift 2 + ;; + --allow-existing-state) + ALLOW_EXISTING_STATE=true + shift + ;; + --keep-logs) + KEEP_LOGS=true + shift + ;; + --remove-openshell) + KEEP_OPEN_SHELL=false + shift + ;; + --delete-models) + DELETE_MODELS=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "Unknown argument: $1" + ;; + esac +done + +[ -n "${NVIDIA_API_KEY:-}" ] || fail "NVIDIA_API_KEY must be set for the smoke install flow." +[ -x "$REPO_DIR/install.sh" ] || fail "install.sh not found at repo root." +[ -x "$REPO_DIR/uninstall.sh" ] || fail "uninstall.sh not found at repo root." + +validate_sandbox_name() { + if ! [[ "$SANDBOX_NAME" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then + fail "Invalid sandbox name '$SANDBOX_NAME'. Use lowercase letters, numbers, and hyphens." + fi +} + +select_runtime() { + case "$RUNTIME" in + "") + return 0 + ;; + colima) + local socket_path + socket_path="$(find_colima_docker_socket || true)" + [ -n "$socket_path" ] || fail "Requested runtime 'colima', but no Colima Docker socket was found." + export DOCKER_HOST="unix://$socket_path" + info "Using runtime 'colima' via $socket_path" + ;; + docker-desktop) + local socket_path + socket_path="$(find_docker_desktop_socket || true)" + [ -n "$socket_path" ] || fail "Requested runtime 'docker-desktop', but no Docker Desktop socket was found." + export DOCKER_HOST="unix://$socket_path" + info "Using runtime 'docker-desktop' via $socket_path" + ;; + *) + fail "Unsupported runtime '$RUNTIME'. Use 'colima' or 'docker-desktop'." + ;; + esac +} + +ensure_clean_start() { + if [ "$ALLOW_EXISTING_STATE" = true ]; then + return 0 + fi + + if [ -d "$HOME/.nemoclaw" ] || [ -d "$HOME/.config/nemoclaw" ] || [ -d "$HOME/.config/openshell" ]; then + fail "Existing NemoClaw/OpenShell state detected. Re-run with --allow-existing-state if you really want to test on this machine." + fi + + if command -v openshell > /dev/null 2>&1; then + if openshell sandbox list 2>/dev/null | grep -Eq '[[:alnum:]]'; then + fail "Existing OpenShell sandboxes detected. Re-run with --allow-existing-state only if you are prepared for uninstall.sh to remove them." + fi + fi +} + +feed_install_answers() { + local answers_pipe="$1" + local install_log="$2" + + ( + printf '%s\n' "$SANDBOX_NAME" + + while :; do + if [ -f "$install_log" ] && grep -q "OpenClaw gateway launched inside sandbox" "$install_log"; then + break + fi + sleep 1 + done + + printf 'n\n' + ) > "$answers_pipe" +} + +start_log_follow() { + local logfile="$1" + : > "$logfile" + tail -n +1 -f "$logfile" & + LOG_FOLLOW_PID=$! +} + +stop_log_follow() { + if [ -n "$LOG_FOLLOW_PID" ] && kill -0 "$LOG_FOLLOW_PID" 2>/dev/null; then + kill "$LOG_FOLLOW_PID" 2>/dev/null || true + wait "$LOG_FOLLOW_PID" 2>/dev/null || true + fi + LOG_FOLLOW_PID="" +} + +run_install() { + local answers_pipe="$1" + info "Running install.sh with sandbox '$SANDBOX_NAME'" + feed_install_answers "$answers_pipe" "$INSTALL_LOG" & + ANSWER_WRITER_PID=$! + start_log_follow "$INSTALL_LOG" + set +e + bash "$REPO_DIR/install.sh" < "$answers_pipe" >> "$INSTALL_LOG" 2>&1 + INSTALL_STATUS=$? + set -e + stop_log_follow + stop_answer_writer + return 0 +} + +run_uninstall() { + local -a args=(--yes) + if [ "$KEEP_OPEN_SHELL" = true ]; then + args+=(--keep-openshell) + fi + if [ "$DELETE_MODELS" = true ]; then + args+=(--delete-models) + fi + + info "Running uninstall.sh for cleanup" + start_log_follow "$UNINSTALL_LOG" + set +e + bash "$REPO_DIR/uninstall.sh" "${args[@]}" >> "$UNINSTALL_LOG" 2>&1 + UNINSTALL_STATUS=$? + set -e + stop_log_follow + return 0 +} + +verify_cleanup() { + local leftovers=0 + + if [ -d "$HOME/.nemoclaw" ] || [ -d "$HOME/.config/nemoclaw" ]; then + warn "NemoClaw state directories still exist under HOME." + leftovers=1 + fi + + if command -v openshell > /dev/null 2>&1; then + local sandbox_output + sandbox_output="$(openshell sandbox list 2>/dev/null || true)" + if printf '%s' "$sandbox_output" | grep -Eq '[[:alnum:]]'; then + warn "OpenShell still reports sandbox entries after uninstall." + leftovers=1 + fi + fi + + if command -v docker > /dev/null 2>&1 && docker info > /dev/null 2>&1; then + local related_containers + related_containers="$( + docker ps -a --format '{{.Image}} {{.Names}}' 2>/dev/null \ + | awk 'BEGIN { IGNORECASE=1 } /openshell-cluster|openshell|openclaw|nemoclaw/ { print }' + )" + if [ -n "$related_containers" ]; then + warn "Related Docker containers remain after uninstall:" + printf '%s\n' "$related_containers" + leftovers=1 + fi + fi + + return "$leftovers" +} + +cleanup() { + stop_log_follow + + stop_answer_writer + + if [ -n "$ANSWERS_PIPE" ] && [ -p "$ANSWERS_PIPE" ]; then + rm -f "$ANSWERS_PIPE" + fi + + if [ -n "$UNINSTALL_LOG" ]; then + run_uninstall + if [ "$UNINSTALL_STATUS" -ne 0 ]; then + warn "uninstall.sh exited with status $UNINSTALL_STATUS" + fi + if ! verify_cleanup; then + warn "Cleanup verification found leftover state." + else + info "Cleanup verification passed" + fi + fi + + if [ "$KEEP_LOGS" = false ] && [ "$INSTALL_STATUS" -eq 0 ] && [ "$UNINSTALL_STATUS" -eq 0 ]; then + rm -f "$INSTALL_LOG" "$UNINSTALL_LOG" + rmdir "$LOG_DIR" 2>/dev/null || true + else + info "Install log: $INSTALL_LOG" + info "Uninstall log: $UNINSTALL_LOG" + fi +} + +main() { + validate_sandbox_name + select_runtime + ensure_clean_start + + mkdir -p "$LOG_DIR" + local stamp + stamp="$(date +%Y%m%d-%H%M%S)" + INSTALL_LOG="$LOG_DIR/install-$stamp.log" + UNINSTALL_LOG="$LOG_DIR/uninstall-$stamp.log" + + ANSWERS_PIPE="$(mktemp -u "${TMPDIR:-/tmp}/nemoclaw-smoke-answers-XXXXXX")" + mkfifo "$ANSWERS_PIPE" + trap cleanup EXIT + + info "Logs will be written under $LOG_DIR" + run_install "$ANSWERS_PIPE" + + if [ "$INSTALL_STATUS" -ne 0 ]; then + fail "install.sh failed with status $INSTALL_STATUS. See $INSTALL_LOG" + fi + + info "install.sh completed successfully" +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + main "$@" +fi diff --git a/test/credentials.test.js b/test/credentials.test.js new file mode 100644 index 00000000000..08ce5d7985b --- /dev/null +++ b/test/credentials.test.js @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +describe("credential prompts", () => { + it("exits cleanly when answers are staged through a pipe", () => { + const script = ` + set -euo pipefail + pipe="$(mktemp -u)" + mkfifo "$pipe" + trap 'rm -f "$pipe"' EXIT + { + printf 'sandbox-name\\n' + sleep 1 + printf 'n\\n' + } > "$pipe" & + node -e 'const { prompt } = require(${JSON.stringify(path.join(__dirname, "..", "bin", "lib", "credentials"))}); (async()=>{ await prompt("first: "); await prompt("second: "); })().catch(err=>{ console.error(err); process.exit(1); });' < "$pipe" + `; + + const result = spawnSync("bash", ["-lc", script], { + cwd: path.join(__dirname, ".."), + encoding: "utf-8", + timeout: 5000, + }); + + assert.equal(result.status, 0); + }); +}); diff --git a/test/install-preflight.test.js b/test/install-preflight.test.js index 6f70e197e0e..43fafcc510d 100644 --- a/test/install-preflight.test.js +++ b/test/install-preflight.test.js @@ -9,6 +9,9 @@ const path = require("node:path"); const { spawnSync } = require("node:child_process"); const INSTALLER = path.join(__dirname, "..", "install.sh"); +const CURL_PIPE_INSTALLER = path.join(__dirname, "..", "scripts", "install.sh"); +const GITHUB_INSTALL_URL = "git+https://github.com/NVIDIA/NemoClaw.git"; +const TEST_SYSTEM_PATH = "/usr/bin:/bin"; function writeExecutable(target, contents) { fs.writeFileSync(target, contents, { mode: 0o755 }); @@ -50,7 +53,7 @@ exit 98 env: { ...process.env, HOME: tmp, - PATH: `${fakeBin}:${process.env.PATH}`, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, }, }); @@ -61,4 +64,296 @@ exit 98 assert.match(output, /v18\.19\.1/); assert.match(output, /9\.8\.1/); }); + + it("uses the HTTPS GitHub fallback when not installing from a repo checkout", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-fallback-")); + const fakeBin = path.join(tmp, "bin"); + const prefix = path.join(tmp, "prefix"); + const npmLog = path.join(tmp, "npm.log"); + fs.mkdirSync(fakeBin); + fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); + + writeExecutable( + path.join(fakeBin, "node"), + `#!/usr/bin/env bash +if [ "$1" = "--version" ]; then + echo "v22.14.0" + exit 0 +fi +echo "unexpected node invocation: $*" >&2 +exit 99 +`, + ); + + writeExecutable( + path.join(fakeBin, "npm"), + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >> "$NPM_LOG_PATH" +if [ "$1" = "--version" ]; then + echo "10.9.2" + exit 0 +fi +if [ "$1" = "config" ] && [ "$2" = "get" ] && [ "$3" = "prefix" ]; then + echo "$NPM_PREFIX" + exit 0 +fi +if [ "$1" = "install" ] && [ "$2" = "-g" ] && [ "$3" = "${GITHUB_INSTALL_URL}" ]; then + cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' +#!/usr/bin/env bash +if [ "$1" = "onboard" ]; then + exit 0 +fi +if [ "$1" = "--version" ]; then + echo "v0.1.0-test" + exit 0 +fi +exit 0 +EOS + chmod +x "$NPM_PREFIX/bin/nemoclaw" + exit 0 +fi +echo "unexpected npm invocation: $*" >&2 +exit 98 +`, + ); + + const result = spawnSync("bash", [INSTALLER], { + cwd: tmp, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + NPM_PREFIX: prefix, + NPM_LOG_PATH: npmLog, + }, + }); + + assert.equal(result.status, 0); + assert.match(fs.readFileSync(npmLog, "utf-8"), new RegExp(`install -g ${GITHUB_INSTALL_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)); + }); + + it("prints the HTTPS GitHub remediation when the binary is missing", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-remediation-")); + const fakeBin = path.join(tmp, "bin"); + const prefix = path.join(tmp, "prefix"); + fs.mkdirSync(fakeBin); + fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); + + writeExecutable( + path.join(fakeBin, "node"), + `#!/usr/bin/env bash +if [ "$1" = "--version" ]; then + echo "v22.14.0" + exit 0 +fi +echo "unexpected node invocation: $*" >&2 +exit 99 +`, + ); + + writeExecutable( + path.join(fakeBin, "npm"), + `#!/usr/bin/env bash +set -euo pipefail +if [ "$1" = "--version" ]; then + echo "10.9.2" + exit 0 +fi +if [ "$1" = "config" ] && [ "$2" = "get" ] && [ "$3" = "prefix" ]; then + echo "$NPM_PREFIX" + exit 0 +fi +if [ "$1" = "install" ] && [ "$2" = "-g" ] && [ "$3" = "${GITHUB_INSTALL_URL}" ]; then + exit 0 +fi +echo "unexpected npm invocation: $*" >&2 +exit 98 +`, + ); + + const result = spawnSync("bash", [INSTALLER], { + cwd: tmp, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + NPM_PREFIX: prefix, + }, + }); + + const output = `${result.stdout}${result.stderr}`; + assert.notEqual(result.status, 0); + assert.match(output, new RegExp(GITHUB_INSTALL_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.doesNotMatch(output, /npm install -g nemoclaw/); + }); + + it("does not silently prefer Colima when both macOS runtimes are available", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-macos-runtime-choice-")); + const fakeBin = path.join(tmp, "bin"); + const colimaSocket = path.join(tmp, ".colima/default/docker.sock"); + const dockerDesktopSocket = path.join(tmp, ".docker/run/docker.sock"); + fs.mkdirSync(fakeBin); + + writeExecutable( + path.join(fakeBin, "node"), + `#!/usr/bin/env bash +if [ "$1" = "-v" ] || [ "$1" = "--version" ]; then + echo "v22.14.0" + exit 0 +fi +exit 99 +`, + ); + + writeExecutable( + path.join(fakeBin, "npm"), + `#!/usr/bin/env bash +if [ "$1" = "--version" ]; then + echo "10.9.2" + exit 0 +fi +echo "/tmp/npm-prefix" +exit 0 +`, + ); + + writeExecutable( + path.join(fakeBin, "docker"), + `#!/usr/bin/env bash +if [ "$1" = "info" ]; then + exit 1 +fi +exit 0 +`, + ); + + writeExecutable( + path.join(fakeBin, "colima"), + `#!/usr/bin/env bash +echo "colima should not be started" >&2 +exit 97 +`, + ); + + writeExecutable( + path.join(fakeBin, "uname"), + `#!/usr/bin/env bash +if [ "$1" = "-s" ]; then + echo "Darwin" + exit 0 +fi +if [ "$1" = "-m" ]; then + echo "arm64" + exit 0 +fi +echo "Darwin" +`, + ); + + const result = spawnSync("bash", [CURL_PIPE_INSTALLER], { + cwd: path.join(__dirname, ".."), + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + NEMOCLAW_TEST_SOCKET_PATHS: `${colimaSocket}:${dockerDesktopSocket}`, + }, + }); + + const output = `${result.stdout}${result.stderr}`; + assert.notEqual(result.status, 0); + assert.match(output, /Both Colima and Docker Desktop are available/); + assert.doesNotMatch(output, /colima should not be started/); + }); + + it("can run via stdin without a sibling runtime.sh file", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-curl-pipe-installer-")); + const fakeBin = path.join(tmp, "bin"); + const prefix = path.join(tmp, "prefix"); + fs.mkdirSync(fakeBin); + fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); + + writeExecutable( + path.join(fakeBin, "node"), + `#!/usr/bin/env bash +if [ "$1" = "-v" ] || [ "$1" = "--version" ]; then + echo "v22.14.0" + exit 0 +fi +exit 99 +`, + ); + + writeExecutable( + path.join(fakeBin, "npm"), + `#!/usr/bin/env bash +set -euo pipefail +if [ "$1" = "--version" ]; then + echo "10.9.2" + exit 0 +fi +if [ "$1" = "config" ] && [ "$2" = "get" ] && [ "$3" = "prefix" ]; then + echo "$NPM_PREFIX" + exit 0 +fi +if [ "$1" = "install" ] && [ "$2" = "-g" ]; then + cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' +#!/usr/bin/env bash +if [ "$1" = "--version" ]; then + echo "v0.1.0-test" + exit 0 +fi +exit 0 +EOS + chmod +x "$NPM_PREFIX/bin/nemoclaw" + exit 0 +fi +echo "unexpected npm invocation: $*" >&2 +exit 98 +`, + ); + + writeExecutable( + path.join(fakeBin, "docker"), + `#!/usr/bin/env bash +if [ "$1" = "info" ]; then + exit 0 +fi +exit 0 +`, + ); + + writeExecutable( + path.join(fakeBin, "openshell"), + `#!/usr/bin/env bash +if [ "$1" = "--version" ]; then + echo "openshell 0.0.9" + exit 0 +fi +exit 0 +`, + ); + + const scriptContents = fs.readFileSync(CURL_PIPE_INSTALLER, "utf-8"); + const result = spawnSync("bash", [], { + cwd: tmp, + input: scriptContents, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + NPM_PREFIX: prefix, + }, + }); + + const output = `${result.stdout}${result.stderr}`; + assert.equal(result.status, 0); + assert.match(output, /Installation complete!/); + assert.match(output, /nemoclaw v0\.1\.0-test is ready/); + }); }); diff --git a/test/local-inference.test.js b/test/local-inference.test.js new file mode 100644 index 00000000000..ae2049490e4 --- /dev/null +++ b/test/local-inference.test.js @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); + +const { + getLocalProviderBaseUrl, + getLocalProviderHealthCheck, + validateLocalProvider, +} = require("../bin/lib/local-inference"); + +describe("local inference helpers", () => { + it("returns the expected base URL for vllm-local", () => { + assert.equal( + getLocalProviderBaseUrl("vllm-local"), + "http://host.openshell.internal:8000/v1", + ); + }); + + it("returns the expected base URL for ollama-local", () => { + assert.equal( + getLocalProviderBaseUrl("ollama-local"), + "http://host.openshell.internal:11434/v1", + ); + }); + + it("returns the expected health check command for ollama-local", () => { + assert.equal( + getLocalProviderHealthCheck("ollama-local"), + "curl -sf http://localhost:11434/api/tags 2>/dev/null", + ); + }); + + it("validates a reachable local provider", () => { + const result = validateLocalProvider("ollama-local", () => '{"models":[]}'); + assert.deepEqual(result, { ok: true }); + }); + + it("returns a clear error when ollama-local is unavailable", () => { + const result = validateLocalProvider("ollama-local", () => ""); + assert.equal(result.ok, false); + assert.match(result.message, /http:\/\/localhost:11434/); + }); + + it("returns a clear error when vllm-local is unavailable", () => { + const result = validateLocalProvider("vllm-local", () => ""); + assert.equal(result.ok, false); + assert.match(result.message, /http:\/\/localhost:8000/); + }); +}); diff --git a/test/platform.test.js b/test/platform.test.js new file mode 100644 index 00000000000..6a20e16ecae --- /dev/null +++ b/test/platform.test.js @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); + +const { + detectDockerHost, + findColimaDockerSocket, + getDockerSocketCandidates, + inferContainerRuntime, + isUnsupportedMacosRuntime, + isWsl, + shouldPatchCoredns, +} = require("../bin/lib/platform"); + +describe("platform helpers", () => { + describe("isWsl", () => { + it("detects WSL from environment", () => { + assert.equal( + isWsl({ + platform: "linux", + env: { WSL_DISTRO_NAME: "Ubuntu" }, + release: "6.6.87.2-microsoft-standard-WSL2", + }), + true, + ); + }); + + it("does not treat macOS as WSL", () => { + assert.equal( + isWsl({ + platform: "darwin", + env: {}, + release: "24.6.0", + }), + false, + ); + }); + }); + + describe("getDockerSocketCandidates", () => { + it("returns macOS candidates in priority order", () => { + const home = "/tmp/test-home"; + assert.deepEqual(getDockerSocketCandidates({ platform: "darwin", home }), [ + path.join(home, ".colima/default/docker.sock"), + path.join(home, ".config/colima/default/docker.sock"), + path.join(home, ".docker/run/docker.sock"), + ]); + }); + + it("does not auto-detect sockets on Linux", () => { + assert.deepEqual(getDockerSocketCandidates({ platform: "linux", home: "/tmp/test-home" }), []); + }); + }); + + 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 existsSync = (socketPath) => sockets.has(socketPath); + + assert.equal( + findColimaDockerSocket({ home, existsSync }), + path.join(home, ".config/colima/default/docker.sock"), + ); + }); + }); + + describe("detectDockerHost", () => { + it("respects an existing DOCKER_HOST", () => { + assert.deepEqual( + detectDockerHost({ + env: { DOCKER_HOST: "unix:///custom/docker.sock" }, + platform: "darwin", + home: "/tmp/test-home", + existsSync: () => false, + }), + { + dockerHost: "unix:///custom/docker.sock", + source: "env", + socketPath: null, + }, + ); + }); + + it("prefers Colima over Docker Desktop on macOS", () => { + const home = "/tmp/test-home"; + const sockets = new Set([ + path.join(home, ".colima/default/docker.sock"), + path.join(home, ".docker/run/docker.sock"), + ]); + const existsSync = (socketPath) => sockets.has(socketPath); + + assert.deepEqual( + detectDockerHost({ env: {}, platform: "darwin", home, existsSync }), + { + dockerHost: `unix://${path.join(home, ".colima/default/docker.sock")}`, + source: "socket", + socketPath: path.join(home, ".colima/default/docker.sock"), + }, + ); + }); + + it("detects Docker Desktop when Colima is absent", () => { + const home = "/tmp/test-home"; + const socketPath = path.join(home, ".docker/run/docker.sock"); + const existsSync = (candidate) => candidate === socketPath; + + assert.deepEqual( + detectDockerHost({ env: {}, platform: "darwin", home, existsSync }), + { + dockerHost: `unix://${socketPath}`, + source: "socket", + socketPath, + }, + ); + }); + + it("returns null when no auto-detected socket is available", () => { + assert.equal( + detectDockerHost({ + env: {}, + platform: "linux", + home: "/tmp/test-home", + existsSync: () => false, + }), + null, + ); + }); + }); + + describe("inferContainerRuntime", () => { + it("detects podman", () => { + assert.equal(inferContainerRuntime("podman version 5.4.1"), "podman"); + }); + + it("detects Docker Desktop", () => { + assert.equal(inferContainerRuntime("Docker Desktop 4.42.0 (190636)"), "docker-desktop"); + }); + + it("detects Colima", () => { + assert.equal(inferContainerRuntime("Server: Colima\n Docker Engine - Community"), "colima"); + }); + }); + + describe("isUnsupportedMacosRuntime", () => { + it("flags podman on macOS", () => { + assert.equal(isUnsupportedMacosRuntime("podman", { platform: "darwin" }), true); + }); + + it("does not flag podman on Linux", () => { + assert.equal(isUnsupportedMacosRuntime("podman", { platform: "linux" }), false); + }); + }); + + describe("shouldPatchCoredns", () => { + it("patches CoreDNS for Colima only", () => { + assert.equal(shouldPatchCoredns("colima"), true); + assert.equal(shouldPatchCoredns("docker-desktop"), false); + assert.equal(shouldPatchCoredns("docker"), false); + }); + }); +}); diff --git a/test/runner.test.js b/test/runner.test.js new file mode 100644 index 00000000000..03bd3a2b84d --- /dev/null +++ b/test/runner.test.js @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); +const childProcess = require("node:child_process"); +const { spawnSync } = childProcess; + +const runnerPath = path.join(__dirname, "..", "bin", "lib", "runner"); + +describe("runner helpers", () => { + it("does not let child commands consume installer stdin", () => { + const script = ` + const { run } = require(${JSON.stringify(runnerPath)}); + process.stdin.setEncoding("utf8"); + run("cat >/dev/null || true"); + process.stdin.once("data", (chunk) => { + process.stdout.write(chunk); + }); + `; + + const result = spawnSync("node", ["-e", script], { + cwd: path.join(__dirname, ".."), + encoding: "utf-8", + input: "preserved-answer\n", + }); + + assert.equal(result.status, 0); + assert.equal(result.stdout, "preserved-answer\n"); + }); + + it("uses inherited stdio for interactive commands only", () => { + const calls = []; + const originalSpawnSync = childProcess.spawnSync; + childProcess.spawnSync = (...args) => { + calls.push(args); + return { status: 0 }; + }; + + try { + delete require.cache[require.resolve(runnerPath)]; + const { run, runInteractive } = require(runnerPath); + run("echo noninteractive"); + runInteractive("echo interactive"); + } finally { + childProcess.spawnSync = originalSpawnSync; + delete require.cache[require.resolve(runnerPath)]; + } + + assert.equal(calls.length, 2); + assert.deepEqual(calls[0][2].stdio, ["ignore", "inherit", "inherit"]); + assert.equal(calls[1][2].stdio, "inherit"); + }); +}); diff --git a/test/runtime-shell.test.js b/test/runtime-shell.test.js new file mode 100644 index 00000000000..979460b9888 --- /dev/null +++ b/test/runtime-shell.test.js @@ -0,0 +1,189 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const { afterEach, describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const RUNTIME_SH = path.join(__dirname, "..", "scripts", "lib", "runtime.sh"); + +afterEach(() => {}); + +function runShell(script, env = {}) { + return spawnSync("bash", ["-lc", script], { + cwd: path.join(__dirname, ".."), + encoding: "utf-8", + env: { ...process.env, ...env }, + }); +} + +describe("shell runtime helpers", () => { + it("respects an existing DOCKER_HOST", () => { + const result = runShell(`source "${RUNTIME_SH}"; detect_docker_host`, { + DOCKER_HOST: "unix:///custom/docker.sock", + HOME: "/tmp/unused-home", + }); + + assert.equal(result.status, 0); + assert.equal(result.stdout.trim(), "unix:///custom/docker.sock"); + }); + + it("prefers Colima over Docker Desktop", () => { + 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"); + + const result = runShell(`source "${RUNTIME_SH}"; detect_docker_host`, { + HOME: home, + NEMOCLAW_TEST_SOCKET_PATHS: `${colimaSocket}:${dockerDesktopSocket}`, + }); + + assert.equal(result.status, 0); + assert.equal(result.stdout.trim(), `unix://${colimaSocket}`); + fs.rmSync(home, { recursive: true, force: true }); + }); + + it("detects Docker Desktop when Colima is absent", () => { + 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`, { + HOME: home, + NEMOCLAW_TEST_SOCKET_PATHS: dockerDesktopSocket, + }); + + assert.equal(result.status, 0); + assert.equal(result.stdout.trim(), `unix://${dockerDesktopSocket}`); + fs.rmSync(home, { recursive: true, force: true }); + }); + + it("classifies a Docker Desktop DOCKER_HOST correctly", () => { + const result = runShell(`source "${RUNTIME_SH}"; docker_host_runtime "unix:///Users/test/.docker/run/docker.sock"`); + + assert.equal(result.status, 0); + assert.equal(result.stdout.trim(), "docker-desktop"); + }); + + it("selects the matching gateway cluster when a gateway name is present", () => { + const result = runShell( + `source "${RUNTIME_SH}"; + select_openshell_cluster_container "nemoclaw" $'openshell-cluster-alpha\\nopenshell-cluster-nemoclaw'`, + ); + + assert.equal(result.status, 0); + assert.equal(result.stdout.trim(), "openshell-cluster-nemoclaw"); + }); + + it("fails on ambiguous cluster selection", () => { + const result = runShell( + `source "${RUNTIME_SH}"; + select_openshell_cluster_container "" $'openshell-cluster-a\\nopenshell-cluster-b'`, + ); + + assert.notEqual(result.status, 0); + }); + + 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 result = runShell(`source "${RUNTIME_SH}"; find_colima_docker_socket`, { + HOME: home, + NEMOCLAW_TEST_SOCKET_PATHS: xdgColimaSocket, + }); + + assert.equal(result.status, 0); + assert.equal(result.stdout.trim(), xdgColimaSocket); + fs.rmSync(home, { recursive: true, force: true }); + }); + + it("detects podman from docker info output", () => { + const result = runShell(`source "${RUNTIME_SH}"; infer_container_runtime_from_info "podman version 5.4.1"`); + assert.equal(result.status, 0); + assert.equal(result.stdout.trim(), "podman"); + }); + + it("flags podman on macOS as unsupported", () => { + const result = runShell(`source "${RUNTIME_SH}"; is_unsupported_macos_runtime Darwin podman`); + assert.equal(result.status, 0); + }); + + it("does not flag podman on Linux", () => { + const result = runShell(`source "${RUNTIME_SH}"; is_unsupported_macos_runtime Linux podman`); + assert.notEqual(result.status, 0); + }); + + it("returns the vllm-local base URL", () => { + const result = runShell(`source "${RUNTIME_SH}"; get_local_provider_base_url vllm-local`); + assert.equal(result.status, 0); + assert.equal(result.stdout.trim(), "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`); + assert.equal(result.status, 0); + assert.equal(result.stdout.trim(), "http://host.openshell.internal:11434/v1"); + }); + + it("rejects unknown local providers", () => { + const result = runShell(`source "${RUNTIME_SH}"; get_local_provider_base_url bogus-provider`); + assert.notEqual(result.status, 0); + }); + + it("returns the first non-loopback nameserver", () => { + const result = runShell( + `source "${RUNTIME_SH}"; first_non_loopback_nameserver $'nameserver 127.0.0.11\\nnameserver 10.0.0.2'`, + ); + + assert.equal(result.status, 0); + assert.equal(result.stdout.trim(), "10.0.0.2"); + }); + + it("prefers the container nameserver when it is not loopback", () => { + const result = runShell( + `source "${RUNTIME_SH}"; resolve_coredns_upstream $'nameserver 10.0.0.2' $'nameserver 1.1.1.1' colima`, + ); + + assert.equal(result.status, 0); + assert.equal(result.stdout.trim(), "10.0.0.2"); + }); + + it("falls back to the Colima VM nameserver when the container resolver is loopback", () => { + const result = runShell( + `source "${RUNTIME_SH}"; + get_colima_vm_nameserver() { printf '192.168.5.1\\n'; } + resolve_coredns_upstream $'nameserver 127.0.0.11' $'nameserver 1.1.1.1' colima`, + ); + + assert.equal(result.status, 0); + assert.equal(result.stdout.trim(), "192.168.5.1"); + }); + + it("falls back to the host nameserver when no Colima VM nameserver is available", () => { + const result = runShell( + `source "${RUNTIME_SH}"; + get_colima_vm_nameserver() { return 1; } + resolve_coredns_upstream $'nameserver 127.0.0.11' $'nameserver 9.9.9.9' colima`, + ); + + assert.equal(result.status, 0); + assert.equal(result.stdout.trim(), "9.9.9.9"); + }); + + it("does not consume installer stdin when reading the Colima VM nameserver", () => { + const result = runShell( + `function colima() { cat > /dev/null || true; printf 'nameserver 100.100.100.100\\n'; } + source "${RUNTIME_SH}" + printf 'sandbox-answer\\n' | { + get_colima_vm_nameserver > /tmp/nemoclaw-colima-ns.out + cat + }`, + ); + + assert.equal(result.status, 0); + assert.equal(result.stdout.trim(), "sandbox-answer"); + }); +}); diff --git a/test/smoke-macos-install.test.js b/test/smoke-macos-install.test.js new file mode 100644 index 00000000000..6d6e44c5095 --- /dev/null +++ b/test/smoke-macos-install.test.js @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const SMOKE_SCRIPT = path.join(__dirname, "..", "scripts", "smoke-macos-install.sh"); + +describe("macOS smoke install script guardrails", () => { + it("prints help", () => { + const result = spawnSync("bash", [SMOKE_SCRIPT, "--help"], { + cwd: path.join(__dirname, ".."), + encoding: "utf-8", + }); + + assert.equal(result.status, 0); + assert.match(result.stdout, /Usage: \.\/scripts\/smoke-macos-install\.sh/); + }); + + it("requires NVIDIA_API_KEY", () => { + const result = spawnSync("bash", [SMOKE_SCRIPT], { + cwd: path.join(__dirname, ".."), + encoding: "utf-8", + env: { ...process.env, NVIDIA_API_KEY: "" }, + }); + + assert.notEqual(result.status, 0); + assert.match(`${result.stdout}${result.stderr}`, /NVIDIA_API_KEY must be set/); + }); + + it("rejects invalid sandbox names", () => { + const result = spawnSync("bash", [SMOKE_SCRIPT, "--sandbox-name", "Bad Name"], { + cwd: path.join(__dirname, ".."), + encoding: "utf-8", + env: { ...process.env, NVIDIA_API_KEY: "nvapi-test" }, + }); + + assert.notEqual(result.status, 0); + assert.match(`${result.stdout}${result.stderr}`, /Invalid sandbox name/); + }); + + it("rejects unsupported runtimes", () => { + const result = spawnSync("bash", [SMOKE_SCRIPT, "--runtime", "podman"], { + cwd: path.join(__dirname, ".."), + encoding: "utf-8", + env: { ...process.env, NVIDIA_API_KEY: "nvapi-test" }, + }); + + assert.notEqual(result.status, 0); + assert.match(`${result.stdout}${result.stderr}`, /Unsupported runtime 'podman'/); + }); + + it("fails when a requested runtime socket is unavailable", () => { + const result = spawnSync("bash", [SMOKE_SCRIPT, "--runtime", "docker-desktop"], { + cwd: path.join(__dirname, ".."), + encoding: "utf-8", + env: { + ...process.env, + NVIDIA_API_KEY: "nvapi-test", + HOME: "/tmp/nemoclaw-smoke-no-runtime", + }, + }); + + assert.notEqual(result.status, 0); + assert.match(`${result.stdout}${result.stderr}`, /no Docker Desktop socket was found/); + }); + + it("stages the policy preset no answer after sandbox setup", () => { + const script = ` + set -euo pipefail + source "${SMOKE_SCRIPT}" + answers_pipe="$(mktemp -u)" + install_log="$(mktemp)" + mkfifo "$answers_pipe" + trap 'rm -f "$answers_pipe" "$install_log"' EXIT + SANDBOX_NAME="smoke-test" + feed_install_answers "$answers_pipe" "$install_log" & + feeder_pid="$!" + { + IFS= read -r first_line + printf '%s\\n' "$first_line" + printf ' ✓ OpenClaw gateway launched inside sandbox\\n' >> "$install_log" + IFS= read -r second_line + printf '%s\\n' "$second_line" + } < "$answers_pipe" + wait "$feeder_pid" + `; + + const result = spawnSync("bash", ["-lc", script], { + cwd: path.join(__dirname, ".."), + encoding: "utf-8", + env: { ...process.env, NVIDIA_API_KEY: "nvapi-test" }, + }); + + assert.equal(result.status, 0); + assert.equal(result.stdout, "smoke-test\nn\n"); + }); +}); diff --git a/test/uninstall.test.js b/test/uninstall.test.js new file mode 100644 index 00000000000..3b7a6b0cb50 --- /dev/null +++ b/test/uninstall.test.js @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const UNINSTALL_SCRIPT = path.join(__dirname, "..", "uninstall.sh"); + +describe("uninstall helpers", () => { + it("returns the expected gateway volume candidate", () => { + const result = spawnSync( + "bash", + ["-lc", `source "${UNINSTALL_SCRIPT}"; gateway_volume_candidates nemoclaw`], + { + cwd: path.join(__dirname, ".."), + encoding: "utf-8", + }, + ); + + assert.equal(result.status, 0); + assert.equal(result.stdout.trim(), "openshell-cluster-nemoclaw"); + }); +}); diff --git a/uninstall.sh b/uninstall.sh index 4cf23b19159..f3bb8c82c4f 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -311,6 +311,52 @@ remove_related_docker_images() { fi } +gateway_volume_candidates() { + local gateway_name="${1:-$DEFAULT_GATEWAY}" + + printf 'openshell-cluster-%s\n' "$gateway_name" +} + +remove_related_docker_volumes() { + if ! command -v docker > /dev/null 2>&1; then + warn "docker not found; skipping Docker volume cleanup." + return 0 + fi + + if ! docker info > /dev/null 2>&1; then + warn "docker is not running; skipping Docker volume cleanup." + return 0 + fi + + local -a volume_names=() + local volume_name + while IFS= read -r volume_name; do + [ -n "$volume_name" ] || continue + volume_names+=("$volume_name") + done < <(gateway_volume_candidates "$DEFAULT_GATEWAY") + + if [ "${#volume_names[@]}" -eq 0 ]; then + info "No NemoClaw/OpenShell Docker volumes found" + return 0 + fi + + local removed_any=false + for volume_name in "${volume_names[@]}"; do + if docker volume inspect "$volume_name" > /dev/null 2>&1; then + if docker volume rm -f "$volume_name" > /dev/null 2>&1; then + info "Removed Docker volume $volume_name" + removed_any=true + else + warn "Failed to remove Docker volume $volume_name" + fi + fi + done + + if [ "$removed_any" = false ]; then + info "No NemoClaw/OpenShell Docker volumes found" + fi +} + remove_optional_ollama_models() { if [ "$DELETE_MODELS" != true ]; then info "Keeping Ollama models as requested." @@ -387,6 +433,9 @@ main() { info "Removing related Docker images..." remove_related_docker_images + info "Removing related Docker volumes..." + remove_related_docker_volumes + info "Removing optional Ollama models..." remove_optional_ollama_models @@ -400,4 +449,6 @@ main() { info "Uninstall complete." } -main "$@" +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + main "$@" +fi