Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion src/lib/actions/sandbox/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -312,15 +322,30 @@ export async function showSandboxStatus(sandboxName: string): Promise<void> {

// Agent version check
try {
const versionCheck = sandboxVersion.checkAgentVersion(sandboxName, { skipProbe: true });
const shouldProbeRuntimeVersion = shouldProbeSandboxRuntimeVersion(lookup, sb);
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 */
Expand Down
15 changes: 14 additions & 1 deletion src/lib/actions/upgrade-sandboxes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
): sandboxVersion.VersionCheckResult {
return sandboxVersion.checkAgentVersion(
sandboxName,
liveNames.has(sandboxName) ? { forceProbe: true } : undefined,
);
}

export async function upgradeSandboxes(
options: string[] | UpgradeSandboxesOptions = {},
): Promise<void> {
Expand Down Expand Up @@ -74,7 +87,7 @@ export async function upgradeSandboxes(
const { stale, unknown } = classifyUpgradeableSandboxes(
sandboxes,
liveNames,
sandboxVersion.checkAgentVersion,
(name) => checkAgentVersionForUpgrade(name, liveNames),
);

if (stale.length === 0 && unknown.length === 0) {
Expand Down
115 changes: 102 additions & 13 deletions src/lib/onboard/sandbox-registry-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,56 @@
// 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");

/**
* 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);
}
}

function makeHelpers(opts: { dockerDriverEnabled: boolean }) {
return createSandboxRegistryMetadataHelpers({
/**
* 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
// 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,
});
}

/**
* Creates a minimal OpenClaw agent definition for metadata preservation tests.
*/
function openclawAgent(expectedVersion: string): AgentDefinition {
return {
name: "openclaw",
expectedVersion,
} as AgentDefinition;
}

const GPU_OFF: SandboxGpuConfig = {
hostGpuDetected: false,
hostGpuPlatform: null,
Expand All @@ -37,30 +60,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);

Expand Down
5 changes: 3 additions & 2 deletions src/lib/onboard/sandbox-registry-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
20 changes: 18 additions & 2 deletions src/lib/runtime-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,20 +59,22 @@ 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",
"zeta openshell 2026-03-24 10:04:00 NotReady",
].join("\n"),
),
),
).toEqual(["alpha", "delta"]);
).toEqual(["alpha", "epsilon", "delta"]);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("skips sandboxes that report Error PHASE (stopped container)", () => {
Expand All @@ -88,6 +90,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([]);
Expand Down
12 changes: 11 additions & 1 deletion src/lib/runtime-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "");
Expand All @@ -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<string> {
const clean = stripAnsi(listOutput);
const names = new Set<string>();
Expand All @@ -51,7 +59,9 @@ export function parseReadySandboxNames(listOutput = ""): Set<string> {
const cols = line.split(/\s+/);
if (!cols[0]) continue;
if (isNonSandboxRow(line, cols[0])) continue;
if (cols.at(-1) !== "Ready") continue;
const phase = parseSandboxListPhase(cols);
const isReadyOrRunning = phase === "Ready" || phase === "Running";
if (phase === "NotReady" || !isReadyOrRunning) continue;
names.add(cols[0]);
}
return names;
Expand Down
21 changes: 21 additions & 0 deletions src/lib/sandbox/version.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
11 changes: 10 additions & 1 deletion src/lib/sandbox/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ 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;
}

/**
* Resolve the agent definition for a sandbox.
* Falls back to "openclaw" when the sandbox has no agent set.
Expand All @@ -55,6 +63,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 });
Expand Down Expand Up @@ -89,7 +98,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;
Expand Down
Loading
Loading