diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 82c29fe6a0a..315fd0c50f6 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -11,6 +11,17 @@ ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/hermes-sandbox-base:latest # hadolint ignore=DL3006 FROM ${BASE_IMAGE} +# Keep the final image contract explicit even when the published base image +# changes independently of this Dockerfile. +RUN set -eu; \ + hermes_path="$(command -v hermes 2>/dev/null || true)"; \ + if [ "$hermes_path" != "/usr/local/bin/hermes" ]; then \ + echo "ERROR: expected hermes at /usr/local/bin/hermes, got ${hermes_path:-missing}" >&2; \ + exit 1; \ + fi; \ + test -x /usr/local/bin/hermes; \ + /usr/local/bin/hermes --version + # Harden: remove unnecessary build tools and network probes RUN (apt-get remove --purge -y gcc gcc-12 g++ g++-12 cpp cpp-12 make \ netcat-openbsd netcat-traditional ncat 2>/dev/null || true) \ diff --git a/scripts/install.sh b/scripts/install.sh index 871bf91eae0..19e1e975fc6 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -89,6 +89,19 @@ installer_version_for_display() { printf " v%s" "$NEMOCLAW_VERSION" } +agent_display_name() { + case "${1:-}" in + hermes) printf "Hermes" ;; + openclaw | "") printf "OpenClaw" ;; + *) + local first rest + first="$(printf "%.1s" "$1" | tr '[:lower:]' '[:upper:]')" + rest="${1#?}" + printf "%s%s" "$first" "$rest" + ;; + esac +} + # Resolve which Git ref to install from. # Priority: NEMOCLAW_INSTALL_TAG env var > "latest" tag. resolve_release_tag() { @@ -172,13 +185,13 @@ verify_downloaded_script() { resolve_default_sandbox_name() { local registry_file="${HOME}/.nemoclaw/sandboxes.json" - local sandbox_name="${NEMOCLAW_SANDBOX_NAME:-}" + local sandbox_name="" # Prefer the sandbox name from the current onboard session — it reflects # the sandbox just created, whereas sandboxes.json may hold a stale default # from a previous gateway that no longer exists (#1839). local session_file="${HOME}/.nemoclaw/onboard-session.json" - if [[ -z "$sandbox_name" && -f "$session_file" ]] && command_exists node; then + if [[ -f "$session_file" ]] && command_exists node; then sandbox_name="$( node -e ' const fs = require("fs"); @@ -190,6 +203,16 @@ resolve_default_sandbox_name() { ' "$session_file" 2>/dev/null || true )" fi + if [[ -z "$sandbox_name" && -f "$session_file" ]]; then + sandbox_name="$( + sed -n 's/.*"sandboxName"[[:space:]]*:[[:space:]]*"\([^"\\]*\)".*/\1/p' "$session_file" 2>/dev/null \ + | head -n 1 + )" + fi + + if [[ -z "$sandbox_name" ]]; then + sandbox_name="${NEMOCLAW_SANDBOX_NAME:-}" + fi if [[ -z "$sandbox_name" && -f "$registry_file" ]] && command_exists node; then sandbox_name="$( @@ -207,7 +230,11 @@ resolve_default_sandbox_name() { )" fi - printf "%s" "${sandbox_name:-my-assistant}" + local fallback="my-assistant" + if [[ "${NEMOCLAW_AGENT:-}" == "hermes" ]]; then + fallback="hermes" + fi + printf "%s" "${sandbox_name:-$fallback}" } resolve_onboarded_agent() { @@ -225,6 +252,125 @@ resolve_onboarded_agent() { fi } +restore_onboard_forward_after_post_checks() { + local sandbox_name agent_name agent_display port openshell_bin attempt state_dir pid_file watcher_script watcher_pid + sandbox_name="$(resolve_default_sandbox_name)" + agent_name="$(resolve_onboarded_agent)" + agent_display="$(agent_display_name "$agent_name")" + + case "$agent_name" in + hermes) port=8642 ;; + *) return 0 ;; + esac + + if [[ -n "${NEMOCLAW_OPENSHELL_BIN:-}" && -x "$NEMOCLAW_OPENSHELL_BIN" ]]; then + openshell_bin="$NEMOCLAW_OPENSHELL_BIN" + elif command_exists openshell; then + openshell_bin="$(command -v openshell)" + else + return 0 + fi + + state_dir="${HOME}/.nemoclaw/state" + mkdir -p "$state_dir" 2>/dev/null || true + pid_file="${state_dir}/${agent_name}-${sandbox_name}-${port}.forward.pid" + if [[ -f "$pid_file" ]]; then + local old_pid expected_watcher_script current_uid old_uid old_args + old_pid="$(cat "$pid_file" 2>/dev/null || true)" + expected_watcher_script="${pid_file}.js" + current_uid="$(id -u)" + if [[ "$old_pid" =~ ^[0-9]+$ ]] && kill -0 "$old_pid" >/dev/null 2>&1; then + old_uid="$(ps -p "$old_pid" -o uid= 2>/dev/null | tr -d '[:space:]' || true)" + old_args="$(ps -p "$old_pid" -o args= 2>/dev/null || true)" + if [[ "$old_uid" == "$current_uid" && "$old_args" == *"$expected_watcher_script"* ]]; then + kill "$old_pid" >/dev/null 2>&1 || true + fi + fi + rm -f "$pid_file" + fi + + stop_agent_forward_if_owned() { + local forward_list owner status + "$openshell_bin" forward stop "$port" "$sandbox_name" >/dev/null 2>&1 && return 0 + forward_list="$("$openshell_bin" forward list 2>/dev/null || true)" + owner="$(awk -v sandbox="$sandbox_name" -v port="$port" ' + $1 == sandbox && $3 == port { + print $1 + exit + } + ' <<<"$forward_list")" + status="$(awk -v sandbox="$sandbox_name" -v port="$port" ' + $1 == sandbox && $3 == port { + print tolower($5) + exit + } + ' <<<"$forward_list")" + if [[ "$owner" == "$sandbox_name" && ("$status" == "running" || "$status" == "active") ]]; then + "$openshell_bin" forward stop "$port" "$sandbox_name" >/dev/null 2>&1 || true + fi + } + + for attempt in 1 2 3; do + stop_agent_forward_if_owned + if [ "$attempt" -gt 1 ]; then + sleep 2 + fi + "$openshell_bin" forward start --background "$port" "$sandbox_name" >/dev/null 2>&1 || true + watcher_pid="" + if [[ "${NEMOCLAW_SKIP_FORWARD_WATCHER:-}" != "1" ]] && command_exists node; then + watcher_script="${pid_file}.js" + cat >"$watcher_script" <<'NODE' +const { spawnSync } = require("child_process"); +const [openshellBin, port, sandboxName] = process.argv.slice(2); +function run(args) { + spawnSync(openshellBin, args, { stdio: "ignore" }); +} +function healthy() { + return spawnSync("curl", ["-sf", "--max-time", "3", `http://127.0.0.1:${port}/health`], { + stdio: "ignore", + }).status === 0; +} +function tick() { + if (healthy()) return; + run(["forward", "stop", port, sandboxName]); + run(["forward", "start", "--background", port, sandboxName]); +} +tick(); +setInterval(tick, 10_000); +NODE + node -e ' + const { spawn } = require("child_process"); + const fs = require("fs"); + const [script, openshellBin, port, sandboxName, pidFile] = process.argv.slice(1); + const child = spawn(process.execPath, [script, openshellBin, port, sandboxName], { + detached: true, + stdio: "ignore", + }); + fs.writeFileSync(pidFile, String(child.pid) + "\n"); + child.unref(); + ' "$watcher_script" "$openshell_bin" "$port" "$sandbox_name" "$pid_file" \ + >/dev/null 2>&1 || true + fi + sleep 4 + if command_exists curl \ + && curl -sf --max-time 3 "http://127.0.0.1:${port}/health" >/dev/null 2>&1; then + return 0 + fi + watcher_pid="$(cat "$pid_file" 2>/dev/null || true)" + if ! command_exists curl && [[ -n "$watcher_pid" ]] && kill -0 "$watcher_pid" >/dev/null 2>&1; then + return 0 + fi + if [[ -n "$watcher_pid" ]]; then + kill "$watcher_pid" >/dev/null 2>&1 || true + fi + rm -f "$pid_file" + done + + warn "Could not restore ${agent_display} host forward on port ${port}." + warn "Run: openshell forward start --background ${port} ${sandbox_name}" + return 1 +} + # step N "Description" — numbered section header step() { local n=$1 msg=$2 @@ -255,7 +401,7 @@ print_banner() { fi printf "\n" if [[ -n "${NEMOCLAW_AGENT:-}" && "${NEMOCLAW_AGENT}" != "openclaw" ]]; then - printf " ${C_DIM}Launch %s in an OpenShell sandbox.%s${C_RESET}\n" "${NEMOCLAW_AGENT^}" "$version_suffix" + printf " ${C_DIM}Launch %s in an OpenShell sandbox.%s${C_RESET}\n" "$(agent_display_name "$NEMOCLAW_AGENT")" "$version_suffix" else printf " ${C_DIM}Launch OpenClaw in an OpenShell sandbox.%s${C_RESET}\n" "$version_suffix" fi @@ -278,7 +424,7 @@ print_done() { if [[ "$agent_name" == "openclaw" || -z "$agent_name" ]]; then printf " ${C_GREEN}Your OpenClaw Sandbox is live.${C_RESET}\n" else - printf " ${C_GREEN}Your %s Sandbox is live.${C_RESET}\n" "${agent_name^}" + printf " ${C_GREEN}Your %s Sandbox is live.${C_RESET}\n" "$(agent_display_name "$agent_name")" fi printf " ${C_DIM}Sandbox in, break things, and tell us what you find.${C_RESET}\n" printf "\n" @@ -593,6 +739,15 @@ refresh_path() { fi } +prefer_user_local_openshell() { + local local_bin="${XDG_BIN_HOME:-${HOME}/.local/bin}" + local openshell_bin="${local_bin}/openshell" + if [[ -x "$openshell_bin" ]]; then + export NEMOCLAW_OPENSHELL_BIN="$openshell_bin" + export PATH="$local_bin:$PATH" + fi +} + ensure_cli_shim() { local cli_bin="${1:-$_CLI_BIN}" local npm_bin shim_path node_path node_dir cli_path @@ -1181,6 +1336,7 @@ install_nemoclaw() { # running ./scripts/install.sh manages their own openshell. The script is # idempotent on the happy path. See #2272. spin "Installing OpenShell CLI" bash "${NEMOCLAW_SOURCE_ROOT}/scripts/install-openshell.sh" + prefer_user_local_openshell fi refresh_path @@ -1579,6 +1735,7 @@ except Exception: info "Checking for sandboxes that need upgrading…" "$_CLI_BIN" upgrade-sandboxes --auto 2>&1 || warn "Sandbox upgrade check failed (non-fatal)." fi + restore_onboard_forward_after_post_checks || error "Hermes host forward restore failed." else warn "Skipping onboarding until the host prerequisites above are fixed." fi diff --git a/src/lib/agent-onboard.test.ts b/src/lib/agent-onboard.test.ts index c7488573e1b..6e00cf830ad 100644 --- a/src/lib/agent-onboard.test.ts +++ b/src/lib/agent-onboard.test.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; // Import from compiled dist/ so coverage is attributed correctly. import { printDashboardUi } from "../../dist/lib/agent-onboard"; import type { AgentDefinition } from "./agent-defs"; @@ -120,3 +122,32 @@ describe("printDashboardUi — regression for #2078 (port 8642 is not a chat UI) expect(output).toContain("http://127.0.0.1:19000/#token=tok"); }); }); + +describe("handleAgentSetup guards", () => { + it("fails onboarding instead of completing when the agent binary or health probe is missing", () => { + const source = fs.readFileSync(path.join(import.meta.dirname, "agent-onboard.ts"), "utf-8"); + + expect(source).toContain("verifyAgentBinaryAvailable"); + expect(source).toContain( + 'resolved="$(command -v ${shellQuote(executable)} 2>/dev/null || true)"', + ); + expect(source).toContain('[ "$resolved" = ${shellQuote(binaryPath)} ]'); + expect(source).toMatch( + /"sandbox",\s*"exec",\s*"-n",\s*sandboxName,\s*"--",\s*"sh",\s*"-lc",\s*script/, + ); + expect(source).not.toMatch(/\["sandbox",\s*"exec",\s*sandboxName,\s*"sh"/); + expect(source).toContain("failAgentSetup"); + expect(source).toContain('onboardSession.markStepFailed("agent_setup"'); + expect(source).toContain("gateway did not respond within"); + expect(source).not.toContain("gateway may still be starting"); + }); + + it("accepts Hermes JSON health responses without substring false positives", () => { + const source = fs.readFileSync(path.join(import.meta.dirname, "agent-onboard.ts"), "utf-8"); + + expect(source).toContain("function isHealthProbeOk"); + expect(source).toContain("JSON.parse(body)"); + expect(source).toContain('parsed.status === "ok"'); + expect(source).not.toContain('.includes("ok")'); + }); +}); diff --git a/src/lib/agent-onboard.ts b/src/lib/agent-onboard.ts index efe5151a1d7..c5bad2dff15 100644 --- a/src/lib/agent-onboard.ts +++ b/src/lib/agent-onboard.ts @@ -9,9 +9,10 @@ import fs from "fs"; import os from "os"; import path from "path"; -import { ROOT, run } from "./runner"; +import { ROOT, run, shellQuote } from "./runner"; import { dockerBuild, dockerImageInspect } from "./docker"; import { loadAgent, resolveAgentName, type AgentDefinition } from "./agent-defs"; +import { getAgentBranding } from "./branding"; import { getProviderSelectionConfig } from "./inference-config"; import * as onboardSession from "./onboard-session"; import { sleepSeconds } from "./wait"; @@ -110,6 +111,102 @@ function sleep(seconds: number): void { sleepSeconds(seconds); } +function agentCliName(agent: AgentDefinition): string { + return getAgentBranding(agent.name).cli; +} + +function agentExecutableName(agent: AgentDefinition): string { + const configuredPath = typeof agent.binary_path === "string" ? agent.binary_path.trim() : ""; + return path.basename(configuredPath || agent.name); +} + +type AgentBinaryAvailability = + | { available: true } + | { + available: false; + reason: "not_found" | "not_executable" | "path_mismatch"; + binaryPath?: string; + resolvedPath?: string; + }; + +function verifyAgentBinaryAvailable( + sandboxName: string, + agent: AgentDefinition, + runCaptureOpenshell: OnboardContext["runCaptureOpenshell"], +): AgentBinaryAvailability { + const executable = agentExecutableName(agent); + const binaryPath = typeof agent.binary_path === "string" ? agent.binary_path.trim() : ""; + const script = binaryPath + ? [ + `resolved="$(command -v ${shellQuote(executable)} 2>/dev/null || true)"`, + `[ -n "$resolved" ] || { echo not_found; exit 1; }`, + `[ -x ${shellQuote(binaryPath)} ] || { echo not_executable; exit 1; }`, + `[ "$resolved" = ${shellQuote(binaryPath)} ] || { printf 'path_mismatch:%s\\n' "$resolved"; exit 1; }`, + "echo ok", + ].join(" && ") + : `command -v ${shellQuote(executable)} >/dev/null 2>&1 && echo ok || echo not_found`; + const result = runCaptureOpenshell( + ["sandbox", "exec", "-n", sandboxName, "--", "sh", "-lc", script], + { + ignoreError: true, + }, + ); + const status = result?.trim() ?? ""; + if (status === "ok") { + return { available: true }; + } + if (binaryPath && result) { + const mismatch = result.match(/path_mismatch:([^\n]+)/); + if (mismatch) { + return { + available: false, + reason: "path_mismatch", + binaryPath, + resolvedPath: mismatch[1].trim(), + }; + } + if (result.includes("not_executable")) { + return { available: false, reason: "not_executable", binaryPath }; + } + } + return { available: false, reason: "not_found", binaryPath: binaryPath || undefined }; +} + +function describeAgentBinaryFailure( + sandboxName: string, + agent: AgentDefinition, + result: Exclude, +): string { + const executable = agentExecutableName(agent); + if (result.reason === "path_mismatch") { + return `${agent.displayName} binary '${executable}' resolves to '${result.resolvedPath}', expected '${result.binaryPath}' inside sandbox '${sandboxName}'`; + } + if (result.reason === "not_executable") { + return `${agent.displayName} configured binary '${result.binaryPath}' is not executable inside sandbox '${sandboxName}'`; + } + return `${agent.displayName} binary '${executable}' is missing inside sandbox '${sandboxName}'`; +} + +function failAgentSetup(sandboxName: string, agent: AgentDefinition, message: string): never { + onboardSession.markStepFailed("agent_setup", message); + console.error(` \u2717 ${message}`); + console.error(` Check: ${agentCliName(agent)} ${sandboxName} logs --follow`); + process.exit(1); +} + +function isHealthProbeOk(result: string | null | undefined): boolean { + const body = (result ?? "").trim(); + if (body === "ok") { + return true; + } + try { + const parsed = JSON.parse(body) as { status?: unknown }; + return parsed.status === "ok"; + } catch { + return false; + } +} + /** * Handle the full agent setup step (step 7) including resume detection. * For non-OpenClaw agents: writes config into the sandbox and verifies @@ -140,10 +237,10 @@ export async function handleAgentSetup( const probe = agent.healthProbe; if (probe?.url) { const result = runCaptureOpenshell( - ["sandbox", "exec", sandboxName, "curl", "-sf", "--max-time", "3", probe.url], + ["sandbox", "exec", "-n", sandboxName, "--", "curl", "-sf", "--max-time", "3", probe.url], { ignoreError: true }, ); - if (result && result.includes("ok")) { + if (isHealthProbeOk(result)) { skippedStepMessage("agent_setup", sandboxName); onboardSession.markStepComplete("agent_setup", { sandboxName, provider, model }); return; @@ -154,6 +251,15 @@ export async function handleAgentSetup( startRecordedStep("agent_setup", { sandboxName, provider, model }); step(7, 8, `Setting up ${agent.displayName} inside sandbox`); + const binaryAvailability = verifyAgentBinaryAvailable(sandboxName, agent, runCaptureOpenshell); + if (!binaryAvailability.available) { + failAgentSetup( + sandboxName, + agent, + describeAgentBinaryFailure(sandboxName, agent, binaryAvailability), + ); + } + const selectionConfig = getProviderSelectionConfig(provider, model); if (selectionConfig) { const sandboxConfig = { @@ -183,10 +289,10 @@ export async function handleAgentSetup( let healthy = false; for (let i = 0; i < maxAttempts; i++) { const result = runCaptureOpenshell( - ["sandbox", "exec", sandboxName, "curl", "-sf", "--max-time", "3", probe.url], + ["sandbox", "exec", "-n", sandboxName, "--", "curl", "-sf", "--max-time", "3", probe.url], { ignoreError: true }, ); - if (result && result.includes("ok")) { + if (isHealthProbeOk(result)) { healthy = true; break; } @@ -195,8 +301,11 @@ export async function handleAgentSetup( if (healthy) { console.log(` \u2713 ${agent.displayName} gateway is healthy`); } else { - console.log(` \u26a0 ${agent.displayName} gateway did not respond within ${timeoutSecs}s.`); - console.log(` The gateway may still be starting. Check: nemoclaw ${sandboxName} logs`); + failAgentSetup( + sandboxName, + agent, + `${agent.displayName} gateway did not respond within ${timeoutSecs}s`, + ); } } else { console.log(` \u2713 ${agent.displayName} configured inside sandbox`); diff --git a/src/lib/inventory-commands.test.ts b/src/lib/inventory-commands.test.ts index 009c075c553..35c01a25772 100644 --- a/src/lib/inventory-commands.test.ts +++ b/src/lib/inventory-commands.test.ts @@ -120,7 +120,33 @@ describe("inventory commands", () => { expect(lines).toContain(" Recovered 1 sandbox entry from the live OpenShell gateway."); expect(lines).toContain(" alpha *"); expect(lines).toContain( - " model: nvidia/nemotron-3-super-120b-a12b provider: nvidia-prod GPU policies: pypi", + " agent: openclaw model: nvidia/nemotron-3-super-120b-a12b provider: nvidia-prod GPU policies: pypi", + ); + }); + + it("prints the per-sandbox agent type in list output", async () => { + const lines: string[] = []; + await listSandboxesCommand({ + recoverRegistryEntries: async () => ({ + sandboxes: [ + { + name: "hermes", + model: "nvidia/nemotron-3-super-120b-a12b", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + agent: "hermes", + }, + ], + defaultSandbox: "hermes", + }), + getLiveInference: () => null, + loadLastSession: () => null, + log: (message = "") => lines.push(message), + }); + + expect(lines).toContain( + " agent: hermes model: nvidia/nemotron-3-super-120b-a12b provider: nvidia-prod CPU policies: none", ); }); @@ -153,11 +179,11 @@ describe("inventory commands", () => { // Default sandbox reflects live gateway state, with an onboarded drift note. expect(lines).toContain( - " model: live-model provider: live-provider GPU policies: none", + " agent: openclaw model: live-model provider: live-provider GPU policies: none", ); // Stale stored row for the default sandbox must not leak through. expect(lines).not.toContain( - " model: configured-alpha provider: configured-provider GPU policies: none", + " agent: openclaw model: configured-alpha provider: configured-provider GPU policies: none", ); expect(lines).toContain( " (onboarded: model=configured-alpha, provider=configured-provider)", @@ -165,7 +191,7 @@ describe("inventory commands", () => { // Non-default sandbox keeps its stored config — the gateway only applies // to whichever sandbox is currently connected. expect(lines).toContain( - " model: configured-beta provider: beta-provider CPU policies: none", + " agent: openclaw model: configured-beta provider: beta-provider CPU policies: none", ); }); @@ -190,7 +216,7 @@ describe("inventory commands", () => { }); expect(lines).toContain( - " model: configured-alpha provider: configured-provider GPU policies: none", + " agent: openclaw model: configured-alpha provider: configured-provider GPU policies: none", ); expect(lines.some((l) => l.includes("onboarded"))).toBe(false); }); @@ -216,7 +242,7 @@ describe("inventory commands", () => { }); expect(lines).toContain( - " model: configured-alpha provider: configured-provider GPU policies: none", + " agent: openclaw model: configured-alpha provider: configured-provider GPU policies: none", ); expect(lines.some((l) => l.includes("onboarded"))).toBe(false); }); @@ -243,7 +269,7 @@ describe("inventory commands", () => { }); expect(lines).toContain( - " model: live-model provider: configured-provider GPU policies: none", + " agent: openclaw model: live-model provider: configured-provider GPU policies: none", ); expect(lines).toContain(" (onboarded: model=configured-alpha)"); }); @@ -270,7 +296,7 @@ describe("inventory commands", () => { }); expect(lines).toContain( - " model: configured-alpha provider: live-provider GPU policies: none", + " agent: openclaw model: configured-alpha provider: live-provider GPU policies: none", ); expect(lines).toContain(" (onboarded: provider=configured-provider)"); }); diff --git a/src/lib/inventory-commands.ts b/src/lib/inventory-commands.ts index f8136ebf552..f669a156ea5 100644 --- a/src/lib/inventory-commands.ts +++ b/src/lib/inventory-commands.ts @@ -176,8 +176,11 @@ export function renderSandboxInventoryText( const gpu = sandbox.gpuEnabled ? "GPU" : "CPU"; const presets = sandbox.policies.length > 0 ? sandbox.policies.join(", ") : "none"; const connected = sandbox.connected ? " ●" : ""; + const agent = sandbox.agent || "openclaw"; log(` ${sandbox.name}${def}${connected}`); - log(` model: ${model} provider: ${provider} ${gpu} policies: ${presets}`); + log( + ` agent: ${agent} model: ${model} provider: ${provider} ${gpu} policies: ${presets}`, + ); if (modelDrifted || providerDrifted) { const parts: string[] = []; if (modelDrifted) parts.push(`model=${sandbox.model || "unknown"}`); diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index d00e3ce7a7f..1c24f6f6400 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -1618,10 +1618,13 @@ function verifyWebSearchInsideSandbox( if (agentName === "hermes") { // `hermes dump` outputs config_overrides and active toolsets. // Look for the web backend in its output. - const dump = runCaptureOpenshell(["sandbox", "exec", sandboxName, "hermes", "dump"], { - ignoreError: true, - timeout: 10_000, - }); + const dump = runCaptureOpenshell( + ["sandbox", "exec", "-n", sandboxName, "--", "hermes", "dump"], + { + ignoreError: true, + timeout: 10_000, + }, + ); if (!dump) { console.warn(" ⚠ Could not verify web search config inside sandbox (hermes dump failed)."); return; @@ -1644,7 +1647,7 @@ function verifyWebSearchInsideSandbox( } else if (agentName === "openclaw") { // OpenClaw: verify tools.web.search block exists in the baked config. const configCheck = runCaptureOpenshell( - ["sandbox", "exec", sandboxName, "cat", "/sandbox/.openclaw/openclaw.json"], + ["sandbox", "exec", "-n", sandboxName, "--", "cat", "/sandbox/.openclaw/openclaw.json"], { ignoreError: true, timeout: 10_000 }, ); if (!configCheck) { @@ -2152,7 +2155,8 @@ function isOpenshellInstalled(): boolean { } function getFutureShellPathHint(binDir: string, pathValue = process.env.PATH || ""): string | null { - if (String(pathValue).split(path.delimiter).includes(binDir)) { + const parts = String(pathValue).split(path.delimiter).filter(Boolean); + if (parts[0] === binDir) { return null; } return `export PATH="${binDir}:$PATH"`; @@ -2201,6 +2205,9 @@ function installOpenshell(): { process.env.PATH = `${localBin}${path.delimiter}${process.env.PATH}`; } OPENSHELL_BIN = resolveOpenshell(); + if (OPENSHELL_BIN) { + process.env.NEMOCLAW_OPENSHELL_BIN = OPENSHELL_BIN; + } return { installed: OPENSHELL_BIN !== null, localBin, @@ -2237,6 +2244,33 @@ function getGatewayClusterContainerState(): string { return state || "missing"; } +function parseGatewayClusterImageVersion(imageRef: string | null | undefined): string | null { + const match = String(imageRef || "").match(/openshell\/cluster:([0-9]+\.[0-9]+\.[0-9]+)/); + return match ? match[1] : null; +} + +function getGatewayClusterImageRef(): string | null { + const containerName = getGatewayClusterContainerName(); + const imageRef = dockerContainerInspectFormat("{{.Config.Image}}", containerName, { + ignoreError: true, + }).trim(); + return imageRef || null; +} + +function getGatewayClusterImageDrift(): { + currentImage: string; + currentVersion: string; + expectedVersion: string; +} | null { + const expectedVersion = getInstalledOpenshellVersion(); + const currentImage = getGatewayClusterImageRef(); + const currentVersion = parseGatewayClusterImageVersion(currentImage); + if (!expectedVersion || !currentImage || !currentVersion || currentVersion === expectedVersion) { + return null; + } + return { currentImage, currentVersion, expectedVersion }; +} + function getGatewayHealthWaitConfig(_startStatus = 0, containerState = "") { const isArm64 = process.arch === "arm64"; const standardCount = envInt("NEMOCLAW_HEALTH_POLL_COUNT", isArm64 ? 30 : 12); @@ -2792,6 +2826,18 @@ async function preflight(): Promise> { console.log( " Warning: could not verify gateway container state (Docker may be unavailable). Proceeding with cached health status.", ); + } else { + const imageDrift = getGatewayClusterImageDrift(); + if (imageDrift) { + console.log( + ` Gateway image ${imageDrift.currentVersion} does not match openshell ${imageDrift.expectedVersion}. Recreating...`, + ); + stopAllDashboardForwards(); + destroyGateway(); + registry.clearAll(); + gatewayReuseState = "missing"; + console.log(" ✓ Previous gateway cleaned up"); + } } } @@ -3365,15 +3411,97 @@ const RESERVED_SANDBOX_NAMES = new Set([ "help", ]); -async function promptValidatedSandboxName() { +function normalizeSandboxAgentName(agentName: string | null | undefined): string { + const trimmed = typeof agentName === "string" ? agentName.trim() : ""; + return trimmed && trimmed !== "openclaw" ? trimmed : "openclaw"; +} + +const UNKNOWN_SANDBOX_AGENT_NAME = "unknown"; + +function getRequestedSandboxAgentName(agent: AgentDefinition | null | undefined): string { + return normalizeSandboxAgentName(agent?.name); +} + +function formatSandboxAgentName(agentName: string | null | undefined): string { + const normalized = normalizeSandboxAgentName(agentName); + if (normalized === "openclaw") return "OpenClaw"; + if (normalized === "hermes") return "Hermes"; + return normalized; +} + +function getDefaultSandboxNameForAgent(agent: AgentDefinition | null | undefined): string { + return getRequestedSandboxAgentName(agent) === "hermes" ? "hermes" : "my-assistant"; +} + +function getSandboxPromptDefault(agent: AgentDefinition | null | undefined): string { + return getDefaultSandboxNameForAgent(agent); +} + +function getEffectiveSandboxAgent(agent: AgentDefinition | null | undefined): AgentDefinition { + return agent || agentDefs.loadAgent("openclaw"); +} + +function getSandboxAgentRegistryFields( + agent: AgentDefinition | null | undefined, + agentVersionKnown = true, +): Pick { + const effectiveAgent = getEffectiveSandboxAgent(agent); + const agentName = normalizeSandboxAgentName(effectiveAgent.name); + return { + agent: agentName === "openclaw" ? null : agentName, + agentVersion: agentVersionKnown ? effectiveAgent.expectedVersion || null : null, + }; +} + +function getSandboxAgentDrift( + sandboxName: string, + requestedAgentName: string, +): { changed: boolean; existingAgentName: string; requestedAgentName: string } { + const existingEntry: SandboxEntry | null = registry.getSandbox(sandboxName); + if (!existingEntry) { + return { + changed: true, + existingAgentName: UNKNOWN_SANDBOX_AGENT_NAME, + requestedAgentName, + }; + } + const existingAgentName = normalizeSandboxAgentName(existingEntry?.agent); + return { + changed: existingAgentName !== requestedAgentName, + existingAgentName, + requestedAgentName, + }; +} + +function updateReusedSandboxMetadata( + sandboxName: string, + agent: AgentDefinition | null | undefined, + model: string, + provider: string, + dashboardPort: number, + selectionVerified = true, +): void { + const existingEntry = registry.getSandbox(sandboxName); + const agentVersionKnown = existingEntry?.agentVersion !== null; + const selectionUpdates = selectionVerified ? { model, provider } : {}; + registry.updateSandbox(sandboxName, { + ...selectionUpdates, + dashboardPort, + ...getSandboxAgentRegistryFields(agent, agentVersionKnown), + }); + registry.setDefault(sandboxName); +} + +async function promptValidatedSandboxName(agent: AgentDefinition | null = null) { const MAX_ATTEMPTS = 3; + const defaultSandboxName = getSandboxPromptDefault(agent); for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { const nameAnswer = await promptOrDefault( - " Sandbox name (lowercase, starts with letter, hyphens ok) [my-assistant]: ", + ` Sandbox name (lowercase, starts with letter, hyphens ok) [${defaultSandboxName}]: `, "NEMOCLAW_SANDBOX_NAME", - "my-assistant", + defaultSandboxName, ); - const sandboxName = (nameAnswer || "my-assistant").trim(); + const sandboxName = (nameAnswer || defaultSandboxName).trim(); try { const validatedSandboxName = validateName(sandboxName, "sandbox name"); @@ -3494,7 +3622,7 @@ async function createSandbox( step(6, 8, "Creating sandbox"); const sandboxName = validateName( - sandboxNameOverride ?? (await promptValidatedSandboxName()), + sandboxNameOverride ?? (await promptValidatedSandboxName(agent)), "sandbox name", ); @@ -3647,6 +3775,43 @@ async function createSandbox( if (liveExists) { const existingSandboxState = getSandboxReuseState(sandboxName); + const requestedAgentName = getRequestedSandboxAgentName(agent); + const agentDrift = getSandboxAgentDrift(sandboxName, requestedAgentName); + let recreateForAgentDrift = agentDrift.changed && isRecreateSandbox(); + + if (agentDrift.changed && !isRecreateSandbox()) { + console.log( + ` Sandbox '${sandboxName}' already exists as ${formatSandboxAgentName(agentDrift.existingAgentName)}.`, + ); + console.log( + ` ${cliDisplayName()} is onboarding ${formatSandboxAgentName(agentDrift.requestedAgentName)} for this sandbox name.`, + ); + console.log(" Side-by-side agents are supported, but each sandbox name has one agent type."); + if (isNonInteractive()) { + console.error( + ` Aborting: choose a different name or set NEMOCLAW_RECREATE_SANDBOX=1 to recreate '${sandboxName}'.`, + ); + console.error( + ` Example: ${cliName()} onboard --name ${getDefaultSandboxNameForAgent(agent)}`, + ); + process.exit(1); + } + if ( + await promptYesNoOrDefault( + ` Delete and recreate '${sandboxName}' as ${formatSandboxAgentName(agentDrift.requestedAgentName)}?`, + null, + false, + ) + ) { + recreateForAgentDrift = true; + } else { + console.error(" Aborted. Existing sandbox left unchanged."); + console.error( + ` Re-run with a different name, for example: ${cliName()} onboard --name ${getDefaultSandboxNameForAgent(agent)}`, + ); + process.exit(1); + } + } // Check whether messaging providers are missing from the gateway. Only // force recreation when at least one required provider doesn't exist yet — @@ -3664,7 +3829,12 @@ async function createSandbox( ? detectMessagingCredentialRotation(sandboxName, messagingTokenDefs) : { changed: false, changedProviders: [] }; - if (!isRecreateSandbox() && !needsProviderMigration && !credentialRotation.changed) { + if ( + !isRecreateSandbox() && + !recreateForAgentDrift && + !needsProviderMigration && + !credentialRotation.changed + ) { if (isNonInteractive()) { if (existingSandboxState === "ready") { if (confirmedSelectionDrift) { @@ -3688,7 +3858,14 @@ async function createSandbox( } const reusedPort = ensureDashboardForward(sandboxName, chatUiUrl); process.env.CHAT_UI_URL = `http://127.0.0.1:${reusedPort}`; - registry.updateSandbox(sandboxName, { dashboardPort: reusedPort }); + updateReusedSandboxMetadata( + sandboxName, + agent, + model, + provider, + reusedPort, + !selectionDrift.unknown, + ); return sandboxName; } } else { @@ -3717,7 +3894,14 @@ async function createSandbox( upsertMessagingProviders(messagingTokenDefs); const reusedPort2 = ensureDashboardForward(sandboxName, chatUiUrl); process.env.CHAT_UI_URL = `http://127.0.0.1:${reusedPort2}`; - registry.updateSandbox(sandboxName, { dashboardPort: reusedPort2 }); + updateReusedSandboxMetadata( + sandboxName, + agent, + model, + provider, + reusedPort2, + !selectionDrift.unknown, + ); return sandboxName; } } @@ -3757,7 +3941,14 @@ async function createSandbox( } const reusedPort3 = ensureDashboardForward(sandboxName, chatUiUrl); process.env.CHAT_UI_URL = `http://127.0.0.1:${reusedPort3}`; - registry.updateSandbox(sandboxName, { dashboardPort: reusedPort3 }); + updateReusedSandboxMetadata( + sandboxName, + agent, + model, + provider, + reusedPort3, + !selectionDrift.unknown, + ); return sandboxName; } } catch (err) { @@ -3775,12 +3966,23 @@ async function createSandbox( } const reusedPort4 = ensureDashboardForward(sandboxName, chatUiUrl); process.env.CHAT_UI_URL = `http://127.0.0.1:${reusedPort4}`; - registry.updateSandbox(sandboxName, { dashboardPort: reusedPort4 }); + updateReusedSandboxMetadata( + sandboxName, + agent, + model, + provider, + reusedPort4, + !selectionDrift.unknown, + ); return sandboxName; } } - if (needsProviderMigration) { + if (recreateForAgentDrift) { + note( + ` Sandbox '${sandboxName}' exists as ${formatSandboxAgentName(agentDrift.existingAgentName)} — recreating as ${formatSandboxAgentName(agentDrift.requestedAgentName)}.`, + ); + } else if (needsProviderMigration) { console.log(` Sandbox '${sandboxName}' exists but messaging providers are not attached.`); console.log(" Recreating to ensure credentials flow through the provider pipeline."); } else if (confirmedSelectionDrift) { @@ -4213,7 +4415,9 @@ async function createSandbox( [ "sandbox", "exec", + "-n", sandboxName, + "--", "curl", "-sf", `http://localhost:${effectiveDashboardPort}/`, @@ -4257,7 +4461,6 @@ async function createSandbox( process.env.CHAT_UI_URL = chatUiUrl; // Register only after confirmed ready — prevents phantom entries - const effectiveAgent = agent || agentDefs.loadAgent("openclaw"); const providerCredentialHashes: Record = {}; for (const { envKey, token } of messagingTokenDefs) { const hash = token ? hashCredential(token) : null; @@ -4266,9 +4469,7 @@ async function createSandbox( } } // openshell tags images with seconds; buildId is ms. Parse actual tag from output. Fixes #2672. - const builtImageMatch = createResult.output.match( - /Built image (openshell\/sandbox-from:\d+)/, - ); + const builtImageMatch = createResult.output.match(/Built image (openshell\/sandbox-from:\d+)/); if (!builtImageMatch) { console.warn( " Warning: could not parse image tag from build output; imageTag may be stale. Run 'nemoclaw gc' if destroy fails.", @@ -4283,8 +4484,7 @@ async function createSandbox( model: model || null, provider: provider || null, gpuEnabled: !!gpu, - agent: agent ? agent.name : null, - agentVersion: fromDockerfile ? null : effectiveAgent.expectedVersion || null, + ...getSandboxAgentRegistryFields(agent, !fromDockerfile), imageTag: resolvedImageTag, providerCredentialHashes: Object.keys(providerCredentialHashes).length > 0 ? providerCredentialHashes : undefined, @@ -4292,6 +4492,7 @@ async function createSandbox( disabledChannels: disabledChannels.length > 0 ? [...disabledChannels] : undefined, dashboardPort: actualDashboardPort, }); + registry.setDefault(sandboxName); // Restore workspace state if we backed it up during credential rotation. if (pendingStateRestore?.success && pendingStateRestore.manifest) { @@ -6851,6 +7052,49 @@ function findDashboardForwardOwner( return portLine ? (portLine.split(/\s+/)[0] ?? null) : null; } +function findForwardEntry( + forwardListOutput: string | null | undefined, + port: string, +): { sandboxName: string; status: string } | null { + if (!forwardListOutput) return null; + for (const line of forwardListOutput.split("\n")) { + if (/^\s*SANDBOX\s/i.test(line)) continue; + const parts = line.trim().split(/\s+/); + if (parts.length < 3 || parts[2] !== port) continue; + return { + sandboxName: parts[0] || "", + status: (parts[4] || "").toLowerCase(), + }; + } + return null; +} + +function isLiveForwardStatus(status: string): boolean { + return status === "running" || status === "active"; +} + +function getRunningForwardPorts(forwardListOutput: string | null | undefined): string[] { + const ports = new Set(); + if (!forwardListOutput) return []; + for (const line of forwardListOutput.split("\n")) { + if (/^\s*SANDBOX\s/i.test(line)) continue; + const parts = line.trim().split(/\s+/); + if (parts.length < 5 || !/^\d+$/.test(parts[2])) continue; + const status = (parts[4] || "").toLowerCase(); + if (isLiveForwardStatus(status)) { + ports.add(parts[2]); + } + } + return [...ports]; +} + +function stopAllDashboardForwards(): void { + const forwardList = runCaptureOpenshell(["forward", "list"], { ignoreError: true }); + for (const port of getRunningForwardPorts(forwardList)) { + runOpenshell(["forward", "stop", port], { ignoreError: true }); + } +} + /** * Parse `openshell forward list` output into a Map. * Only includes running forwards — stopped/stale entries are ignored so @@ -6868,7 +7112,7 @@ function getOccupiedPorts(forwardListOutput: string | null): Map // parts: [sandbox, bind, port, pid, status...] if (parts.length < 3 || !/^\d+$/.test(parts[2])) continue; const status = (parts[4] || "").toLowerCase(); - if (status !== "running") continue; + if (!isLiveForwardStatus(status)) continue; occupied.set(parts[2], parts[0]); } return occupied; @@ -6974,7 +7218,15 @@ function ensureDashboardForward( ): number { const { rollbackSandboxOnFailure = false } = options; const preferredPort = Number(getDashboardForwardPort(chatUiUrl)); - const existingForwards = runCaptureOpenshell(["forward", "list"], { ignoreError: true }); + let existingForwards = runCaptureOpenshell(["forward", "list"], { ignoreError: true }); + const preferredEntry = findForwardEntry(existingForwards, String(preferredPort)); + if ( + preferredEntry && + (preferredEntry.sandboxName === sandboxName || !isLiveForwardStatus(preferredEntry.status)) + ) { + runOpenshell(["forward", "stop", String(preferredPort)], { ignoreError: true }); + existingForwards = runCaptureOpenshell(["forward", "list"], { ignoreError: true }); + } let actualPort: number; try { actualPort = findAvailableDashboardPort(sandboxName, preferredPort, existingForwards); @@ -7025,6 +7277,17 @@ function ensureDashboardForward( return actualPort; } +function ensureAgentDashboardForward( + sandboxName: string, + agent: { forwardPort?: number | null }, +): number { + const agentDashboardPort = agent.forwardPort ?? CONTROL_UI_PORT; + const agentDashboardUrl = `http://127.0.0.1:${agentDashboardPort}`; + const actualAgentDashboardPort = ensureDashboardForward(sandboxName, agentDashboardUrl); + process.env.CHAT_UI_URL = `http://127.0.0.1:${actualAgentDashboardPort}`; + return actualAgentDashboardPort; +} + function findOpenclawJsonPath(dir: string): string | null { if (!fs.existsSync(dir)) return null; const entries = fs.readdirSync(dir, { withFileTypes: true }); @@ -7737,6 +8000,18 @@ async function onboard(opts: OnboardOptions = {}): Promise { console.log( " Warning: could not verify gateway container state (Docker may be unavailable). Proceeding with cached health status.", ); + } else { + const imageDrift = getGatewayClusterImageDrift(); + if (imageDrift) { + console.log( + ` Gateway image ${imageDrift.currentVersion} does not match openshell ${imageDrift.expectedVersion}. Recreating...`, + ); + stopAllDashboardForwards(); + destroyGateway(); + registry.clearAll(); + gatewayReuseState = "missing"; + console.log(" ✓ Previous gateway cleaned up"); + } } } @@ -7839,7 +8114,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { // for provider/model above and sees this gate again with the new config. // See #2221 (CodeRabbit). if (!sandboxName) { - sandboxName = await promptValidatedSandboxName(); + sandboxName = await promptValidatedSandboxName(agent); } console.log( formatOnboardConfigSummary({ @@ -7971,7 +8246,12 @@ async function onboard(opts: OnboardOptions = {}): Promise { // Persist model and provider after the sandbox entry exists in the registry. // updateSandbox() silently no-ops when the entry is missing, so this must // run after createSandbox() / registerSandbox() — not before. Fixes #1881. - registry.updateSandbox(sandboxName, { model, provider }); + registry.updateSandbox(sandboxName, { + model, + provider, + ...getSandboxAgentRegistryFields(agent, !fromDockerfile), + }); + registry.setDefault(sandboxName); onboardSession.markStepComplete( "sandbox", toSessionUpdates({ sandboxName, provider, model, nimContainer, webSearchConfig }), @@ -7999,6 +8279,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { startRecordedStep, skippedStepMessage, }); + ensureAgentDashboardForward(sandboxName, agent); onboardSession.markStepSkipped("openclaw"); } else { const resumeOpenclaw = resume && sandboxName && isOpenclawReady(sandboxName); @@ -8070,6 +8351,10 @@ async function onboard(opts: OnboardOptions = {}): Promise { ); } + if (agent) { + ensureAgentDashboardForward(sandboxName, agent); + } + onboardSession.completeSession(toSessionUpdates({ sandboxName, provider, model })); completed = true; // Onboarding finished successfully. Delete the legacy plaintext @@ -8186,6 +8471,10 @@ module.exports = { upsertProvider, hashCredential, detectMessagingCredentialRotation, + getDefaultSandboxNameForAgent, + getSandboxPromptDefault, + getRequestedSandboxAgentName, + normalizeSandboxAgentName, hydrateCredentialEnv, pruneKnownHostsEntries, shouldIncludeBuildContextPath, diff --git a/src/lib/resolve-openshell.test.ts b/src/lib/resolve-openshell.test.ts index 15b90cb8f79..7d1d05a6dd0 100644 --- a/src/lib/resolve-openshell.test.ts +++ b/src/lib/resolve-openshell.test.ts @@ -2,6 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { resolveOpenshell } from "../../dist/lib/resolve-openshell"; describe("lib/resolve-openshell", () => { @@ -9,6 +12,24 @@ describe("lib/resolve-openshell", () => { expect(resolveOpenshell({ commandVResult: "/usr/bin/openshell" })).toBe("/usr/bin/openshell"); }); + it("prefers explicit installer override over command -v", () => { + const previous = process.env.NEMOCLAW_OPENSHELL_BIN; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-bin-")); + const override = path.join(tmp, "openshell"); + fs.writeFileSync(override, "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + + try { + process.env.NEMOCLAW_OPENSHELL_BIN = override; + expect(resolveOpenshell({ commandVResult: "/opt/homebrew/bin/openshell" })).toBe(override); + } finally { + if (previous === undefined) { + delete process.env.NEMOCLAW_OPENSHELL_BIN; + } else { + process.env.NEMOCLAW_OPENSHELL_BIN = previous; + } + } + }); + it("rejects non-absolute command -v result (alias)", () => { expect( resolveOpenshell({ commandVResult: "openshell", checkExecutable: () => false }), @@ -81,5 +102,4 @@ describe("lib/resolve-openshell", () => { }), ).toBeNull(); }); - }); diff --git a/src/lib/resolve-openshell.ts b/src/lib/resolve-openshell.ts index b55fbfac84f..4e71900d9fd 100644 --- a/src/lib/resolve-openshell.ts +++ b/src/lib/resolve-openshell.ts @@ -21,6 +21,21 @@ export interface ResolveOpenshellOptions { */ export function resolveOpenshell(opts: ResolveOpenshellOptions = {}): string | null { const home = opts.home ?? process.env.HOME; + const checkExecutable = + opts.checkExecutable ?? + ((p: string): boolean => { + try { + accessSync(p, constants.X_OK); + return true; + } catch { + return false; + } + }); + + const override = process.env.NEMOCLAW_OPENSHELL_BIN; + if (override?.startsWith("/") && checkExecutable(override)) { + return override; + } // Step 1: command -v if (opts.commandVResult === undefined) { @@ -35,17 +50,6 @@ export function resolveOpenshell(opts: ResolveOpenshellOptions = {}): string | n } // Step 2: fallback candidates - const checkExecutable = - opts.checkExecutable ?? - ((p: string): boolean => { - try { - accessSync(p, constants.X_OK); - return true; - } catch { - return false; - } - }); - const candidates = [ ...(home?.startsWith("/") ? [`${home}/.local/bin/openshell`] : []), "/usr/local/bin/openshell", diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 28a194df478..7c6b9aafe82 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -4275,6 +4275,54 @@ function help() { console.log(lines.join("\n")); } +function editDistance(left: string, right: string): number { + const rows = left.length + 1; + const cols = right.length + 1; + const matrix: number[][] = Array.from({ length: rows }, () => Array(cols).fill(0)); + for (let i = 0; i < rows; i++) matrix[i][0] = i; + for (let j = 0; j < cols; j++) matrix[0][j] = j; + for (let i = 1; i < rows; i++) { + for (let j = 1; j < cols; j++) { + const cost = left[i - 1] === right[j - 1] ? 0 : 1; + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j - 1] + cost, + ); + } + } + return matrix[left.length][right.length]; +} + +function suggestGlobalCommand(token: string): string | null { + let best: { command: string; distance: number } | null = null; + for (const command of GLOBAL_COMMANDS) { + if (command.startsWith("-")) continue; + const distance = editDistance(token, command); + if (!best || distance < best.distance) { + best = { command, distance }; + } + } + if (!best) return null; + if (best.distance <= 1) return best.command; + if (token.length >= 5 && best.distance <= 2) return best.command; + return null; +} + +function findRegisteredSandboxName(tokens: string[]): string | null { + const registered = new Set( + registry.listSandboxes().sandboxes.map((s: { name: string }) => s.name), + ); + return tokens.find((token) => registered.has(token)) || null; +} + +function printConnectOrderHint(candidate: string | null): void { + console.error(` Command order is: ${CLI_NAME} connect`); + if (candidate) { + console.error(` Did you mean: ${CLI_NAME} ${candidate} connect?`); + } +} + // ── Dispatch ───────────────────────────────────────────────────── const [cmd, ...args] = process.argv.slice(2); @@ -4364,22 +4412,47 @@ const [cmd, ...args] = process.argv.slice(2); // command, attempt recovery — the sandbox may still be live with a stale registry. // Derived from command registry — single source of truth const sandboxActions = sandboxActionTokens(); - if (!registry.getSandbox(cmd) && sandboxActions.includes(args[0] || "")) { + const requestedSandboxAction = args[0] || "connect"; + if (!registry.getSandbox(cmd) && sandboxActions.includes(requestedSandboxAction)) { validateName(cmd, "sandbox name"); await recoverRegistryEntries({ requestedSandboxName: cmd }); if (!registry.getSandbox(cmd)) { + if (args.length === 0) { + const suggestion = suggestGlobalCommand(cmd); + if (suggestion) { + console.error(` Unknown command: ${cmd}`); + console.error(` Did you mean: ${CLI_NAME} ${suggestion}?`); + process.exit(1); + } + } console.error(` Sandbox '${cmd}' does not exist.`); const allNames = registry.listSandboxes().sandboxes.map((s: { name: string }) => s.name); if (allNames.length > 0) { console.error(""); console.error(` Registered sandboxes: ${allNames.join(", ")}`); console.error(` Run '${CLI_NAME} list' to see all sandboxes.`); + const reorderedCandidate = + args[0] === "connect" ? findRegisteredSandboxName(args.slice(1)) : null; + if (reorderedCandidate) { + console.error(""); + printConnectOrderHint(reorderedCandidate); + } } else { console.error(` Run '${CLI_NAME} onboard' to create one.`); } process.exit(1); } } + + if (!registry.getSandbox(cmd)) { + const suggestion = suggestGlobalCommand(cmd); + if (suggestion) { + console.error(` Unknown command: ${cmd}`); + console.error(` Did you mean: ${CLI_NAME} ${suggestion}?`); + process.exit(1); + } + } + const sandbox = registry.getSandbox(cmd); if (sandbox) { validateName(cmd, "sandbox name"); @@ -4397,6 +4470,10 @@ const [cmd, ...args] = process.argv.slice(2); " --dangerously-skip-permissions was removed; use shields commands instead.", ); } + const reorderedCandidate = findRegisteredSandboxName(actionArgs); + if (reorderedCandidate) { + printConnectOrderHint(reorderedCandidate); + } console.error(` Usage: ${CLI_NAME} connect`); process.exit(1); } diff --git a/test/cli.test.ts b/test/cli.test.ts index 16a47416fa2..d4ae146b6ae 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -187,8 +187,13 @@ function createDebugCommandTestEnv(prefix: string): Record { describe("CLI dispatch", () => { it("config get validates flags and values before dispatch", () => { - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "src", "nemoclaw.ts"), "utf-8"); - const configGet = src.match(/case "get": \{([\s\S]*?)sandboxConfig\.configGet\(cmd, configOpts\);/); + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "nemoclaw.ts"), + "utf-8", + ); + const configGet = src.match( + /case "get": \{([\s\S]*?)sandboxConfig\.configGet\(cmd, configOpts\);/, + ); expect(configGet).toBeTruthy(); expect(configGet![1]).toContain("--key requires a value"); expect(configGet![1]).toContain("--format requires a value"); @@ -235,6 +240,69 @@ describe("CLI dispatch", () => { expect(r.out.includes("Unknown command")).toBeTruthy(); }); + it("suggests list for a mistyped list command", () => { + const r = run("liost"); + expect(r.code).toBe(1); + expect(r.out).toContain("Unknown command: liost"); + expect(r.out).toContain("Did you mean: nemoclaw list?"); + }); + + it("recovers a live sandbox before suggesting a bare command typo", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-recover-typo-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'printf "%s\\n" "$*" >> "$HOME/openshell-calls.log"', + 'case "$*" in', + ' "status") printf "Status: Connected\\nGateway: nemoclaw\\n"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ' "sandbox list") echo "liost Ready"; exit 0 ;;', + ' "sandbox get liost") printf "Name: liost\\nPhase: Ready\\nPolicy:\\n"; exit 0 ;;', + ' "policy get --full liost") exit 1 ;;', + ' "inference get") exit 1 ;;', + ' "sandbox connect liost") echo "CONNECTED_LIOST"; exit 0 ;;', + " *) exit 0 ;;", + "esac", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("liost", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NEMOCLAW_CONNECT_TIMEOUT: "1", + NEMOCLAW_NO_CONNECT_HINT: "1", + }); + expect(r.code).toBe(0); + expect(r.out).toContain("CONNECTED_LIOST"); + expect(r.out).not.toContain("Unknown command: liost"); + }); + + it("explains sandbox connect command order when the sandbox name is last", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-connect-order-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + fs.writeFileSync( + path.join(localBin, "openshell"), + ["#!/usr/bin/env bash", "exit 1"].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("hermes connect alpha", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(1); + expect(r.out).toContain("Sandbox 'hermes' does not exist"); + expect(r.out).toContain("Command order is: nemoclaw connect"); + expect(r.out).toContain("Did you mean: nemoclaw alpha connect?"); + }); + it("list exits 0", () => { const r = run("list"); expect(r.code).toBe(0); @@ -2928,11 +2996,11 @@ describe("list shows live gateway inference", () => { expect(r.code).toBe(0); // Live gateway values render on the default sandbox's main row. expect(r.out).toContain( - "model: nvidia/nemotron-3-super-120b-a12b provider: nvidia-prod GPU policies: pypi, npm", + "agent: openclaw model: nvidia/nemotron-3-super-120b-a12b provider: nvidia-prod GPU policies: pypi, npm", ); // The stale (stored) row must not appear. expect(r.out).not.toContain( - "model: configured-model provider: configured-provider GPU policies: pypi, npm", + "agent: openclaw model: configured-model provider: configured-provider GPU policies: pypi, npm", ); // Onboarded values appear in the drift annotation. expect(r.out).toContain("(onboarded: model=configured-model, provider=configured-provider)"); diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index f8de2697de1..a86d03ab53b 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -1795,6 +1795,18 @@ describe("installer pure helpers", () => { }); } + function callInstallerPayloadFn(fnCall: string, env: Record = {}) { + return spawnSync("bash", ["-c", `source "${INSTALLER_PAYLOAD}" 2>/dev/null; ${fnCall}`], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { + HOME: os.tmpdir(), + PATH: TEST_SYSTEM_PATH, + ...env, + }, + }); + } + it("verify_nemoclaw checks the active CLI alias", () => { const script = fs.readFileSync(INSTALLER_PAYLOAD, "utf-8"); const body = requireMatch( @@ -2061,6 +2073,79 @@ exit 1 expect(r.stdout).toBe(" v0.0.21"); }); + it("agent_display_name: formats Hermes without Bash 4 uppercase expansion", () => { + const source = fs.readFileSync(INSTALLER_PAYLOAD, "utf-8"); + expect(source).not.toContain("${NEMOCLAW_AGENT^}"); + expect(source).not.toContain("${agent_name^}"); + expect(source).not.toContain("${agent_display_name"); + + const r = callInstallerPayloadFn("agent_display_name hermes"); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe("Hermes"); + }); + + it("prefer_user_local_openshell: exports the freshly installed OpenShell path", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-path-")); + const localBin = path.join(tmp, ".local", "bin"); + const openshell = path.join(localBin, "openshell"); + fs.mkdirSync(localBin, { recursive: true }); + writeExecutable(openshell, "#!/usr/bin/env bash\nexit 0\n"); + + const r = callInstallerPayloadFn( + 'prefer_user_local_openshell; printf "%s\\n%s\\n" "$NEMOCLAW_OPENSHELL_BIN" "$PATH"', + { + HOME: tmp, + PATH: "/opt/homebrew/bin:/usr/bin:/bin", + }, + ); + const [resolved, pathValue] = r.stdout.trim().split("\n"); + expect(r.status).toBe(0); + expect(resolved).toBe(openshell); + expect(pathValue.startsWith(`${localBin}:`)).toBe(true); + }); + + it("restore_onboard_forward_after_post_checks: restores Hermes forward from session", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemohermes-forward-restore-")); + const fakeBin = path.join(tmp, "bin"); + const stateDir = path.join(tmp, ".nemoclaw"); + const openshellLog = path.join(tmp, "openshell.log"); + fs.mkdirSync(fakeBin, { recursive: true }); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "onboard-session.json"), + JSON.stringify({ sandboxName: "created-by-onboard", agent: "hermes" }), + ); + writeExecutable( + path.join(fakeBin, "openshell"), + `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$OPENSHELL_LOG" +if [ "$1" = "forward" ] && [ "$2" = "list" ]; then + echo "SANDBOX BIND PORT PID STATUS" + echo "created-by-onboard 127.0.0.1 8642 123 running" +fi +exit 0 +`, + ); + writeExecutable( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +exit 0 +`, + ); + + const r = callInstallerPayloadFn("restore_onboard_forward_after_post_checks", { + HOME: tmp, + NEMOCLAW_SKIP_FORWARD_WATCHER: "1", + OPENSHELL_LOG: openshellLog, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }); + + expect(r.status).toBe(0); + const openshellCalls = fs.readFileSync(openshellLog, "utf-8"); + expect(openshellCalls).toContain("forward stop 8642 created-by-onboard"); + expect(openshellCalls).toContain("forward start --background 8642 created-by-onboard"); + }); + // -- resolve_default_sandbox_name -- it("resolve_default_sandbox_name: returns 'my-assistant' with no registry", () => { @@ -2069,6 +2154,15 @@ exit 1 expect(r.stdout.trim()).toBe("my-assistant"); }); + it("resolve_default_sandbox_name: defaults to 'hermes' for NemoHermes with no state", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemohermes-sandbox-name-")); + const r = callInstallerFn("resolve_default_sandbox_name", { + HOME: tmp, + NEMOCLAW_AGENT: "hermes", + }); + expect(r.stdout.trim()).toBe("hermes"); + }); + it("resolve_default_sandbox_name: reads defaultSandbox from registry", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sandbox-name-reg-")); const registryDir = path.join(tmp, ".nemoclaw"); @@ -2095,6 +2189,52 @@ exit 1 }); expect(r.stdout.trim()).toBe("my-custom-name"); }); + + it("resolve_default_sandbox_name: current onboard session wins over env and registry", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sandbox-name-session-")); + const registryDir = path.join(tmp, ".nemoclaw"); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "onboard-session.json"), + JSON.stringify({ sandboxName: "created-by-onboard" }), + ); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "old-default", + sandboxes: { "old-default": {} }, + }), + ); + const r = callInstallerFn("resolve_default_sandbox_name", { + HOME: tmp, + NEMOCLAW_SANDBOX_NAME: "env-name", + PATH: `${process.env.PATH}`, + }); + expect(r.stdout.trim()).toBe("created-by-onboard"); + }); + + it("resolve_default_sandbox_name: payload session lookup wins even when node is absent", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sandbox-name-payload-session-")); + const registryDir = path.join(tmp, ".nemoclaw"); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "onboard-session.json"), + `${JSON.stringify({ sandboxName: "created-by-onboard" }, null, 2)}\n`, + ); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "old-default", + sandboxes: { "old-default": {} }, + }), + ); + const r = callInstallerPayloadFn("resolve_default_sandbox_name", { + HOME: tmp, + NEMOCLAW_SANDBOX_NAME: "env-name", + }); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe("created-by-onboard"); + }); }); // --------------------------------------------------------------------------- diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 4694947d891..a127751f2dd 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -65,6 +65,10 @@ type OnboardTestInternals = { getRequestedModelHint: ShimFn; getRequestedProviderHint: ShimFn; getRequestedSandboxNameHint: ShimFn; + getDefaultSandboxNameForAgent: (agent?: AgentDefinition | null) => string; + getSandboxPromptDefault: (agent?: AgentDefinition | null) => string; + getRequestedSandboxAgentName: (agent?: AgentDefinition | null) => string; + normalizeSandboxAgentName: (agentName?: string | null) => string; getResumeConfigConflicts: ShimFn; getResumeSandboxConflict: ShimFn<{ requestedSandboxName: string; @@ -117,6 +121,10 @@ function isOnboardTestInternals( value !== null && typeof value.buildProviderArgs === "function" && typeof value.classifySandboxCreateFailure === "function" && + typeof value.getDefaultSandboxNameForAgent === "function" && + typeof value.getSandboxPromptDefault === "function" && + typeof value.getRequestedSandboxAgentName === "function" && + typeof value.normalizeSandboxAgentName === "function" && typeof value.agentSupportsWebSearch === "function" && typeof value.configureWebSearch === "function" && typeof value.writeSandboxConfigSyncFile === "function" @@ -151,6 +159,10 @@ const { getRequestedModelHint, getRequestedProviderHint, getRequestedSandboxNameHint, + getDefaultSandboxNameForAgent, + getSandboxPromptDefault, + getRequestedSandboxAgentName, + normalizeSandboxAgentName, getResumeConfigConflicts, getResumeSandboxConflict, getSandboxStateFromOutputs, @@ -177,6 +189,28 @@ const { } = onboardTestInternals; describe("onboard helpers", () => { + it("uses Hermes-oriented sandbox defaults when NemoHermes selects Hermes", () => { + const previousSandboxName = process.env.NEMOCLAW_SANDBOX_NAME; + try { + delete process.env.NEMOCLAW_SANDBOX_NAME; + const hermes = loadAgent("hermes"); + expect(getRequestedSandboxAgentName(null)).toBe("openclaw"); + expect(normalizeSandboxAgentName(null)).toBe("openclaw"); + expect(getDefaultSandboxNameForAgent(null)).toBe("my-assistant"); + expect(getDefaultSandboxNameForAgent(hermes)).toBe("hermes"); + expect(getSandboxPromptDefault(hermes)).toBe("hermes"); + + process.env.NEMOCLAW_SANDBOX_NAME = "custom-hermes"; + expect(getSandboxPromptDefault(hermes)).toBe("hermes"); + } finally { + if (previousSandboxName === undefined) { + delete process.env.NEMOCLAW_SANDBOX_NAME; + } else { + process.env.NEMOCLAW_SANDBOX_NAME = previousSandboxName; + } + } + }); + it("classifies sandbox create timeout failures and tracks upload progress", () => { expect( classifySandboxCreateFailure("Error: failed to read image export stream\nTimeout error").kind, @@ -2629,6 +2663,63 @@ const { setupInference } = require(${onboardPath}); assert.match(source, /setupOpenclaw[\s\S]*?markStepSkipped\("agent_setup"\)/); }); + it("uses named sandbox exec for dashboard and web-search probes", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), + "utf-8", + ); + + assert.match(source, /"sandbox",\s*"exec",\s*"-n",\s*sandboxName,\s*"--",\s*"curl"/); + assert.match(source, /"sandbox",\s*"exec",\s*"-n",\s*sandboxName,\s*"--",\s*"hermes"/); + assert.match(source, /"sandbox",\s*"exec",\s*"-n",\s*sandboxName,\s*"--",\s*"cat"/); + assert.doesNotMatch(source, /\["sandbox",\s*"exec",\s*sandboxName,\s*"curl"/); + assert.doesNotMatch(source, /\["sandbox",\s*"exec",\s*sandboxName,\s*"hermes"/); + assert.doesNotMatch(source, /\["sandbox",\s*"exec",\s*sandboxName,\s*"cat"/); + }); + + it("re-establishes the agent dashboard forward after agent setup health checks", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), + "utf-8", + ); + const setupPos = source.indexOf("await agentOnboard.handleAgentSetup"); + const forwardPos = source.indexOf("ensureAgentDashboardForward(sandboxName, agent)", setupPos); + + assert.ok(setupPos !== -1, "agent setup call not found"); + assert.ok( + forwardPos > setupPos, + "agent dashboard forward should be re-established after agent health checks", + ); + }); + + it("re-establishes the agent dashboard forward after policies are applied", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), + "utf-8", + ); + const policiesPos = source.indexOf("await setupPoliciesWithSelection"); + const completePoliciesPos = source.indexOf( + 'onboardSession.markStepComplete(\n "policies"', + policiesPos, + ); + const forwardPos = source.indexOf( + "ensureAgentDashboardForward(sandboxName, agent)", + completePoliciesPos, + ); + const completeSessionPos = source.indexOf( + "onboardSession.completeSession", + completePoliciesPos, + ); + + assert.ok(policiesPos !== -1, "policy setup call not found"); + assert.ok(completePoliciesPos !== -1, "policy completion call not found"); + assert.ok(forwardPos > completePoliciesPos, "agent forward should be reset after policy setup"); + assert.ok( + forwardPos < completeSessionPos, + "agent forward should be reset before onboarding is marked complete", + ); + }); + it("starts the sandbox step before prompting for the sandbox name", () => { const source = fs.readFileSync( path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), @@ -2903,6 +2994,9 @@ const childProcess = require("node:child_process"); const { EventEmitter } = require("node:events"); const commands = []; +const registerCalls = []; +const updateCalls = []; +const defaultCalls = []; runner.run = (command, opts = {}) => { commands.push({ command: _n(command), env: opts.env || null }); return { status: 0 }; @@ -2910,11 +3004,22 @@ runner.run = (command, opts = {}) => { runner.runCapture = (command) => { if (_n(command).includes("sandbox get my-assistant")) return ""; if (_n(command).includes("sandbox list")) return "my-assistant Ready"; - if (_n(command).includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if (_n(command).includes("sandbox exec -n my-assistant -- curl -sf http://localhost:18789/")) return "ok"; if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; return ""; }; -registry.registerSandbox = () => true; +registry.registerSandbox = (entry) => { + registerCalls.push(entry); + return true; +}; +registry.updateSandbox = (name, updates) => { + updateCalls.push({ name, updates }); + return true; +}; +registry.setDefault = (name) => { + defaultCalls.push(name); + return true; +}; registry.removeSandbox = () => true; preflight.checkPortAvailable = async () => ({ ok: true }); credentials.prompt = async () => ""; @@ -2936,7 +3041,7 @@ const { createSandbox } = require(${onboardPath}); (async () => { process.env.OPENSHELL_GATEWAY = "nemoclaw"; const sandboxName = await createSandbox(null, "gpt-5.4"); - console.log(JSON.stringify({ sandboxName, commands })); + console.log(JSON.stringify({ sandboxName, commands, registerCalls, updateCalls, defaultCalls })); })().catch((error) => { console.error(error); process.exit(1); @@ -2965,6 +3070,23 @@ const { createSandbox } = require(${onboardPath}); assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); const payload = JSON.parse(payloadLine); assert.equal(payload.sandboxName, "my-assistant"); + assert.deepEqual(payload.defaultCalls, ["my-assistant"]); + assert.ok( + payload.registerCalls.some( + (entry: Record) => + entry.name === "my-assistant" && + entry.model === "gpt-5.4" && + Object.prototype.hasOwnProperty.call(entry, "agentVersion"), + ), + "expected registry metadata for created sandbox", + ); + assert.ok( + payload.updateCalls.every( + (call: { name: string; updates: Record }) => + call.name === "my-assistant" && call.updates, + ), + "expected any registry metadata updates to target the created sandbox", + ); const createCommand = payload.commands.find((entry: CommandEntry) => entry.command.includes("sandbox create"), ); @@ -3019,11 +3141,13 @@ runner.run = (command, opts = {}) => { runner.runCapture = (command) => { if (_n(command).includes("sandbox get my-assistant")) return ""; if (_n(command).includes("sandbox list")) return "my-assistant Ready"; - if (_n(command).includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if (_n(command).includes("sandbox exec -n my-assistant -- curl -sf http://localhost:18789/")) return "ok"; if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; return ""; }; registry.registerSandbox = () => true; +registry.updateSandbox = () => true; +registry.setDefault = () => true; registry.removeSandbox = () => true; preflight.checkPortAvailable = async () => ({ ok: true }); credentials.prompt = async () => ""; @@ -3113,11 +3237,13 @@ runner.runCapture = (command) => { if (_n(command).includes("sandbox get my-assistant")) return ""; if (_n(command).includes("sandbox list")) return "my-assistant Ready"; // Custom port: dashboard readiness curl uses 19000 (DASHBOARD_PORT from env) - if (_n(command).includes("sandbox exec my-assistant curl -sf http://localhost:19000/")) return "ok"; + if (_n(command).includes("sandbox exec -n my-assistant -- curl -sf http://localhost:19000/")) return "ok"; if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 19000 12345 running"; return ""; }; registry.registerSandbox = () => true; +registry.updateSandbox = () => true; +registry.setDefault = () => true; registry.removeSandbox = () => true; preflight.checkPortAvailable = async () => ({ ok: true }); credentials.prompt = async () => ""; @@ -3247,6 +3373,8 @@ runner.runCapture = (command) => { return ""; }; registry.registerSandbox = () => true; +registry.updateSandbox = () => true; +registry.setDefault = () => true; registry.removeSandbox = () => true; preflight.checkPortAvailable = async () => ({ ok: true }); credentials.prompt = async () => ""; @@ -3428,6 +3556,8 @@ runner.runCapture = (command) => { return ""; }; registry.registerSandbox = () => true; +registry.updateSandbox = () => true; +registry.setDefault = () => true; registry.removeSandbox = () => true; preflight.checkPortAvailable = async () => ({ ok: true }); credentials.prompt = async () => ""; @@ -3687,6 +3817,8 @@ runner.runCapture = (command) => { }; registry.getSandbox = () => ({ name: "my-assistant", gpuEnabled: false }); registry.registerSandbox = () => true; +registry.updateSandbox = () => true; +registry.setDefault = () => true; registry.removeSandbox = () => true; const preflight = require(${JSON.stringify(path.join(repoRoot, "dist", "lib", "preflight.js"))}); @@ -3801,6 +3933,8 @@ registry.getSandbox = () => ({ policyTier: "balanced", }); registry.registerSandbox = () => true; +registry.updateSandbox = () => true; +registry.setDefault = () => true; registry.removeSandbox = () => true; const preflight = require(${JSON.stringify(path.join(repoRoot, "dist", "lib", "preflight.js"))}); @@ -4055,6 +4189,8 @@ runner.runCapture = (command) => { }; registry.getSandbox = () => ({ name: "my-assistant", gpuEnabled: false }); registry.registerSandbox = () => true; +registry.updateSandbox = () => true; +registry.setDefault = () => true; registry.removeSandbox = () => true; const preflight = require(${JSON.stringify(path.join(repoRoot, "dist", "lib", "preflight.js"))}); @@ -4178,6 +4314,8 @@ runner.runCapture = (command) => { }; registry.getSandbox = () => ({ name: "my-assistant", gpuEnabled: false }); registry.registerSandbox = () => true; +registry.updateSandbox = () => true; +registry.setDefault = () => true; registry.removeSandbox = () => true; const preflight = require(${JSON.stringify(path.join(repoRoot, "dist", "lib", "preflight.js"))}); @@ -4660,11 +4798,13 @@ runner.runCapture = (command) => { sandboxListCalls += 1; return sandboxListCalls >= 2 ? "my-assistant Ready" : "my-assistant Pending"; } - if (_n(command).includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if (_n(command).includes("sandbox exec -n my-assistant -- curl -sf http://localhost:18789/")) return "ok"; if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; return ""; }; registry.registerSandbox = () => true; +registry.updateSandbox = () => true; +registry.setDefault = () => true; registry.removeSandbox = () => true; preflight.checkPortAvailable = async () => ({ ok: true }); credentials.prompt = async () => ""; @@ -5053,11 +5193,13 @@ runner.run = (command, opts = {}) => { runner.runCapture = (command) => { if (_n(command).includes("sandbox get my-assistant")) return ""; if (_n(command).includes("sandbox list")) return "my-assistant Ready"; - if (_n(command).includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if (_n(command).includes("sandbox exec -n my-assistant -- curl -sf http://localhost:18789/")) return "ok"; if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; return ""; }; registry.registerSandbox = () => true; +registry.updateSandbox = () => true; +registry.setDefault = () => true; registry.removeSandbox = () => true; preflight.checkPortAvailable = async () => ({ ok: true }); credentials.prompt = async () => ""; @@ -5183,11 +5325,13 @@ runner.run = (command, opts = {}) => { runner.runCapture = (command) => { if (_n(command).includes("sandbox get my-assistant")) return ""; if (_n(command).includes("sandbox list")) return "my-assistant Ready"; - if (_n(command).includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if (_n(command).includes("sandbox exec -n my-assistant -- curl -sf http://localhost:18789/")) return "ok"; if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; return ""; }; registry.registerSandbox = () => true; +registry.updateSandbox = () => true; +registry.setDefault = () => true; registry.removeSandbox = () => true; preflight.checkPortAvailable = async () => ({ ok: true }); credentials.prompt = async () => ""; @@ -5754,7 +5898,10 @@ const { setupMessagingChannels, MESSAGING_CHANNELS } = require(${onboardPath}); fs.mkdirSync(path.join(customBuildDir, "secrets"), { recursive: true }); fs.writeFileSync(path.join(customBuildDir, "secrets", "token.txt"), "fake test token"); fs.writeFileSync(path.join(customBuildDir, ".env.local"), "EXAMPLE=fake"); - fs.writeFileSync(path.join(customBuildDir, ".npmrc"), "registry=https://registry.example.test\n"); + fs.writeFileSync( + path.join(customBuildDir, ".npmrc"), + "registry=https://registry.example.test\n", + ); fs.writeFileSync(path.join(customBuildDir, "model.pem"), "fake test certificate"); fs.writeFileSync(path.join(customBuildDir, "credentials.json"), "{}"); @@ -5795,11 +5942,13 @@ runner.run = (command, opts = {}) => { runner.runCapture = (command) => { if (_n(command).includes("sandbox get my-assistant")) return ""; if (_n(command).includes("sandbox list")) return "my-assistant Ready"; - if (_n(command).includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if (_n(command).includes("sandbox exec -n my-assistant -- curl -sf http://localhost:18789/")) return "ok"; if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; return ""; }; registry.registerSandbox = () => true; +registry.updateSandbox = () => true; +registry.setDefault = () => true; registry.removeSandbox = () => true; preflight.checkPortAvailable = async () => ({ ok: true }); credentials.prompt = async () => ""; @@ -5917,6 +6066,8 @@ const credentials = require(${credentialsPath}); runner.run = () => ({ status: 0 }); runner.runCapture = () => ""; registry.registerSandbox = () => true; +registry.updateSandbox = () => true; +registry.setDefault = () => true; registry.removeSandbox = () => true; preflight.checkPortAvailable = async () => ({ ok: true }); credentials.prompt = async () => ""; @@ -5975,6 +6126,8 @@ const credentials = require(${credentialsPath}); runner.run = () => ({ status: 0 }); runner.runCapture = () => ""; registry.registerSandbox = () => true; +registry.updateSandbox = () => true; +registry.setDefault = () => true; registry.removeSandbox = () => true; preflight.checkPortAvailable = async () => ({ ok: true }); credentials.prompt = async () => ""; @@ -6036,6 +6189,8 @@ const credentials = require(${credentialsPath}); runner.run = () => ({ status: 0 }); runner.runCapture = () => ""; registry.registerSandbox = () => true; +registry.updateSandbox = () => true; +registry.setDefault = () => true; registry.removeSandbox = () => true; preflight.checkPortAvailable = async () => ({ ok: true }); credentials.prompt = async () => ""; @@ -6118,6 +6273,8 @@ fs.cpSync = (src, dest, options) => { runner.run = () => ({ status: 0 }); runner.runCapture = () => ""; registry.registerSandbox = () => true; +registry.updateSandbox = () => true; +registry.setDefault = () => true; registry.removeSandbox = () => true; preflight.checkPortAvailable = async () => ({ ok: true }); credentials.prompt = async () => ""; @@ -6165,7 +6322,7 @@ const { createSandbox } = require(${onboardPath}); ); // Extract the promptValidatedSandboxName function body const fnMatch = source.match( - /async function promptValidatedSandboxName\(\)\s*\{([\s\S]*?)\n\}/, + /async function promptValidatedSandboxName\([^)]*\)\s*\{([\s\S]*?)\n\}/, ); assert.ok(fnMatch, "promptValidatedSandboxName function not found"); const fnBody = fnMatch[1]; @@ -6180,6 +6337,28 @@ const { createSandbox } = require(${onboardPath}); assert.match(fnBody, /process\.exit\(1\)/); }); + it("guards against reusing the same sandbox name for a different agent", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), + "utf-8", + ); + assert.match(source, /getSandboxAgentDrift/); + assert.match( + source, + /Side-by-side agents are supported, but each sandbox name has one agent type/, + ); + assert.match(source, /UNKNOWN_SANDBOX_AGENT_NAME/); + assert.match(source, /if \(!existingEntry\) \{[\s\S]*?changed: true/); + assert.match(source, /recreateForAgentDrift/); + assert.match(source, /getSandboxAgentRegistryFields/); + assert.match(source, /getSandboxAgentRegistryFields\(agent, agentVersionKnown\)/); + assert.match( + source, + /const existingEntry = registry\.getSandbox\(sandboxName\)[\s\S]*?existingEntry\?\.agentVersion !== null/, + ); + assert.match(source, /registry\.setDefault\(sandboxName\)/); + }); + it("regression #1881: registry.updateSandbox(model/provider) is called AFTER createSandbox", () => { // updateSandbox() silently no-ops when the entry does not exist yet. // This asserts that the model/provider update comes AFTER createSandbox() @@ -6191,7 +6370,7 @@ const { createSandbox } = require(${onboardPath}); const createSandboxPos = source.indexOf("sandboxName = await createSandbox("); assert.ok(createSandboxPos !== -1, "createSandbox call not found in onboard.ts"); const updateAfterCreate = source.indexOf( - "registry.updateSandbox(sandboxName, { model, provider })", + "registry.updateSandbox(sandboxName, {", createSandboxPos, ); assert.ok( @@ -6487,6 +6666,25 @@ const { createSandbox } = require(${onboardPath}); assert.equal(findDashboardForwardOwner(falsePositive, "18789"), null); }); + it("ensureDashboardForward clears stale preferred-port forwards before reallocating", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), + "utf-8", + ); + + assert.match(source, /const preferredEntry = findForwardEntry/); + assert.match(source, /function isLiveForwardStatus/); + assert.match(source, /!isLiveForwardStatus\(preferredEntry\.status\)/); + assert.match( + source, + /runOpenshell\(\["forward", "stop", String\(preferredPort\)\], \{ ignoreError: true \}\)/, + ); + assert.match( + source, + /findAvailableDashboardPort\(sandboxName, preferredPort, existingForwards\)/, + ); + }); + it("formatOnboardConfigSummary renders all collected fields (#2165)", () => { const summary = formatOnboardConfigSummary({ provider: "gemini-api", diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 99a6f3c5d59..cbe72a22004 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -20,6 +20,7 @@ const ROOT = path.resolve(import.meta.dirname, ".."); const DOCKERFILE = path.join(ROOT, "Dockerfile"); const DOCKERFILE_BASE = path.join(ROOT, "Dockerfile.base"); const DOCKERFILE_SANDBOX = path.join(ROOT, "test", "Dockerfile.sandbox"); +const HERMES_DOCKERFILE = path.join(ROOT, "agents", "hermes", "Dockerfile"); describe("sandbox provisioning: unified .openclaw layout (#2227)", () => { const src = fs.readFileSync(DOCKERFILE_BASE, "utf-8"); @@ -68,6 +69,17 @@ describe("sandbox provisioning: procps debug tools (#2343)", () => { }); }); +describe("Hermes sandbox provisioning", () => { + const src = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); + + it("final image validates the manifest-declared hermes binary path", () => { + expect(src).toContain('hermes_path="$(command -v hermes 2>/dev/null || true)"'); + expect(src).toContain('[ "$hermes_path" != "/usr/local/bin/hermes" ]'); + expect(src).toContain("test -x /usr/local/bin/hermes"); + expect(src).toContain("/usr/local/bin/hermes --version"); + }); +}); + describe("sandbox provisioning: gateway auth token externalization (#2378)", () => { const src = fs.readFileSync(DOCKERFILE, "utf-8");