From 182d3fb0d829e34a31432db9ff5eea36db0e2964 Mon Sep 17 00:00:00 2001 From: Revant Patel Date: Fri, 29 May 2026 00:47:36 -0700 Subject: [PATCH 1/5] fix(cli): probe live sandbox agent versions Signed-off-by: Revant Patel --- src/lib/actions/sandbox/status.ts | 17 +- src/lib/actions/upgrade-sandboxes.ts | 6 +- .../onboard/sandbox-registry-metadata.test.ts | 103 ++++++++++-- src/lib/onboard/sandbox-registry-metadata.ts | 5 +- src/lib/sandbox/version.test.ts | 21 +++ src/lib/sandbox/version.ts | 8 +- test/cli.test.ts | 159 ++++++++++++++++++ 7 files changed, 301 insertions(+), 18 deletions(-) diff --git a/src/lib/actions/sandbox/status.ts b/src/lib/actions/sandbox/status.ts index 1592249dc2..b054a01215 100644 --- a/src/lib/actions/sandbox/status.ts +++ b/src/lib/actions/sandbox/status.ts @@ -237,15 +237,30 @@ export async function showSandboxStatus(sandboxName: string): Promise { // Agent version check try { - const versionCheck = sandboxVersion.checkAgentVersion(sandboxName, { skipProbe: true }); + const shouldProbeRuntimeVersion = lookup.state === "present" && Boolean(sb.agentVersion); + const versionCheck = sandboxVersion.checkAgentVersion(sandboxName, { + forceProbe: shouldProbeRuntimeVersion, + skipProbe: !shouldProbeRuntimeVersion, + }); const agent = agentRuntime.getSessionAgent(sandboxName); const agentName = agentRuntime.getAgentDisplayName(agent); if (versionCheck.sandboxVersion) { console.log(` Agent: ${agentName} v${versionCheck.sandboxVersion}`); + } else if (shouldProbeRuntimeVersion && versionCheck.expectedVersion) { + console.log( + ` Agent: ${agentName} version not verified (expected v${versionCheck.expectedVersion})`, + ); } if (versionCheck.isStale) { console.log(` ${YW}Update: v${versionCheck.expectedVersion} available${R}`); console.log(` Run \`${CLI_NAME} ${sandboxName} rebuild\` to upgrade`); + } else if ( + shouldProbeRuntimeVersion && + versionCheck.detectionMethod === "unavailable" && + versionCheck.expectedVersion + ) { + console.log(` ${YW}Update: unable to verify sandbox ${agentName} version${R}`); + console.log(` Run \`${CLI_NAME} ${sandboxName} rebuild\` if this sandbox predates the current install`); } } catch { /* non-fatal */ diff --git a/src/lib/actions/upgrade-sandboxes.ts b/src/lib/actions/upgrade-sandboxes.ts index ef2be6cd2d..8fd1a7221d 100644 --- a/src/lib/actions/upgrade-sandboxes.ts +++ b/src/lib/actions/upgrade-sandboxes.ts @@ -74,7 +74,11 @@ export async function upgradeSandboxes( const { stale, unknown } = classifyUpgradeableSandboxes( sandboxes, liveNames, - sandboxVersion.checkAgentVersion, + (name) => + sandboxVersion.checkAgentVersion( + name, + liveNames.has(name) ? { forceProbe: true } : undefined, + ), ); if (stale.length === 0 && unknown.length === 0) { diff --git a/src/lib/onboard/sandbox-registry-metadata.test.ts b/src/lib/onboard/sandbox-registry-metadata.test.ts index 1935956f36..53fe1c20df 100644 --- a/src/lib/onboard/sandbox-registry-metadata.test.ts +++ b/src/lib/onboard/sandbox-registry-metadata.test.ts @@ -1,11 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { afterEach, describe, expect, it } from "vitest"; -// Import the compiled module: sandbox-registry-metadata.ts pulls in state/registry, -// which transitively requires the JS-only `./platform` helper that vitest cannot -// resolve from TS source. Same pattern as `vm-dns-monkeypatch.test.ts`. -import { createSandboxRegistryMetadataHelpers } from "../../../dist/lib/onboard/sandbox-registry-metadata"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AgentDefinition } from "../agent/defs"; import type { SandboxGpuConfig } from "./sandbox-gpu-mode"; const ORIGINAL_PLATFORM = Object.getOwnPropertyDescriptor(process, "platform"); @@ -20,14 +20,25 @@ function restorePlatform(): void { } } -function makeHelpers(opts: { dockerDriverEnabled: boolean }) { - return createSandboxRegistryMetadataHelpers({ +async function makeHelpers(opts: { dockerDriverEnabled: boolean }) { + // Import the compiled module: sandbox-registry-metadata.ts pulls in state/registry, + // which transitively requires the JS-only `./platform` helper that vitest cannot + // resolve from TS source. Same pattern as `vm-dns-monkeypatch.test.ts`. + const metadata = await import("../../../dist/lib/onboard/sandbox-registry-metadata"); + return metadata.createSandboxRegistryMetadataHelpers({ isLinuxDockerDriverGatewayEnabled: () => opts.dockerDriverEnabled, getInstalledOpenshellVersion: () => "0.0.42", runCaptureOpenshell: () => null, }); } +function openclawAgent(expectedVersion: string): AgentDefinition { + return { + name: "openclaw", + expectedVersion, + } as AgentDefinition; +} + const GPU_OFF: SandboxGpuConfig = { hostGpuDetected: false, hostGpuPlatform: null, @@ -37,30 +48,96 @@ const GPU_OFF: SandboxGpuConfig = { errors: [], }; +describe("sandbox registry metadata", () => { + const originalHome = process.env.HOME; + let tmpDir: string | null = null; + + afterEach(() => { + process.env.HOME = originalHome; + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = null; + vi.resetModules(); + }); + + it("preserves the recorded agent version when reusing an existing sandbox", async () => { + tmpDir = mkdtempSync(join(tmpdir(), "nemoclaw-reuse-metadata-")); + process.env.HOME = tmpDir; + vi.resetModules(); + + const metadata = await import("../../../dist/lib/onboard/sandbox-registry-metadata"); + + const configDir = join(tmpDir, ".nemoclaw"); + const registryFile = join(configDir, "sandboxes.json"); + mkdirSync(configDir, { recursive: true }); + writeFileSync(registryFile, JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "old-model", + provider: "old-provider", + agentVersion: "2026.5.18", + }, + }, + defaultSandbox: "alpha", + })); + + const readSandbox = () => JSON.parse(readFileSync(registryFile, "utf8")).sandboxes.alpha; + + expect(readSandbox()).toEqual({ + name: "alpha", + model: "old-model", + provider: "old-provider", + agentVersion: "2026.5.18", + }); + + const helpers = metadata.createSandboxRegistryMetadataHelpers({ + isLinuxDockerDriverGatewayEnabled: () => true, + getInstalledOpenshellVersion: () => "0.0.44", + runCaptureOpenshell: () => "openshell 0.0.44", + }); + + helpers.updateReusedSandboxMetadata( + "alpha", + openclawAgent("2026.5.22"), + "new-model", + "nvidia-prod", + 18789, + ); + + expect(readSandbox()).toEqual( + expect.objectContaining({ + model: "new-model", + provider: "nvidia-prod", + agentVersion: "2026.5.18", + }), + ); + }); +}); + describe("getSandboxRuntimeRegistryFields openshellDriver", () => { afterEach(restorePlatform); - it("records Docker for macOS sandboxes on the Docker-driver gateway path", () => { + it("records Docker for macOS sandboxes on the Docker-driver gateway path", async () => { setPlatform("darwin"); - const helpers = makeHelpers({ dockerDriverEnabled: true }); + const helpers = await makeHelpers({ dockerDriverEnabled: true }); const fields = helpers.getSandboxRuntimeRegistryFields(GPU_OFF); expect(fields.openshellDriver).toBe("docker"); }); - it("records Docker for Linux sandboxes on the Docker-driver gateway path", () => { + it("records Docker for Linux sandboxes on the Docker-driver gateway path", async () => { setPlatform("linux"); - const helpers = makeHelpers({ dockerDriverEnabled: true }); + const helpers = await makeHelpers({ dockerDriverEnabled: true }); const fields = helpers.getSandboxRuntimeRegistryFields(GPU_OFF); expect(fields.openshellDriver).toBe("docker"); }); - it("records Kubernetes for legacy Linux sandboxes when the Docker-driver gateway is disabled", () => { + it("records Kubernetes for legacy Linux sandboxes when the Docker-driver gateway is disabled", async () => { setPlatform("linux"); - const helpers = makeHelpers({ dockerDriverEnabled: false }); + const helpers = await makeHelpers({ dockerDriverEnabled: false }); const fields = helpers.getSandboxRuntimeRegistryFields(GPU_OFF); diff --git a/src/lib/onboard/sandbox-registry-metadata.ts b/src/lib/onboard/sandbox-registry-metadata.ts index c6a69ba710..a14429d03f 100644 --- a/src/lib/onboard/sandbox-registry-metadata.ts +++ b/src/lib/onboard/sandbox-registry-metadata.ts @@ -86,12 +86,13 @@ export function createSandboxRegistryMetadataHelpers( sandboxGpuConfig: SandboxGpuConfig | null = null, ): void { const existingEntry = registry.getSandbox(sandboxName); - const agentVersionKnown = existingEntry?.agentVersion !== null; + const agentFields = getSandboxAgentRegistryFields(agent, false); const selectionUpdates = selectionVerified ? { model, provider } : {}; registry.updateSandbox(sandboxName, { ...selectionUpdates, dashboardPort, - ...getSandboxAgentRegistryFields(agent, agentVersionKnown), + agent: agentFields.agent, + agentVersion: existingEntry?.agentVersion ?? null, ...(sandboxGpuConfig ? getSandboxRuntimeRegistryFields(sandboxGpuConfig) : {}), }); registry.setDefault(sandboxName); diff --git a/src/lib/sandbox/version.test.ts b/src/lib/sandbox/version.test.ts index 8e2da099b0..499468b438 100644 --- a/src/lib/sandbox/version.test.ts +++ b/src/lib/sandbox/version.test.ts @@ -194,6 +194,27 @@ describe("checkAgentVersion", () => { expect(result.detectionMethod).toBe("ssh-exec"); expect(result.sandboxVersion).toBe("2026.5.22"); }); + + it("force probe does not trust cached metadata when live version probing is unavailable", () => { + registry.registerSandbox({ + name: "test-sb", + agent: null, + agentVersion: "2026.5.18", + }); + + vi.mocked(captureSandboxSshConfigCommand).mockReturnValue({ + status: 0, + output: "", + }); + vi.mocked(spawnSync).mockClear(); + + const result = checkAgentVersion("test-sb", { forceProbe: true }); + + expect(result.detectionMethod).toBe("unavailable"); + expect(result.sandboxVersion).toBeNull(); + expect(result.isStale).toBe(false); + expect(spawnSync).not.toHaveBeenCalled(); + }); }); describe("formatStalenessWarning", () => { diff --git a/src/lib/sandbox/version.ts b/src/lib/sandbox/version.ts index 5e57aa1266..d06f290980 100644 --- a/src/lib/sandbox/version.ts +++ b/src/lib/sandbox/version.ts @@ -30,6 +30,11 @@ export interface VersionCheckResult { detectionMethod: "registry" | "ssh-exec" | "unavailable"; } +export interface VersionCheckOptions { + forceProbe?: boolean; + skipProbe?: boolean; +} + /** * Resolve the agent definition for a sandbox. * Falls back to "openclaw" when the sandbox has no agent set. @@ -55,6 +60,7 @@ export function probeAgentVersion(sandboxName: string): string | null { timeout: OPENSHELL_PROBE_TIMEOUT_MS, }); if (sshConfigResult.status !== 0) return null; + if (!sshConfigResult.output.trim()) return null; const tmpFile = path.join(os.tmpdir(), `nemoclaw-ver-${process.pid}-${Date.now()}.conf`); fs.writeFileSync(tmpFile, sshConfigResult.output, { mode: 0o600 }); @@ -89,7 +95,7 @@ export function probeAgentVersion(sandboxName: string): string | null { */ export function checkAgentVersion( sandboxName: string, - opts?: { forceProbe?: boolean; skipProbe?: boolean }, + opts?: VersionCheckOptions, ): VersionCheckResult { const agent = resolveAgentForSandbox(sandboxName); const expectedVersion = agent.expectedVersion; diff --git a/test/cli.test.ts b/test/cli.test.ts index 5499e61f84..e4cf42aeaa 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -120,6 +120,7 @@ type SandboxEntry = { gpuEnabled: boolean; policies: string[]; agent?: string; + agentVersion?: string | null; }; function writeRecordingCommand( @@ -5097,6 +5098,68 @@ describe("CLI dispatch", () => { expect(inferenceGetIdx).toBeGreaterThan(sandboxGetIdx); }); + it("status reports the live sandbox agent version instead of cached host metadata", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-status-agent-drift-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home, { + model: "configured-model", + provider: "nvidia-prod", + agentVersion: "2026.5.18", + }); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', + " echo 'Sandbox:'", + " echo", + " echo ' Id: abc'", + " echo ' Name: alpha'", + " echo ' Namespace: openshell'", + " echo ' Phase: Ready'", + " exit 0", + "fi", + 'if [ "$1" = "sandbox" ] && [ "$2" = "ssh-config" ] && [ "$3" = "alpha" ]; then', + " echo 'Host openshell-alpha'", + " echo ' HostName 127.0.0.1'", + " exit 0", + "fi", + 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', + " echo 'Gateway inference:'", + " echo", + " echo ' Provider: nvidia-prod'", + " echo ' Model: live-model'", + " exit 0", + "fi", + 'if [ "$1" = "sandbox" ] && [ "$2" = "exec" ] && [ "$3" = "--name" ] && [ "$4" = "alpha" ]; then', + " echo '__NEMOCLAW_SANDBOX_EXEC_STARTED__'", + " echo 'RUNNING'", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + fs.writeFileSync( + path.join(localBin, "ssh"), + ["#!/usr/bin/env bash", "echo 'OpenClaw 2026.3.11 (old)'", "exit 0"].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("alpha status", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(r.out).toContain("Agent: OpenClaw v2026.3.11"); + expect(r.out).toContain("Update:"); + expect(r.out).toContain("v2026.5.18 available"); + expect(r.out).toContain("Run `nemoclaw alpha rebuild` to upgrade"); + expect(r.out).not.toContain("Agent: OpenClaw v2026.5.18"); + }); + it( "does not treat a different connected gateway as a healthy nemoclaw gateway", () => { @@ -5809,6 +5872,15 @@ describe("list shows live gateway inference", () => { ' echo "my-agent Running openclaw"', " exit 0", "fi", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "my-agent" ]; then', + " echo 'Sandbox: my-agent'", + " exit 0", + "fi", + 'if [ "$1" = "sandbox" ] && [ "$2" = "ssh-config" ] && [ "$3" = "my-agent" ]; then', + " echo 'Host openshell-my-agent'", + " echo ' HostName 127.0.0.1'", + " exit 0", + "fi", 'if [ "$1" = "--version" ]; then', ' echo "openshell 0.0.24"', " exit 0", @@ -5817,6 +5889,11 @@ describe("list shows live gateway inference", () => { ].join("\n"), { mode: 0o755 }, ); + fs.writeFileSync( + path.join(localBin, "ssh"), + ["#!/usr/bin/env bash", "echo 'OpenClaw 2026.3.11 (old)'", "exit 0"].join("\n"), + { mode: 0o755 }, + ); const r = runWithEnv("upgrade-sandboxes --check 2>&1", { HOME: home, @@ -5868,6 +5945,15 @@ describe("list shows live gateway inference", () => { ' echo "my-agent Running openclaw"', " exit 0", "fi", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "my-agent" ]; then', + " echo 'Sandbox: my-agent'", + " exit 0", + "fi", + 'if [ "$1" = "sandbox" ] && [ "$2" = "ssh-config" ] && [ "$3" = "my-agent" ]; then', + " echo 'Host openshell-my-agent'", + " echo ' HostName 127.0.0.1'", + " exit 0", + "fi", 'if [ "$1" = "--version" ]; then', ' echo "openshell 0.0.24"', " exit 0", @@ -5876,6 +5962,11 @@ describe("list shows live gateway inference", () => { ].join("\n"), { mode: 0o755 }, ); + fs.writeFileSync( + path.join(localBin, "ssh"), + ["#!/usr/bin/env bash", "echo 'OpenClaw 9999.12.31 (new)'", "exit 0"].join("\n"), + { mode: 0o755 }, + ); const r = runWithEnv("upgrade-sandboxes --check 2>&1", { HOME: home, @@ -5887,6 +5978,74 @@ describe("list shows live gateway inference", () => { }, ); + it( + "upgrade-sandboxes --check probes running sandboxes before trusting cached metadata (#4429)", + testTimeoutOptions(), + () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-upgrade-probe-")); + const localBin = path.join(home, "bin"); + const nemoclawDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(nemoclawDir, { recursive: true }); + + fs.writeFileSync( + path.join(nemoclawDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + "my-agent": { + name: "my-agent", + model: "nvidia/nemotron-3-super-120b-a12b", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + agentVersion: "2026.5.18", + }, + }, + defaultSandbox: "my-agent", + }), + { mode: 0o600 }, + ); + + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', + ' echo "my-agent Running openclaw"', + " exit 0", + "fi", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "my-agent" ]; then', + " echo 'Sandbox: my-agent'", + " exit 0", + "fi", + 'if [ "$1" = "sandbox" ] && [ "$2" = "ssh-config" ] && [ "$3" = "my-agent" ]; then', + " echo 'Host openshell-my-agent'", + " echo ' HostName 127.0.0.1'", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + fs.writeFileSync( + path.join(localBin, "ssh"), + ["#!/usr/bin/env bash", "echo 'OpenClaw 2026.3.11 (old)'", "exit 0"].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("upgrade-sandboxes --check 2>&1", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(r.out).toContain("my-agent"); + expect(r.out).toContain("2026.3.11"); + expect(r.out).toMatch(/stale|need upgrading/i); + expect(r.out).not.toContain("All sandboxes are up to date."); + }, + ); + it("share with no subcommand prints usage help", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-share-")); writeSandboxRegistry(home); From 5e4d09fbfc8285e0f82604d9b4fbe3e161972b88 Mon Sep 17 00:00:00 2001 From: Revant Patel Date: Fri, 29 May 2026 16:18:32 -0700 Subject: [PATCH 2/5] docs(cli): document sandbox version probe helpers Signed-off-by: Revant Patel --- src/lib/actions/sandbox/status.ts | 12 +++++++++++- src/lib/actions/upgrade-sandboxes.ts | 19 ++++++++++++++----- .../onboard/sandbox-registry-metadata.test.ts | 12 ++++++++++++ src/lib/sandbox/version.ts | 3 +++ 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/lib/actions/sandbox/status.ts b/src/lib/actions/sandbox/status.ts index da9d6c1180..3edc1a9d03 100644 --- a/src/lib/actions/sandbox/status.ts +++ b/src/lib/actions/sandbox/status.ts @@ -51,6 +51,16 @@ type ProbeProviderHealth = ( options?: ProviderHealthProbeOptions, ) => ProviderHealthStatus | null; +/** + * Returns true when status can validate a cached agent version against the running sandbox. + */ +function shouldProbeSandboxRuntimeVersion( + lookup: SandboxGatewayState, + sandbox: registry.SandboxEntry, +): boolean { + return lookup.state === "present" && Boolean(sandbox.agentVersion); +} + export function getSandboxStatusInferenceHealth( gatewayPresent: boolean, currentProvider: unknown, @@ -312,7 +322,7 @@ export async function showSandboxStatus(sandboxName: string): Promise { // Agent version check try { - const shouldProbeRuntimeVersion = lookup.state === "present" && Boolean(sb.agentVersion); + const shouldProbeRuntimeVersion = shouldProbeSandboxRuntimeVersion(lookup, sb); const versionCheck = sandboxVersion.checkAgentVersion(sandboxName, { forceProbe: shouldProbeRuntimeVersion, skipProbe: !shouldProbeRuntimeVersion, diff --git a/src/lib/actions/upgrade-sandboxes.ts b/src/lib/actions/upgrade-sandboxes.ts index 8fd1a7221d..54d7e8a1da 100644 --- a/src/lib/actions/upgrade-sandboxes.ts +++ b/src/lib/actions/upgrade-sandboxes.ts @@ -31,6 +31,19 @@ import { rebuildSandbox } from "./sandbox/rebuild"; // ── Upgrade sandboxes (#1904) ──────────────────────────────────── // Detect sandboxes running stale agent versions and offer to rebuild them. +/** + * Checks the sandbox agent version with a live probe when the sandbox is running. + */ +function checkAgentVersionForUpgrade( + sandboxName: string, + liveNames: Set, +): sandboxVersion.VersionCheckResult { + return sandboxVersion.checkAgentVersion( + sandboxName, + liveNames.has(sandboxName) ? { forceProbe: true } : undefined, + ); +} + export async function upgradeSandboxes( options: string[] | UpgradeSandboxesOptions = {}, ): Promise { @@ -74,11 +87,7 @@ export async function upgradeSandboxes( const { stale, unknown } = classifyUpgradeableSandboxes( sandboxes, liveNames, - (name) => - sandboxVersion.checkAgentVersion( - name, - liveNames.has(name) ? { forceProbe: true } : undefined, - ), + (name) => checkAgentVersionForUpgrade(name, liveNames), ); if (stale.length === 0 && unknown.length === 0) { diff --git a/src/lib/onboard/sandbox-registry-metadata.test.ts b/src/lib/onboard/sandbox-registry-metadata.test.ts index 53fe1c20df..305ce80f68 100644 --- a/src/lib/onboard/sandbox-registry-metadata.test.ts +++ b/src/lib/onboard/sandbox-registry-metadata.test.ts @@ -10,16 +10,25 @@ import type { SandboxGpuConfig } from "./sandbox-gpu-mode"; const ORIGINAL_PLATFORM = Object.getOwnPropertyDescriptor(process, "platform"); +/** + * Overrides process.platform for runtime-driver metadata tests. + */ function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, "platform", { value: platform, configurable: true }); } +/** + * Restores the original process.platform descriptor after each platform-specific assertion. + */ function restorePlatform(): void { if (ORIGINAL_PLATFORM) { Object.defineProperty(process, "platform", ORIGINAL_PLATFORM); } } +/** + * Loads the compiled metadata helpers after each test has configured process state. + */ async function makeHelpers(opts: { dockerDriverEnabled: boolean }) { // Import the compiled module: sandbox-registry-metadata.ts pulls in state/registry, // which transitively requires the JS-only `./platform` helper that vitest cannot @@ -32,6 +41,9 @@ async function makeHelpers(opts: { dockerDriverEnabled: boolean }) { }); } +/** + * Creates a minimal OpenClaw agent definition for metadata preservation tests. + */ function openclawAgent(expectedVersion: string): AgentDefinition { return { name: "openclaw", diff --git a/src/lib/sandbox/version.ts b/src/lib/sandbox/version.ts index d06f290980..89952cf3d9 100644 --- a/src/lib/sandbox/version.ts +++ b/src/lib/sandbox/version.ts @@ -30,6 +30,9 @@ export interface VersionCheckResult { detectionMethod: "registry" | "ssh-exec" | "unavailable"; } +/** + * Controls whether version checks may use cached metadata or must inspect the sandbox runtime. + */ export interface VersionCheckOptions { forceProbe?: boolean; skipProbe?: boolean; From 08fba22dab7a51d1670327442e6302deb771d492 Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 29 May 2026 18:33:48 -0700 Subject: [PATCH 3/5] fix(cli): probe running sandboxes for upgrades Signed-off-by: Test User --- src/lib/runtime-recovery.test.ts | 5 +++-- src/lib/runtime-recovery.ts | 3 ++- test/cli.test.ts | 22 +++++++++++++++++++++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/lib/runtime-recovery.test.ts b/src/lib/runtime-recovery.test.ts index 23c31016e9..b56e377b03 100644 --- a/src/lib/runtime-recovery.test.ts +++ b/src/lib/runtime-recovery.test.ts @@ -59,20 +59,21 @@ describe("runtime recovery helpers", () => { }); describe("parseReadySandboxNames", () => { - it("includes only sandboxes whose PHASE is Ready", () => { + it("includes sandboxes whose PHASE is Ready or Running", () => { expect( Array.from( parseReadySandboxNames( [ "NAME NAMESPACE CREATED PHASE", "alpha openshell 2026-03-24 10:00:00 Ready", + "epsilon openshell 2026-03-24 10:00:30 Running", "beta openshell 2026-03-24 10:01:00 Provisioning", "gamma openshell 2026-03-24 10:02:00 Error", "delta openshell 2026-03-24 10:03:00 Ready", ].join("\n"), ), ), - ).toEqual(["alpha", "delta"]); + ).toEqual(["alpha", "epsilon", "delta"]); }); it("skips sandboxes that report Error PHASE (stopped container)", () => { diff --git a/src/lib/runtime-recovery.ts b/src/lib/runtime-recovery.ts index 263aef4fe8..936dd0e356 100644 --- a/src/lib/runtime-recovery.ts +++ b/src/lib/runtime-recovery.ts @@ -51,7 +51,8 @@ export function parseReadySandboxNames(listOutput = ""): Set { const cols = line.split(/\s+/); if (!cols[0]) continue; if (isNonSandboxRow(line, cols[0])) continue; - if (cols.at(-1) !== "Ready") continue; + const isReadyOrRunning = cols.includes("Ready") || cols.includes("Running"); + if (!isReadyOrRunning || cols.includes("NotReady")) continue; names.add(cols[0]); } return names; diff --git a/test/cli.test.ts b/test/cli.test.ts index 30ff60463d..01d4cee246 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -7,6 +7,7 @@ import type { ChildProcess } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { parse as parseYaml } from "yaml"; import { execTimeout, testTimeout, testTimeoutOptions } from "./helpers/timeouts"; @@ -14,6 +15,25 @@ const CLI = path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"); const HERMES_CLI = path.join(import.meta.dirname, "..", "bin", "nemohermes.js"); const PARSER_EXIT_CODE = 2; +function readOpenClawExpectedVersion(): string { + const manifestPath = path.join( + import.meta.dirname, + "..", + "agents", + "openclaw", + "manifest.yaml", + ); + const manifest = parseYaml(fs.readFileSync(manifestPath, "utf8")) as { + expected_version?: unknown; + }; + if (typeof manifest.expected_version === "string" && manifest.expected_version.trim()) { + return manifest.expected_version; + } + throw new Error("agents/openclaw/manifest.yaml is missing expected_version"); +} + +const OPENCLAW_EXPECTED_VERSION = readOpenClawExpectedVersion(); + type CliRunResult = { code: number; out: string; @@ -5446,7 +5466,7 @@ describe("CLI dispatch", () => { expect(r.code).toBe(0); expect(r.out).toContain("Agent: OpenClaw v2026.3.11"); expect(r.out).toContain("Update:"); - expect(r.out).toContain("v2026.5.18 available"); + expect(r.out).toContain(`v${OPENCLAW_EXPECTED_VERSION} available`); expect(r.out).toContain("Run `nemoclaw alpha rebuild` to upgrade"); expect(r.out).not.toContain("Agent: OpenClaw v2026.5.18"); }); From 5f030598f6aef5edcff083d1699bd51405fcfcaa Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 29 May 2026 18:41:49 -0700 Subject: [PATCH 4/5] fix(cli): parse sandbox phase exactly Signed-off-by: Test User --- src/lib/runtime-recovery.test.ts | 14 ++++++++++++++ src/lib/runtime-recovery.ts | 13 +++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/lib/runtime-recovery.test.ts b/src/lib/runtime-recovery.test.ts index b56e377b03..6e1273742a 100644 --- a/src/lib/runtime-recovery.test.ts +++ b/src/lib/runtime-recovery.test.ts @@ -89,6 +89,20 @@ describe("runtime recovery helpers", () => { ).toEqual([]); }); + it("does not treat Ready or Running tokens outside the PHASE column as live", () => { + expect( + Array.from( + parseReadySandboxNames( + [ + "NAME NAMESPACE CREATED PHASE", + "alpha Ready 2026-03-24 10:00:00 Provisioning", + "beta Running 2026-03-24 10:01:00 Error", + ].join("\n"), + ), + ), + ).toEqual([]); + }); + it("treats no-sandboxes output, error lines, and protobuf mismatch as empty", () => { expect(Array.from(parseReadySandboxNames("No sandboxes found."))).toEqual([]); expect(Array.from(parseReadySandboxNames("Error: something went wrong"))).toEqual([]); diff --git a/src/lib/runtime-recovery.ts b/src/lib/runtime-recovery.ts index 936dd0e356..995bffbe29 100644 --- a/src/lib/runtime-recovery.ts +++ b/src/lib/runtime-recovery.ts @@ -7,6 +7,7 @@ */ const ANSI_RE = /\x1b\[[0-9;]*m/g; +const SANDBOX_PHASES = new Set(["Ready", "Running", "NotReady", "Provisioning", "Error"]); function stripAnsi(text: string | null | undefined): string { return String(text || "").replace(ANSI_RE, ""); @@ -28,6 +29,13 @@ function isNonSandboxRow(line: string, firstCol: string): boolean { return false; } +function parseSandboxListPhase(cols: string[]): string | null { + const compactPhase = cols[1]; + if (cols.length <= 3 && SANDBOX_PHASES.has(compactPhase)) return compactPhase; + const trailingPhase = cols.at(-1); + return trailingPhase && SANDBOX_PHASES.has(trailingPhase) ? trailingPhase : null; +} + export function parseLiveSandboxNames(listOutput = ""): Set { const clean = stripAnsi(listOutput); const names = new Set(); @@ -51,8 +59,9 @@ export function parseReadySandboxNames(listOutput = ""): Set { const cols = line.split(/\s+/); if (!cols[0]) continue; if (isNonSandboxRow(line, cols[0])) continue; - const isReadyOrRunning = cols.includes("Ready") || cols.includes("Running"); - if (!isReadyOrRunning || cols.includes("NotReady")) continue; + const phase = parseSandboxListPhase(cols); + const isReadyOrRunning = phase === "Ready" || phase === "Running"; + if (phase === "NotReady" || !isReadyOrRunning) continue; names.add(cols[0]); } return names; From a0bcd53d92526219561c9ca384105c83cb77b5e6 Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 29 May 2026 18:47:55 -0700 Subject: [PATCH 5/5] test(cli): cover notready sandbox phase parsing Signed-off-by: Test User --- src/lib/runtime-recovery.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/runtime-recovery.test.ts b/src/lib/runtime-recovery.test.ts index 6e1273742a..c677eb1c0b 100644 --- a/src/lib/runtime-recovery.test.ts +++ b/src/lib/runtime-recovery.test.ts @@ -70,6 +70,7 @@ describe("runtime recovery helpers", () => { "beta openshell 2026-03-24 10:01:00 Provisioning", "gamma openshell 2026-03-24 10:02:00 Error", "delta openshell 2026-03-24 10:03:00 Ready", + "zeta openshell 2026-03-24 10:04:00 NotReady", ].join("\n"), ), ),