From 130419b0eb49a2cda53b98a539bf6b8301946b69 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 15:30:39 -0700 Subject: [PATCH 01/27] fix nemohermes first-run onboarding Signed-off-by: Aaron Erickson --- 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( From f0c9643654481b85d1e9b677bd6e6d32d561b812 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 16:31:26 -0700 Subject: [PATCH 02/27] fix: prefer onboard session sandbox without node Signed-off-by: Aaron Erickson --- scripts/install.sh | 6 ++++++ test/install-preflight.test.ts | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/scripts/install.sh b/scripts/install.sh index 8ff554f8492..a49d1cfe2de 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -190,6 +190,12 @@ 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:-}" diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index 6a5c764d14b..bbb654b3ead 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( @@ -2127,6 +2139,29 @@ exit 1 }); 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"); + }); }); // --------------------------------------------------------------------------- From cc806b10924a05e708fd91261d1322d3c0215c36 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 17:17:02 -0700 Subject: [PATCH 03/27] fix: address nemohermes review feedback Signed-off-by: Aaron Erickson --- src/lib/agent-onboard.test.ts | 4 +++ src/lib/agent-onboard.ts | 13 +++++---- src/lib/onboard.ts | 51 ++++++++++++++++++++++++++++++----- src/nemoclaw.ts | 29 ++++++++++++++------ test/cli.test.ts | 16 +++++++++++ test/onboard.test.ts | 14 ++++++++-- 6 files changed, 105 insertions(+), 22 deletions(-) diff --git a/src/lib/agent-onboard.test.ts b/src/lib/agent-onboard.test.ts index 96c4cba8dc9..00aabc05681 100644 --- a/src/lib/agent-onboard.test.ts +++ b/src/lib/agent-onboard.test.ts @@ -128,6 +128,10 @@ describe("handleAgentSetup guards", () => { 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).toContain("failAgentSetup"); expect(source).toContain('onboardSession.markStepFailed("agent_setup"'); expect(source).toContain("gateway did not respond within"); diff --git a/src/lib/agent-onboard.ts b/src/lib/agent-onboard.ts index a7f786ad3e1..eb1d4f97522 100644 --- a/src/lib/agent-onboard.ts +++ b/src/lib/agent-onboard.ts @@ -127,11 +127,14 @@ function verifyAgentBinaryAvailable( ): 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 script = binaryPath + ? [ + `resolved="$(command -v ${shellQuote(executable)} 2>/dev/null || true)"`, + `[ "$resolved" = ${shellQuote(binaryPath)} ]`, + `[ -x ${shellQuote(binaryPath)} ]`, + "echo ok", + ].join(" && ") + : `command -v ${shellQuote(executable)} >/dev/null 2>&1 && echo ok`; const result = runCaptureOpenshell(["sandbox", "exec", sandboxName, "sh", "-lc", script], { ignoreError: true, }); diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 6685a3c1be3..760686bc34a 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3370,6 +3370,8 @@ function normalizeSandboxAgentName(agentName: string | null | undefined): string return trimmed && trimmed !== "openclaw" ? trimmed : "openclaw"; } +const UNKNOWN_SANDBOX_AGENT_NAME = "unknown"; + function getRequestedSandboxAgentName(agent: AgentDefinition | null | undefined): string { return normalizeSandboxAgentName(agent?.name); } @@ -3386,8 +3388,7 @@ function getDefaultSandboxNameForAgent(agent: AgentDefinition | null | undefined } function getSandboxPromptDefault(agent: AgentDefinition | null | undefined): string { - const envName = process.env.NEMOCLAW_SANDBOX_NAME?.trim(); - return envName || getDefaultSandboxNameForAgent(agent); + return getDefaultSandboxNameForAgent(agent); } function getEffectiveSandboxAgent(agent: AgentDefinition | null | undefined): AgentDefinition { @@ -3411,6 +3412,13 @@ function getSandboxAgentDrift( 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, @@ -3425,12 +3433,13 @@ function updateReusedSandboxMetadata( model: string, provider: string, dashboardPort: number, + agentVersionKnown = true, ): void { registry.updateSandbox(sandboxName, { model, provider, dashboardPort, - ...getSandboxAgentRegistryFields(agent), + ...getSandboxAgentRegistryFields(agent, agentVersionKnown), }); registry.setDefault(sandboxName); } @@ -3801,7 +3810,14 @@ async function createSandbox( } const reusedPort = ensureDashboardForward(sandboxName, chatUiUrl); process.env.CHAT_UI_URL = `http://127.0.0.1:${reusedPort}`; - updateReusedSandboxMetadata(sandboxName, agent, model, provider, reusedPort); + updateReusedSandboxMetadata( + sandboxName, + agent, + model, + provider, + reusedPort, + !fromDockerfile, + ); return sandboxName; } } else { @@ -3830,7 +3846,14 @@ async function createSandbox( upsertMessagingProviders(messagingTokenDefs); const reusedPort2 = ensureDashboardForward(sandboxName, chatUiUrl); process.env.CHAT_UI_URL = `http://127.0.0.1:${reusedPort2}`; - updateReusedSandboxMetadata(sandboxName, agent, model, provider, reusedPort2); + updateReusedSandboxMetadata( + sandboxName, + agent, + model, + provider, + reusedPort2, + !fromDockerfile, + ); return sandboxName; } } @@ -3870,7 +3893,14 @@ async function createSandbox( } const reusedPort3 = ensureDashboardForward(sandboxName, chatUiUrl); process.env.CHAT_UI_URL = `http://127.0.0.1:${reusedPort3}`; - updateReusedSandboxMetadata(sandboxName, agent, model, provider, reusedPort3); + updateReusedSandboxMetadata( + sandboxName, + agent, + model, + provider, + reusedPort3, + !fromDockerfile, + ); return sandboxName; } } catch (err) { @@ -3888,7 +3918,14 @@ async function createSandbox( } const reusedPort4 = ensureDashboardForward(sandboxName, chatUiUrl); process.env.CHAT_UI_URL = `http://127.0.0.1:${reusedPort4}`; - updateReusedSandboxMetadata(sandboxName, agent, model, provider, reusedPort4); + updateReusedSandboxMetadata( + sandboxName, + agent, + model, + provider, + reusedPort4, + !fromDockerfile, + ); return sandboxName; } } diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 6162459f13f..8fa6b76007e 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -4407,7 +4407,16 @@ const [cmd, ...args] = process.argv.slice(2); return; } - if (!registry.getSandbox(cmd)) { + // 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. + // Derived from command registry — single source of truth + const sandboxActions = sandboxActionTokens(); + const requestedSandboxAction = args[0] || ""; + // Bare command typos should stay cheap: do not start gateway recovery just + // to tell a user that `liost` probably meant `list`. Explicit sandbox + // actions still run recovery before the later typo-suggestion exit. + if (!registry.getSandbox(cmd) && args.length === 0) { const suggestion = suggestGlobalCommand(cmd); if (suggestion) { console.error(` Unknown command: ${cmd}`); @@ -4415,13 +4424,7 @@ const [cmd, ...args] = process.argv.slice(2); 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. - // Derived from command registry — single source of truth - const sandboxActions = sandboxActionTokens(); - if (!registry.getSandbox(cmd) && sandboxActions.includes(args[0] || "")) { + if (!registry.getSandbox(cmd) && sandboxActions.includes(requestedSandboxAction)) { validateName(cmd, "sandbox name"); await recoverRegistryEntries({ requestedSandboxName: cmd }); if (!registry.getSandbox(cmd)) { @@ -4443,6 +4446,16 @@ const [cmd, ...args] = process.argv.slice(2); 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"); diff --git a/test/cli.test.ts b/test/cli.test.ts index 91ed6e8e674..7dd688d19e9 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -247,6 +247,22 @@ describe("CLI dispatch", () => { expect(r.out).toContain("Did you mean: nemoclaw list?"); }); + it("attempts sandbox recovery before typo suggestion exits", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "nemoclaw.ts"), + "utf-8", + ); + const scopedRecovery = source.indexOf( + "if (!registry.getSandbox(cmd) && sandboxActions.includes(requestedSandboxAction))", + ); + const suggestion = source.indexOf( + "const suggestion = suggestGlobalCommand(cmd)", + scopedRecovery, + ); + expect(scopedRecovery).toBeGreaterThan(-1); + expect(suggestion).toBeGreaterThan(scopedRecovery); + }); + 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"); diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 4a7bc01df7c..680312e846d 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -197,7 +197,7 @@ describe("onboard helpers", () => { expect(getSandboxPromptDefault(hermes)).toBe("hermes"); process.env.NEMOCLAW_SANDBOX_NAME = "custom-hermes"; - expect(getSandboxPromptDefault(hermes)).toBe("custom-hermes"); + expect(getSandboxPromptDefault(hermes)).toBe("hermes"); } finally { if (previousSandboxName === undefined) { delete process.env.NEMOCLAW_SANDBOX_NAME; @@ -5808,7 +5808,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"), "{}"); @@ -6254,8 +6257,15 @@ const { createSandbox } = require(${onboardPath}); 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, + /updateReusedSandboxMetadata\([\s\S]*?reusedPort[\s\S]*?!fromDockerfile[\s\S]*?\)/, + ); assert.match(source, /registry\.setDefault\(sandboxName\)/); }); From 2a5f5b6432ff3b4239590673d350bb98be78287d Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 17:38:45 -0700 Subject: [PATCH 04/27] fix: use named openshell exec for agent probes --- agents/hermes/Dockerfile | 11 +++++++++++ src/lib/agent-onboard.test.ts | 4 ++++ src/lib/agent-onboard.ts | 13 ++++++++----- src/lib/onboard.ts | 15 ++++++++++----- test/onboard.test.ts | 28 +++++++++++++++++++++------- test/sandbox-provisioning.test.ts | 12 ++++++++++++ 6 files changed, 66 insertions(+), 17 deletions(-) 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/src/lib/agent-onboard.test.ts b/src/lib/agent-onboard.test.ts index 00aabc05681..7fd27bd2305 100644 --- a/src/lib/agent-onboard.test.ts +++ b/src/lib/agent-onboard.test.ts @@ -132,6 +132,10 @@ describe("handleAgentSetup guards", () => { '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"); diff --git a/src/lib/agent-onboard.ts b/src/lib/agent-onboard.ts index eb1d4f97522..32ffa77598b 100644 --- a/src/lib/agent-onboard.ts +++ b/src/lib/agent-onboard.ts @@ -135,9 +135,12 @@ function verifyAgentBinaryAvailable( "echo ok", ].join(" && ") : `command -v ${shellQuote(executable)} >/dev/null 2>&1 && echo ok`; - const result = runCaptureOpenshell(["sandbox", "exec", sandboxName, "sh", "-lc", script], { - ignoreError: true, - }); + const result = runCaptureOpenshell( + ["sandbox", "exec", "-n", sandboxName, "--", "sh", "-lc", script], + { + ignoreError: true, + }, + ); return Boolean(result && result.includes("ok")); } @@ -178,7 +181,7 @@ 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")) { @@ -229,7 +232,7 @@ 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")) { diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 760686bc34a..2318295595a 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) { @@ -4367,7 +4370,9 @@ async function createSandbox( [ "sandbox", "exec", + "-n", sandboxName, + "--", "curl", "-sf", `http://localhost:${effectiveDashboardPort}/`, diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 680312e846d..72765165d09 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -2659,6 +2659,20 @@ 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("starts the sandbox step before prompting for the sandbox name", () => { const source = fs.readFileSync( path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), @@ -2940,7 +2954,7 @@ 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 ""; }; @@ -3051,7 +3065,7 @@ 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 ""; }; @@ -3147,7 +3161,7 @@ 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 ""; }; @@ -4708,7 +4722,7 @@ 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 ""; }; @@ -5103,7 +5117,7 @@ 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 ""; }; @@ -5235,7 +5249,7 @@ 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 ""; }; @@ -5852,7 +5866,7 @@ 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 ""; }; 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"); From 02273d0de588bb8b6550250b25518e8bb6bae7dc Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 17:41:23 -0700 Subject: [PATCH 05/27] fix: keep installer banner bash 3 compatible --- scripts/install.sh | 17 +++++++++++++++-- test/install-preflight.test.ts | 10 ++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index a49d1cfe2de..e1b69897817 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() { @@ -269,7 +282,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 @@ -292,7 +305,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" diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index bbb654b3ead..77cb760dcee 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -2073,6 +2073,16 @@ 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^}"); + + const r = callInstallerPayloadFn("agent_display_name hermes"); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe("Hermes"); + }); + // -- resolve_default_sandbox_name -- it("resolve_default_sandbox_name: returns 'my-assistant' with no registry", () => { From fbe63f074c1d6d0e9874731b1180d5d3c141555d Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 17:46:37 -0700 Subject: [PATCH 06/27] fix: use freshly installed openshell during install --- scripts/install.sh | 10 ++++++++++ src/lib/resolve-openshell.test.ts | 22 +++++++++++++++++++++- src/lib/resolve-openshell.ts | 26 +++++++++++++++----------- test/install-preflight.test.ts | 20 ++++++++++++++++++++ 4 files changed, 66 insertions(+), 12 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index e1b69897817..3c448be7038 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -620,6 +620,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 @@ -1208,6 +1217,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 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/test/install-preflight.test.ts b/test/install-preflight.test.ts index 77cb760dcee..41fdf9bbd06 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -2083,6 +2083,26 @@ exit 1 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); + }); + // -- resolve_default_sandbox_name -- it("resolve_default_sandbox_name: returns 'my-assistant' with no registry", () => { From 024350fff840f4d4b32ab1f7b12826da1e35b953 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 17:52:55 -0700 Subject: [PATCH 07/27] fix: restore agent dashboard forward after health --- src/lib/onboard.ts | 6 ++++++ test/onboard.test.ts | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 2318295595a..cb67a6a2c01 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -8160,6 +8160,12 @@ async function onboard(opts: OnboardOptions = {}): Promise { startRecordedStep, skippedStepMessage, }); + const agentDashboardPort = agent.forwardPort || CONTROL_UI_PORT; + const agentDashboardUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${agentDashboardPort}`; + const actualAgentDashboardPort = ensureDashboardForward(sandboxName, agentDashboardUrl); + if (actualAgentDashboardPort !== Number(getDashboardForwardPort(agentDashboardUrl))) { + process.env.CHAT_UI_URL = `http://127.0.0.1:${actualAgentDashboardPort}`; + } onboardSession.markStepSkipped("openclaw"); } else { const resumeOpenclaw = resume && sandboxName && isOpenclawReady(sandboxName); diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 72765165d09..55d3f11433f 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -2673,6 +2673,24 @@ const { setupInference } = require(${onboardPath}); 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( + "ensureDashboardForward(sandboxName, agentDashboardUrl)", + 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("starts the sandbox step before prompting for the sandbox name", () => { const source = fs.readFileSync( path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), From e23c9b2a71db8ec9dabea2793a229fa82ca6b905 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 17:57:30 -0700 Subject: [PATCH 08/27] fix: reclaim stale dashboard forwards --- src/lib/onboard.ts | 27 ++++++++++++++++++++++++++- test/onboard.test.ts | 18 ++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index cb67a6a2c01..72e7aefe564 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -7007,6 +7007,23 @@ 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; +} + /** * Parse `openshell forward list` output into a Map. * Only includes running forwards — stopped/stale entries are ignored so @@ -7130,7 +7147,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 || preferredEntry.status !== "running") + ) { + runOpenshell(["forward", "stop", String(preferredPort)], { ignoreError: true }); + existingForwards = runCaptureOpenshell(["forward", "list"], { ignoreError: true }); + } let actualPort: number; try { actualPort = findAvailableDashboardPort(sandboxName, preferredPort, existingForwards); diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 55d3f11433f..853722b1a56 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -6608,6 +6608,24 @@ 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, /preferredEntry\.status !== "running"/); + 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", From 14abe0838c10cf12628229d1dbc71ca1a4c4af9c Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 18:09:22 -0700 Subject: [PATCH 09/27] fix: restore agent forward after policies --- src/lib/onboard.ts | 24 ++++++++++++++++++------ test/onboard.test.ts | 30 +++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 72e7aefe564..bc3bb6b9cab 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -7206,6 +7206,19 @@ function ensureDashboardForward( return actualPort; } +function ensureAgentDashboardForward( + sandboxName: string, + agent: { forwardPort?: number | null }, +): number { + const agentDashboardPort = agent.forwardPort || CONTROL_UI_PORT; + const agentDashboardUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${agentDashboardPort}`; + const actualAgentDashboardPort = ensureDashboardForward(sandboxName, agentDashboardUrl); + if (actualAgentDashboardPort !== Number(getDashboardForwardPort(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 }); @@ -8185,12 +8198,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { startRecordedStep, skippedStepMessage, }); - const agentDashboardPort = agent.forwardPort || CONTROL_UI_PORT; - const agentDashboardUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${agentDashboardPort}`; - const actualAgentDashboardPort = ensureDashboardForward(sandboxName, agentDashboardUrl); - if (actualAgentDashboardPort !== Number(getDashboardForwardPort(agentDashboardUrl))) { - process.env.CHAT_UI_URL = `http://127.0.0.1:${actualAgentDashboardPort}`; - } + ensureAgentDashboardForward(sandboxName, agent); onboardSession.markStepSkipped("openclaw"); } else { const resumeOpenclaw = resume && sandboxName && isOpenclawReady(sandboxName); @@ -8262,6 +8270,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 diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 853722b1a56..5d222319823 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -2680,7 +2680,7 @@ const { setupInference } = require(${onboardPath}); ); const setupPos = source.indexOf("await agentOnboard.handleAgentSetup"); const forwardPos = source.indexOf( - "ensureDashboardForward(sandboxName, agentDashboardUrl)", + "ensureAgentDashboardForward(sandboxName, agent)", setupPos, ); @@ -2691,6 +2691,34 @@ const { setupInference } = require(${onboardPath}); ); }); + 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"), From c9011bbf129eef133047db176ee379052e52f7c6 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 18:18:35 -0700 Subject: [PATCH 10/27] fix: restore hermes forward after install checks --- scripts/install.sh | 28 ++++++++++++++++++++++++++++ test/install-preflight.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/scripts/install.sh b/scripts/install.sh index 3c448be7038..70d140ca19c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -252,6 +252,33 @@ resolve_onboarded_agent() { fi } +restore_onboard_forward_after_post_checks() { + local sandbox_name agent_name port openshell_bin + sandbox_name="$(resolve_default_sandbox_name)" + agent_name="$(resolve_onboarded_agent)" + + 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 + + "$openshell_bin" forward stop "$port" "$sandbox_name" >/dev/null 2>&1 \ + || "$openshell_bin" forward stop "$port" >/dev/null 2>&1 \ + || true + if ! "$openshell_bin" forward start --background "$port" "$sandbox_name" >/dev/null 2>&1; then + warn "Could not restore ${agent_display_name "$agent_name"} host forward on port ${port}." + warn "Run: openshell forward start --background ${port} ${sandbox_name}" + fi +} + # step N "Description" — numbered section header step() { local n=$1 msg=$2 @@ -1616,6 +1643,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 else warn "Skipping onboarding until the host prerequisites above are fixed." fi diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index 41fdf9bbd06..46a63b8a4f5 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -2103,6 +2103,37 @@ exit 1 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" +exit 0 +`, + ); + + const r = callInstallerPayloadFn("restore_onboard_forward_after_post_checks", { + HOME: tmp, + 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", () => { From df84fcef25af73a70019b2faac7cd388848df407 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 18:22:44 -0700 Subject: [PATCH 11/27] fix: retry hermes forward restore after install --- scripts/install.sh | 32 ++++++++++++++++++++++++-------- test/install-preflight.test.ts | 10 ++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 70d140ca19c..d715fe190dd 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -253,7 +253,7 @@ resolve_onboarded_agent() { } restore_onboard_forward_after_post_checks() { - local sandbox_name agent_name port openshell_bin + local sandbox_name agent_name port openshell_bin attempt sandbox_name="$(resolve_default_sandbox_name)" agent_name="$(resolve_onboarded_agent)" @@ -270,13 +270,29 @@ restore_onboard_forward_after_post_checks() { return 0 fi - "$openshell_bin" forward stop "$port" "$sandbox_name" >/dev/null 2>&1 \ - || "$openshell_bin" forward stop "$port" >/dev/null 2>&1 \ - || true - if ! "$openshell_bin" forward start --background "$port" "$sandbox_name" >/dev/null 2>&1; then - warn "Could not restore ${agent_display_name "$agent_name"} host forward on port ${port}." - warn "Run: openshell forward start --background ${port} ${sandbox_name}" - fi + for attempt in 1 2 3; do + "$openshell_bin" forward stop "$port" "$sandbox_name" >/dev/null 2>&1 \ + || "$openshell_bin" forward stop "$port" >/dev/null 2>&1 \ + || true + if [ "$attempt" -gt 1 ]; then + sleep 2 + fi + "$openshell_bin" forward start --background "$port" "$sandbox_name" >/dev/null 2>&1 || continue + sleep 2 + if "$openshell_bin" forward list 2>/dev/null \ + | awk -v sandbox="$sandbox_name" -v fwd_port="$port" ' + $1 == sandbox && $3 == fwd_port && tolower($5) == "running" { found = 1 } + END { exit found ? 0 : 1 } + '; then + if ! command_exists curl \ + || curl -sf --max-time 3 "http://127.0.0.1:${port}/health" >/dev/null 2>&1; then + return 0 + fi + fi + done + + warn "Could not restore ${agent_display_name "$agent_name"} host forward on port ${port}." + warn "Run: openshell forward start --background ${port} ${sandbox_name}" } # step N "Description" — numbered section header diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index 46a63b8a4f5..8e6d75aea40 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -2118,6 +2118,16 @@ exit 1 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 `, ); From 4de5579de6725ded2484a6fd6c3ba1dd4cd506d2 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 18:26:19 -0700 Subject: [PATCH 12/27] fix: keep hermes forward warning bash compatible --- scripts/install.sh | 5 +++-- test/install-preflight.test.ts | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index d715fe190dd..31c2e53aed6 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -253,9 +253,10 @@ resolve_onboarded_agent() { } restore_onboard_forward_after_post_checks() { - local sandbox_name agent_name port openshell_bin attempt + local sandbox_name agent_name agent_display port openshell_bin attempt 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 ;; @@ -291,7 +292,7 @@ restore_onboard_forward_after_post_checks() { fi done - warn "Could not restore ${agent_display_name "$agent_name"} host forward on port ${port}." + warn "Could not restore ${agent_display} host forward on port ${port}." warn "Run: openshell forward start --background ${port} ${sandbox_name}" } diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index 8e6d75aea40..5bf0928fc14 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -2077,6 +2077,7 @@ exit 1 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); From 65b39dad822a7696b50a6e5dfb043f11dc70960e Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 18:28:17 -0700 Subject: [PATCH 13/27] fix: verify hermes forward restore by health --- scripts/install.sh | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 31c2e53aed6..c1c817039af 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -279,16 +279,18 @@ restore_onboard_forward_after_post_checks() { sleep 2 fi "$openshell_bin" forward start --background "$port" "$sandbox_name" >/dev/null 2>&1 || continue - sleep 2 - if "$openshell_bin" forward list 2>/dev/null \ + 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 + if ! command_exists curl \ + && NO_COLOR=1 "$openshell_bin" forward list 2>/dev/null \ | awk -v sandbox="$sandbox_name" -v fwd_port="$port" ' $1 == sandbox && $3 == fwd_port && tolower($5) == "running" { found = 1 } END { exit found ? 0 : 1 } '; then - if ! command_exists curl \ - || curl -sf --max-time 3 "http://127.0.0.1:${port}/health" >/dev/null 2>&1; then - return 0 - fi + return 0 fi done From cc6d75c54602f67f994dafc2512cbf5672f18290 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 18:32:45 -0700 Subject: [PATCH 14/27] fix: nohup hermes install forward restore --- scripts/install.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index c1c817039af..ec89e592826 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -253,7 +253,7 @@ resolve_onboarded_agent() { } restore_onboard_forward_after_post_checks() { - local sandbox_name agent_name agent_display port openshell_bin attempt + local sandbox_name agent_name agent_display port openshell_bin attempt start_pid sandbox_name="$(resolve_default_sandbox_name)" agent_name="$(resolve_onboarded_agent)" agent_display="$(agent_display_name "$agent_name")" @@ -278,7 +278,9 @@ restore_onboard_forward_after_post_checks() { if [ "$attempt" -gt 1 ]; then sleep 2 fi - "$openshell_bin" forward start --background "$port" "$sandbox_name" >/dev/null 2>&1 || continue + nohup "$openshell_bin" forward start --background "$port" "$sandbox_name" >/dev/null 2>&1 & + start_pid=$! + wait "$start_pid" || continue sleep 4 if command_exists curl \ && curl -sf --max-time 3 "http://127.0.0.1:${port}/health" >/dev/null 2>&1; then From c1c6c904fc75f7237291eeed70e0ca2f3275ba01 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 18:35:01 -0700 Subject: [PATCH 15/27] fix: keep hermes install forward alive --- scripts/install.sh | 24 +++++++++++++++--------- test/install-preflight.test.ts | 2 +- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index ec89e592826..b5d10921e35 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -253,7 +253,7 @@ resolve_onboarded_agent() { } restore_onboard_forward_after_post_checks() { - local sandbox_name agent_name agent_display port openshell_bin attempt start_pid + local sandbox_name agent_name agent_display port openshell_bin attempt start_pid state_dir pid_file sandbox_name="$(resolve_default_sandbox_name)" agent_name="$(resolve_onboarded_agent)" agent_display="$(agent_display_name "$agent_name")" @@ -271,6 +271,14 @@ restore_onboard_forward_after_post_checks() { 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 + kill "$(cat "$pid_file" 2>/dev/null)" >/dev/null 2>&1 || true + rm -f "$pid_file" + fi + for attempt in 1 2 3; do "$openshell_bin" forward stop "$port" "$sandbox_name" >/dev/null 2>&1 \ || "$openshell_bin" forward stop "$port" >/dev/null 2>&1 \ @@ -278,22 +286,20 @@ restore_onboard_forward_after_post_checks() { if [ "$attempt" -gt 1 ]; then sleep 2 fi - nohup "$openshell_bin" forward start --background "$port" "$sandbox_name" >/dev/null 2>&1 & + nohup "$openshell_bin" forward start "$port" "$sandbox_name" \ + >"${pid_file}.log" 2>&1 & start_pid=$! - wait "$start_pid" || continue + printf "%s\n" "$start_pid" >"$pid_file" 2>/dev/null || true 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 - if ! command_exists curl \ - && NO_COLOR=1 "$openshell_bin" forward list 2>/dev/null \ - | awk -v sandbox="$sandbox_name" -v fwd_port="$port" ' - $1 == sandbox && $3 == fwd_port && tolower($5) == "running" { found = 1 } - END { exit found ? 0 : 1 } - '; then + if ! command_exists curl && kill -0 "$start_pid" >/dev/null 2>&1; then return 0 fi + kill "$start_pid" >/dev/null 2>&1 || true + rm -f "$pid_file" done warn "Could not restore ${agent_display} host forward on port ${port}." diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index 5bf0928fc14..40c0a420200 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -2142,7 +2142,7 @@ exit 0 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"); + expect(openshellCalls).toContain("forward start 8642 created-by-onboard"); }); // -- resolve_default_sandbox_name -- From 7794c1cbcbfacb01925c1ff3afa685ac08e75298 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 18:37:15 -0700 Subject: [PATCH 16/27] fix: detach hermes install forward stdin --- scripts/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install.sh b/scripts/install.sh index b5d10921e35..2ab592690bd 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -287,7 +287,7 @@ restore_onboard_forward_after_post_checks() { sleep 2 fi nohup "$openshell_bin" forward start "$port" "$sandbox_name" \ - >"${pid_file}.log" 2>&1 & + >"${pid_file}.log" 2>&1 "$pid_file" 2>/dev/null || true sleep 4 From bf13313224ad14ba4880b15ecac72784a270d790 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 18:39:57 -0700 Subject: [PATCH 17/27] fix: keep hermes install forward watched --- scripts/install.sh | 17 ++++++++++++++++- test/install-preflight.test.ts | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 2ab592690bd..bc81e69754d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -286,7 +286,22 @@ restore_onboard_forward_after_post_checks() { if [ "$attempt" -gt 1 ]; then sleep 2 fi - nohup "$openshell_bin" forward start "$port" "$sandbox_name" \ + "$openshell_bin" forward start --background "$port" "$sandbox_name" >/dev/null 2>&1 || true + nohup bash -c ' + set -u + openshell_bin="$1" + port="$2" + sandbox_name="$3" + while true; do + if ! curl -sf --max-time 3 "http://127.0.0.1:${port}/health" >/dev/null 2>&1; then + "$openshell_bin" forward stop "$port" "$sandbox_name" >/dev/null 2>&1 \ + || "$openshell_bin" forward stop "$port" >/dev/null 2>&1 \ + || true + "$openshell_bin" forward start --background "$port" "$sandbox_name" >/dev/null 2>&1 || true + fi + sleep 10 + done + ' nemoclaw-forward "$openshell_bin" "$port" "$sandbox_name" \ >"${pid_file}.log" 2>&1 "$pid_file" 2>/dev/null || true diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index 40c0a420200..5bf0928fc14 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -2142,7 +2142,7 @@ exit 0 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 8642 created-by-onboard"); + expect(openshellCalls).toContain("forward start --background 8642 created-by-onboard"); }); // -- resolve_default_sandbox_name -- From 0b3f4a65157c939ce5d74e142cd41983d365698b Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 18:42:07 -0700 Subject: [PATCH 18/27] fix: disown hermes install forward watcher --- scripts/install.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/install.sh b/scripts/install.sh index bc81e69754d..d83459b29d6 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -305,6 +305,7 @@ restore_onboard_forward_after_post_checks() { >"${pid_file}.log" 2>&1 "$pid_file" 2>/dev/null || true + disown "$start_pid" 2>/dev/null || true sleep 4 if command_exists curl \ && curl -sf --max-time 3 "http://127.0.0.1:${port}/health" >/dev/null 2>&1; then From 4b7b7779e3cc3329959a183b93f5ad042fc9f9dc Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 18:44:54 -0700 Subject: [PATCH 19/27] fix: detach hermes forward watcher with node --- scripts/install.sh | 56 ++++++++++++++++++++++------------ test/install-preflight.test.ts | 1 + 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index d83459b29d6..8ca6007ea7c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -253,7 +253,7 @@ resolve_onboarded_agent() { } restore_onboard_forward_after_post_checks() { - local sandbox_name agent_name agent_display port openshell_bin attempt start_pid state_dir pid_file + local sandbox_name agent_name agent_display port openshell_bin attempt state_dir pid_file watcher_script sandbox_name="$(resolve_default_sandbox_name)" agent_name="$(resolve_onboarded_agent)" agent_display="$(agent_display_name "$agent_name")" @@ -287,25 +287,41 @@ restore_onboard_forward_after_post_checks() { sleep 2 fi "$openshell_bin" forward start --background "$port" "$sandbox_name" >/dev/null 2>&1 || true - nohup bash -c ' - set -u - openshell_bin="$1" - port="$2" - sandbox_name="$3" - while true; do - if ! curl -sf --max-time 3 "http://127.0.0.1:${port}/health" >/dev/null 2>&1; then - "$openshell_bin" forward stop "$port" "$sandbox_name" >/dev/null 2>&1 \ - || "$openshell_bin" forward stop "$port" >/dev/null 2>&1 \ - || true - "$openshell_bin" forward start --background "$port" "$sandbox_name" >/dev/null 2>&1 || true - fi - sleep 10 - done - ' nemoclaw-forward "$openshell_bin" "$port" "$sandbox_name" \ - >"${pid_file}.log" 2>&1 "$pid_file" 2>/dev/null || true - disown "$start_pid" 2>/dev/null || true + 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", "stop", port]); + 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, `${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 diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index 5bf0928fc14..a86d03ab53b 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -2135,6 +2135,7 @@ 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 || ""}`, }); From 8cbb7d519cb4198d38665e36fe57fdcedf57edd1 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 20:13:30 -0700 Subject: [PATCH 20/27] fix: address installer shellcheck findings Signed-off-by: Aaron Erickson --- scripts/install.sh | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 8ca6007ea7c..7190e90e1e7 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -253,7 +253,7 @@ resolve_onboarded_agent() { } restore_onboard_forward_after_post_checks() { - local sandbox_name agent_name agent_display port openshell_bin attempt state_dir pid_file watcher_script + 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")" @@ -287,6 +287,7 @@ restore_onboard_forward_after_post_checks() { 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' @@ -317,7 +318,7 @@ NODE detached: true, stdio: "ignore", }); - fs.writeFileSync(pidFile, `${child.pid}\n`); + fs.writeFileSync(pidFile, String(child.pid) + "\n"); child.unref(); ' "$watcher_script" "$openshell_bin" "$port" "$sandbox_name" "$pid_file" \ >/dev/null 2>&1 || true @@ -327,10 +328,13 @@ NODE && curl -sf --max-time 3 "http://127.0.0.1:${port}/health" >/dev/null 2>&1; then return 0 fi - if ! command_exists curl && kill -0 "$start_pid" >/dev/null 2>&1; then + 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 - kill "$start_pid" >/dev/null 2>&1 || true + if [[ -n "$watcher_pid" ]]; then + kill "$watcher_pid" >/dev/null 2>&1 || true + fi rm -f "$pid_file" done From f605d634dbfb2a212f67e6f6b83e74a08541ab05 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 20:50:53 -0700 Subject: [PATCH 21/27] fix: refresh openshell gateway on first run --- src/lib/onboard.ts | 57 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index bc3bb6b9cab..91ba4a925b6 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2155,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"`; @@ -2204,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, @@ -2240,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); @@ -2795,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...`, + ); + runOpenshell(["forward", "stop", String(DASHBOARD_PORT)], { ignoreError: true }); + destroyGateway(); + registry.clearAll(); + gatewayReuseState = "missing"; + console.log(" ✓ Previous gateway cleaned up"); + } } } @@ -7931,6 +7974,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...`, + ); + runOpenshell(["forward", "stop", String(DASHBOARD_PORT)], { ignoreError: true }); + destroyGateway(); + registry.clearAll(); + gatewayReuseState = "missing"; + console.log(" ✓ Previous gateway cleaned up"); + } } } From 85915671a11c761c861d24c310dc78cec96b9ed8 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 21:42:47 -0700 Subject: [PATCH 22/27] fix: address nemohermes review feedback --- scripts/install.sh | 31 ++++++++++++++--- src/lib/agent-onboard.ts | 57 +++++++++++++++++++++++++++---- src/lib/onboard.ts | 73 ++++++++++++++++++---------------------- src/nemoclaw.ts | 21 +++++------- test/cli.test.ts | 44 +++++++++++++++++------- test/onboard.test.ts | 48 +++++++++++++++++++++----- 6 files changed, 188 insertions(+), 86 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 7190e90e1e7..97e3d549f7e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -279,10 +279,31 @@ restore_onboard_forward_after_post_checks() { 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 \ + || "$openshell_bin" forward stop "$port" >/dev/null 2>&1 \ + || true + fi + } + for attempt in 1 2 3; do - "$openshell_bin" forward stop "$port" "$sandbox_name" >/dev/null 2>&1 \ - || "$openshell_bin" forward stop "$port" >/dev/null 2>&1 \ - || true + stop_agent_forward_if_owned if [ "$attempt" -gt 1 ]; then sleep 2 fi @@ -304,7 +325,6 @@ function healthy() { function tick() { if (healthy()) return; run(["forward", "stop", port, sandboxName]); - run(["forward", "stop", port]); run(["forward", "start", "--background", port, sandboxName]); } tick(); @@ -340,6 +360,7 @@ NODE 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 @@ -1706,7 +1727,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 + 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.ts b/src/lib/agent-onboard.ts index 32ffa77598b..18415a16b56 100644 --- a/src/lib/agent-onboard.ts +++ b/src/lib/agent-onboard.ts @@ -120,28 +120,70 @@ function agentExecutableName(agent: AgentDefinition): string { 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"], -): boolean { +): 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)"`, - `[ "$resolved" = ${shellQuote(binaryPath)} ]`, - `[ -x ${shellQuote(binaryPath)} ]`, + `[ -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`; + : `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, }, ); - return Boolean(result && result.includes("ok")); + if (result && result.includes("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 { @@ -195,11 +237,12 @@ export async function handleAgentSetup( startRecordedStep("agent_setup", { sandboxName, provider, model }); step(7, 8, `Setting up ${agent.displayName} inside sandbox`); - if (!verifyAgentBinaryAvailable(sandboxName, agent, runCaptureOpenshell)) { + const binaryAvailability = verifyAgentBinaryAvailable(sandboxName, agent, runCaptureOpenshell); + if (!binaryAvailability.available) { failAgentSetup( sandboxName, agent, - `${agent.displayName} binary '${agentExecutableName(agent)}' is missing inside sandbox '${sandboxName}'`, + describeAgentBinaryFailure(sandboxName, agent, binaryAvailability), ); } diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 91ba4a925b6..864b9e9f7c0 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2832,7 +2832,7 @@ async function preflight(): Promise> { console.log( ` Gateway image ${imageDrift.currentVersion} does not match openshell ${imageDrift.expectedVersion}. Recreating...`, ); - runOpenshell(["forward", "stop", String(DASHBOARD_PORT)], { ignoreError: true }); + stopAllDashboardForwards(); destroyGateway(); registry.clearAll(); gatewayReuseState = "missing"; @@ -3479,8 +3479,9 @@ function updateReusedSandboxMetadata( model: string, provider: string, dashboardPort: number, - agentVersionKnown = true, ): void { + const existingEntry = registry.getSandbox(sandboxName); + const agentVersionKnown = existingEntry?.agentVersion !== null; registry.updateSandbox(sandboxName, { model, provider, @@ -3856,14 +3857,7 @@ async function createSandbox( } const reusedPort = ensureDashboardForward(sandboxName, chatUiUrl); process.env.CHAT_UI_URL = `http://127.0.0.1:${reusedPort}`; - updateReusedSandboxMetadata( - sandboxName, - agent, - model, - provider, - reusedPort, - !fromDockerfile, - ); + updateReusedSandboxMetadata(sandboxName, agent, model, provider, reusedPort); return sandboxName; } } else { @@ -3892,14 +3886,7 @@ async function createSandbox( upsertMessagingProviders(messagingTokenDefs); const reusedPort2 = ensureDashboardForward(sandboxName, chatUiUrl); process.env.CHAT_UI_URL = `http://127.0.0.1:${reusedPort2}`; - updateReusedSandboxMetadata( - sandboxName, - agent, - model, - provider, - reusedPort2, - !fromDockerfile, - ); + updateReusedSandboxMetadata(sandboxName, agent, model, provider, reusedPort2); return sandboxName; } } @@ -3939,14 +3926,7 @@ async function createSandbox( } const reusedPort3 = ensureDashboardForward(sandboxName, chatUiUrl); process.env.CHAT_UI_URL = `http://127.0.0.1:${reusedPort3}`; - updateReusedSandboxMetadata( - sandboxName, - agent, - model, - provider, - reusedPort3, - !fromDockerfile, - ); + updateReusedSandboxMetadata(sandboxName, agent, model, provider, reusedPort3); return sandboxName; } } catch (err) { @@ -3964,14 +3944,7 @@ async function createSandbox( } const reusedPort4 = ensureDashboardForward(sandboxName, chatUiUrl); process.env.CHAT_UI_URL = `http://127.0.0.1:${reusedPort4}`; - updateReusedSandboxMetadata( - sandboxName, - agent, - model, - provider, - reusedPort4, - !fromDockerfile, - ); + updateReusedSandboxMetadata(sandboxName, agent, model, provider, reusedPort4); return sandboxName; } } @@ -7067,6 +7040,28 @@ function findForwardEntry( return null; } +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 (status === "running" || status === "active") { + 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 @@ -7253,12 +7248,10 @@ function ensureAgentDashboardForward( sandboxName: string, agent: { forwardPort?: number | null }, ): number { - const agentDashboardPort = agent.forwardPort || CONTROL_UI_PORT; - const agentDashboardUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${agentDashboardPort}`; + const agentDashboardPort = agent.forwardPort ?? CONTROL_UI_PORT; + const agentDashboardUrl = `http://127.0.0.1:${agentDashboardPort}`; const actualAgentDashboardPort = ensureDashboardForward(sandboxName, agentDashboardUrl); - if (actualAgentDashboardPort !== Number(getDashboardForwardPort(agentDashboardUrl))) { - process.env.CHAT_UI_URL = `http://127.0.0.1:${actualAgentDashboardPort}`; - } + process.env.CHAT_UI_URL = `http://127.0.0.1:${actualAgentDashboardPort}`; return actualAgentDashboardPort; } @@ -7980,7 +7973,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { console.log( ` Gateway image ${imageDrift.currentVersion} does not match openshell ${imageDrift.expectedVersion}. Recreating...`, ); - runOpenshell(["forward", "stop", String(DASHBOARD_PORT)], { ignoreError: true }); + stopAllDashboardForwards(); destroyGateway(); registry.clearAll(); gatewayReuseState = "missing"; diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 27306eff2aa..7c6b9aafe82 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -4412,22 +4412,19 @@ 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(); - const requestedSandboxAction = args[0] || ""; - // Bare command typos should stay cheap: do not start gateway recovery just - // to tell a user that `liost` probably meant `list`. Explicit sandbox - // actions still run recovery before the later typo-suggestion exit. - if (!registry.getSandbox(cmd) && 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); - } - } + 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) { diff --git a/test/cli.test.ts b/test/cli.test.ts index 7dd688d19e9..d4ae146b6ae 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -247,20 +247,38 @@ describe("CLI dispatch", () => { expect(r.out).toContain("Did you mean: nemoclaw list?"); }); - it("attempts sandbox recovery before typo suggestion exits", () => { - const source = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "nemoclaw.ts"), - "utf-8", - ); - const scopedRecovery = source.indexOf( - "if (!registry.getSandbox(cmd) && sandboxActions.includes(requestedSandboxAction))", - ); - const suggestion = source.indexOf( - "const suggestion = suggestGlobalCommand(cmd)", - scopedRecovery, + 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 }, ); - expect(scopedRecovery).toBeGreaterThan(-1); - expect(suggestion).toBeGreaterThan(scopedRecovery); + + 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", () => { diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 5d222319823..708da8b3e9b 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -121,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" @@ -2679,10 +2683,7 @@ const { setupInference } = require(${onboardPath}); "utf-8", ); const setupPos = source.indexOf("await agentOnboard.handleAgentSetup"); - const forwardPos = source.indexOf( - "ensureAgentDashboardForward(sandboxName, agent)", - setupPos, - ); + const forwardPos = source.indexOf("ensureAgentDashboardForward(sandboxName, agent)", setupPos); assert.ok(setupPos !== -1, "agent setup call not found"); assert.ok( @@ -2993,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 }; @@ -3004,9 +3008,18 @@ runner.runCapture = (command) => { 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.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 () => ""; @@ -3028,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); @@ -3057,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"), ); @@ -6324,7 +6354,7 @@ const { createSandbox } = require(${onboardPath}); assert.match(source, /getSandboxAgentRegistryFields\(agent, agentVersionKnown\)/); assert.match( source, - /updateReusedSandboxMetadata\([\s\S]*?reusedPort[\s\S]*?!fromDockerfile[\s\S]*?\)/, + /const existingEntry = registry\.getSandbox\(sandboxName\)[\s\S]*?existingEntry\?\.agentVersion !== null/, ); assert.match(source, /registry\.setDefault\(sandboxName\)/); }); From a6b01849b640b0ec569c82eff029b3b885a4e29f Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 21:54:14 -0700 Subject: [PATCH 23/27] chore: apply install script formatting --- scripts/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install.sh b/scripts/install.sh index 97e3d549f7e..1ea05670a59 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -295,7 +295,7 @@ restore_onboard_forward_after_post_checks() { exit } ' <<<"$forward_list")" - if [[ "$owner" == "$sandbox_name" && ( "$status" == "running" || "$status" == "active" ) ]]; then + if [[ "$owner" == "$sandbox_name" && ("$status" == "running" || "$status" == "active") ]]; then "$openshell_bin" forward stop "$port" "$sandbox_name" >/dev/null 2>&1 \ || "$openshell_bin" forward stop "$port" >/dev/null 2>&1 \ || true From a1f8c6d9e4a77a0f137db8404f6e38a64ed9fb23 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 22:00:11 -0700 Subject: [PATCH 24/27] fix: address follow-up review comments --- scripts/install.sh | 4 +--- src/lib/agent-onboard.ts | 3 ++- src/lib/onboard.ts | 51 +++++++++++++++++++++++++++++++++------- test/onboard.test.ts | 3 ++- 4 files changed, 47 insertions(+), 14 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 1ea05670a59..62f999dbb1e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -296,9 +296,7 @@ restore_onboard_forward_after_post_checks() { } ' <<<"$forward_list")" if [[ "$owner" == "$sandbox_name" && ("$status" == "running" || "$status" == "active") ]]; then - "$openshell_bin" forward stop "$port" "$sandbox_name" >/dev/null 2>&1 \ - || "$openshell_bin" forward stop "$port" >/dev/null 2>&1 \ - || true + "$openshell_bin" forward stop "$port" "$sandbox_name" >/dev/null 2>&1 || true fi } diff --git a/src/lib/agent-onboard.ts b/src/lib/agent-onboard.ts index 18415a16b56..753fe6f2eaa 100644 --- a/src/lib/agent-onboard.ts +++ b/src/lib/agent-onboard.ts @@ -151,7 +151,8 @@ function verifyAgentBinaryAvailable( ignoreError: true, }, ); - if (result && result.includes("ok")) { + const status = result?.trim() ?? ""; + if (status === "ok") { return { available: true }; } if (binaryPath && result) { diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 864b9e9f7c0..1c24f6f6400 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3479,12 +3479,13 @@ function updateReusedSandboxMetadata( 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, { - model, - provider, + ...selectionUpdates, dashboardPort, ...getSandboxAgentRegistryFields(agent, agentVersionKnown), }); @@ -3857,7 +3858,14 @@ async function createSandbox( } const reusedPort = ensureDashboardForward(sandboxName, chatUiUrl); process.env.CHAT_UI_URL = `http://127.0.0.1:${reusedPort}`; - updateReusedSandboxMetadata(sandboxName, agent, model, provider, reusedPort); + updateReusedSandboxMetadata( + sandboxName, + agent, + model, + provider, + reusedPort, + !selectionDrift.unknown, + ); return sandboxName; } } else { @@ -3886,7 +3894,14 @@ async function createSandbox( upsertMessagingProviders(messagingTokenDefs); const reusedPort2 = ensureDashboardForward(sandboxName, chatUiUrl); process.env.CHAT_UI_URL = `http://127.0.0.1:${reusedPort2}`; - updateReusedSandboxMetadata(sandboxName, agent, model, provider, reusedPort2); + updateReusedSandboxMetadata( + sandboxName, + agent, + model, + provider, + reusedPort2, + !selectionDrift.unknown, + ); return sandboxName; } } @@ -3926,7 +3941,14 @@ async function createSandbox( } const reusedPort3 = ensureDashboardForward(sandboxName, chatUiUrl); process.env.CHAT_UI_URL = `http://127.0.0.1:${reusedPort3}`; - updateReusedSandboxMetadata(sandboxName, agent, model, provider, reusedPort3); + updateReusedSandboxMetadata( + sandboxName, + agent, + model, + provider, + reusedPort3, + !selectionDrift.unknown, + ); return sandboxName; } } catch (err) { @@ -3944,7 +3966,14 @@ async function createSandbox( } const reusedPort4 = ensureDashboardForward(sandboxName, chatUiUrl); process.env.CHAT_UI_URL = `http://127.0.0.1:${reusedPort4}`; - updateReusedSandboxMetadata(sandboxName, agent, model, provider, reusedPort4); + updateReusedSandboxMetadata( + sandboxName, + agent, + model, + provider, + reusedPort4, + !selectionDrift.unknown, + ); return sandboxName; } } @@ -7040,6 +7069,10 @@ function findForwardEntry( 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 []; @@ -7048,7 +7081,7 @@ function getRunningForwardPorts(forwardListOutput: string | null | undefined): s const parts = line.trim().split(/\s+/); if (parts.length < 5 || !/^\d+$/.test(parts[2])) continue; const status = (parts[4] || "").toLowerCase(); - if (status === "running" || status === "active") { + if (isLiveForwardStatus(status)) { ports.add(parts[2]); } } @@ -7079,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; @@ -7189,7 +7222,7 @@ function ensureDashboardForward( const preferredEntry = findForwardEntry(existingForwards, String(preferredPort)); if ( preferredEntry && - (preferredEntry.sandboxName === sandboxName || preferredEntry.status !== "running") + (preferredEntry.sandboxName === sandboxName || !isLiveForwardStatus(preferredEntry.status)) ) { runOpenshell(["forward", "stop", String(preferredPort)], { ignoreError: true }); existingForwards = runCaptureOpenshell(["forward", "list"], { ignoreError: true }); diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 708da8b3e9b..a127751f2dd 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -6673,7 +6673,8 @@ const { createSandbox } = require(${onboardPath}); ); assert.match(source, /const preferredEntry = findForwardEntry/); - assert.match(source, /preferredEntry\.status !== "running"/); + assert.match(source, /function isLiveForwardStatus/); + assert.match(source, /!isLiveForwardStatus\(preferredEntry\.status\)/); assert.match( source, /runOpenshell\(\["forward", "stop", String\(preferredPort\)\], \{ ignoreError: true \}\)/, From 39d1c363af0c36fe54af9d2943007a779275ad30 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 22:05:53 -0700 Subject: [PATCH 25/27] fix: validate installer watcher pid before kill --- scripts/install.sh | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/install.sh b/scripts/install.sh index 62f999dbb1e..19e1e975fc6 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -275,7 +275,17 @@ restore_onboard_forward_after_post_checks() { mkdir -p "$state_dir" 2>/dev/null || true pid_file="${state_dir}/${agent_name}-${sandbox_name}-${port}.forward.pid" if [[ -f "$pid_file" ]]; then - kill "$(cat "$pid_file" 2>/dev/null)" >/dev/null 2>&1 || true + 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 From 3f5ed23685a9e5090c69df575508312c0fb77321 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 22:07:42 -0700 Subject: [PATCH 26/27] fix: require exact agent health probe response --- src/lib/agent-onboard.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib/agent-onboard.ts b/src/lib/agent-onboard.ts index 753fe6f2eaa..e0768aed7fe 100644 --- a/src/lib/agent-onboard.ts +++ b/src/lib/agent-onboard.ts @@ -194,6 +194,10 @@ function failAgentSetup(sandboxName: string, agent: AgentDefinition, message: st process.exit(1); } +function isHealthProbeOk(result: string | null | undefined): boolean { + return (result ?? "").trim() === "ok"; +} + /** * Handle the full agent setup step (step 7) including resume detection. * For non-OpenClaw agents: writes config into the sandbox and verifies @@ -227,7 +231,7 @@ export async function handleAgentSetup( ["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; @@ -279,7 +283,7 @@ export async function handleAgentSetup( ["sandbox", "exec", "-n", sandboxName, "--", "curl", "-sf", "--max-time", "3", probe.url], { ignoreError: true }, ); - if (result && result.includes("ok")) { + if (isHealthProbeOk(result)) { healthy = true; break; } From 9d1474b5c0f56eba85ec2caa824c34f81a194bc0 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Apr 2026 22:22:58 -0700 Subject: [PATCH 27/27] fix: accept hermes json health probe --- src/lib/agent-onboard.test.ts | 9 +++++++++ src/lib/agent-onboard.ts | 11 ++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/lib/agent-onboard.test.ts b/src/lib/agent-onboard.test.ts index 7fd27bd2305..6e00cf830ad 100644 --- a/src/lib/agent-onboard.test.ts +++ b/src/lib/agent-onboard.test.ts @@ -141,4 +141,13 @@ describe("handleAgentSetup guards", () => { 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 e0768aed7fe..c5bad2dff15 100644 --- a/src/lib/agent-onboard.ts +++ b/src/lib/agent-onboard.ts @@ -195,7 +195,16 @@ function failAgentSetup(sandboxName: string, agent: AgentDefinition, message: st } function isHealthProbeOk(result: string | null | undefined): boolean { - return (result ?? "").trim() === "ok"; + 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; + } } /**