From d33f5c55f9a1b317e9e00ff27fe7587ff18dc041 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 15:30:39 -0700 Subject: [PATCH] fix nemohermes first-run onboarding --- scripts/install.sh | 14 ++- src/lib/agent-onboard.test.ts | 14 +++ src/lib/agent-onboard.ts | 52 +++++++++- src/lib/inventory-commands.test.ts | 42 ++++++-- src/lib/inventory-commands.ts | 5 +- src/lib/onboard.ts | 161 +++++++++++++++++++++++++---- src/nemoclaw.ts | 67 ++++++++++++ test/cli.test.ts | 42 +++++++- test/install-preflight.test.ts | 32 ++++++ test/onboard.test.ts | 83 ++++++++++++++- 10 files changed, 472 insertions(+), 40 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 871bf91eae0..8ff554f8492 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -172,13 +172,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"); @@ -191,6 +191,10 @@ resolve_default_sandbox_name() { )" 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="$( node -e ' @@ -207,7 +211,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() { diff --git a/src/lib/agent-onboard.test.ts b/src/lib/agent-onboard.test.ts index c7488573e1b..96c4cba8dc9 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,15 @@ 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("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"); + }); +}); diff --git a/src/lib/agent-onboard.ts b/src/lib/agent-onboard.ts index efe5151a1d7..a7f786ad3e1 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,40 @@ 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); +} + +function verifyAgentBinaryAvailable( + sandboxName: string, + agent: AgentDefinition, + runCaptureOpenshell: OnboardContext["runCaptureOpenshell"], +): boolean { + const executable = agentExecutableName(agent); + const binaryPath = typeof agent.binary_path === "string" ? agent.binary_path.trim() : ""; + const script = [ + `command -v ${shellQuote(executable)} >/dev/null 2>&1 && echo ok && exit 0`, + binaryPath ? `[ -x ${shellQuote(binaryPath)} ] && echo ok && exit 0` : "true", + "exit 1", + ].join("; "); + const result = runCaptureOpenshell(["sandbox", "exec", sandboxName, "sh", "-lc", script], { + ignoreError: true, + }); + return Boolean(result && result.includes("ok")); +} + +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); +} + /** * Handle the full agent setup step (step 7) including resume detection. * For non-OpenClaw agents: writes config into the sandbox and verifies @@ -154,6 +189,14 @@ export async function handleAgentSetup( startRecordedStep("agent_setup", { sandboxName, provider, model }); step(7, 8, `Setting up ${agent.displayName} inside sandbox`); + if (!verifyAgentBinaryAvailable(sandboxName, agent, runCaptureOpenshell)) { + failAgentSetup( + sandboxName, + agent, + `${agent.displayName} binary '${agentExecutableName(agent)}' is missing inside sandbox '${sandboxName}'`, + ); + } + const selectionConfig = getProviderSelectionConfig(provider, model); if (selectionConfig) { const sandboxConfig = { @@ -195,8 +238,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..6685a3c1be3 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3365,15 +3365,86 @@ 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"; +} + +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 { + const envName = process.env.NEMOCLAW_SANDBOX_NAME?.trim(); + return envName || 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); + 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, +): void { + registry.updateSandbox(sandboxName, { + model, + provider, + dashboardPort, + ...getSandboxAgentRegistryFields(agent), + }); + 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 +3565,7 @@ async function createSandbox( step(6, 8, "Creating sandbox"); const sandboxName = validateName( - sandboxNameOverride ?? (await promptValidatedSandboxName()), + sandboxNameOverride ?? (await promptValidatedSandboxName(agent)), "sandbox name", ); @@ -3647,6 +3718,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 +3772,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 +3801,7 @@ 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); return sandboxName; } } else { @@ -3717,7 +3830,7 @@ 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); return sandboxName; } } @@ -3757,7 +3870,7 @@ 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); return sandboxName; } } catch (err) { @@ -3775,12 +3888,16 @@ 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); 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) { @@ -4257,7 +4374,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 +4382,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 +4397,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 +4405,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) { @@ -7839,7 +7953,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 +8085,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 }), @@ -8186,6 +8305,10 @@ module.exports = { upsertProvider, hashCredential, detectMessagingCredentialRotation, + getDefaultSandboxNameForAgent, + getSandboxPromptDefault, + getRequestedSandboxAgentName, + normalizeSandboxAgentName, hydrateCredentialEnv, pruneKnownHostsEntries, shouldIncludeBuildContextPath, diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 755acfdf859..73d77aa48db 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -4268,6 +4268,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); @@ -4352,6 +4400,15 @@ const [cmd, ...args] = process.argv.slice(2); return; } + 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); + } + } + // Sandbox-scoped commands: nemoclaw // If the registry doesn't know this name but the action is a sandbox-scoped // command, attempt recovery — the sandbox may still be live with a stale registry. @@ -4367,6 +4424,12 @@ const [cmd, ...args] = process.argv.slice(2); 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.`); } @@ -4390,6 +4453,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 34adc856903..2a56d6c114e 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -186,8 +186,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"); @@ -234,6 +239,35 @@ 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("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); @@ -2910,11 +2944,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..6a5c764d14b 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -2069,6 +2069,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 +2104,29 @@ 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"); + }); }); // --------------------------------------------------------------------------- diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 4694947d891..4a7bc01df7c 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; @@ -151,6 +155,10 @@ const { getRequestedModelHint, getRequestedProviderHint, getRequestedSandboxNameHint, + getDefaultSandboxNameForAgent, + getSandboxPromptDefault, + getRequestedSandboxAgentName, + normalizeSandboxAgentName, getResumeConfigConflicts, getResumeSandboxConflict, getSandboxStateFromOutputs, @@ -177,6 +185,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("custom-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, @@ -2915,6 +2945,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 () => ""; @@ -3024,6 +3056,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 () => ""; @@ -3118,6 +3152,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 () => ""; @@ -3247,6 +3283,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 +3466,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 +3727,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 +3843,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 +4099,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 +4224,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"))}); @@ -4665,6 +4713,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 () => ""; @@ -5058,6 +5108,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 () => ""; @@ -5188,6 +5240,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 () => ""; @@ -5800,6 +5854,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 () => ""; @@ -5917,6 +5973,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 +6033,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 +6096,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 +6180,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 +6229,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 +6244,21 @@ 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, /recreateForAgentDrift/); + assert.match(source, /getSandboxAgentRegistryFields/); + 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 +6270,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(