diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 45fab5a77d1..61b6ffdeb37 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -951,6 +951,10 @@ Use `--no-sandbox-gpu`, `--no-gpu`, or `NEMOCLAW_SANDBOX_GPU=0` when you want to List all registered sandboxes with their model, provider, and policy presets. Pass `--json` for machine-readable output that includes a `schemaVersion`, the default sandbox, recovery metadata, and the sandbox inventory. Each sandbox row reports `activeSessionCount` as a nonnegative integer when the SSH-session probe is available and `null` when it is unavailable. +Each sandbox row reports `agent` as a string in both text and JSON output, never `null`. +The row reports `openclaw` when the registry records no agent for the sandbox. +The row reports `unknown` for a sandbox that `$$nemoclaw list` recovers from the live OpenShell gateway. +The gateway sandbox list does not expose the agent. The row does not include the former derived `connected` boolean. Sandboxes with an active SSH session are marked with a `●` indicator so you can tell at a glance which sandbox you are already connected to in another terminal. @@ -1578,6 +1582,7 @@ When the live shared route differs, text output prints both routes and JSON outp When `routeDrift.canConnect` is `false`, `connect` cannot safely restore the recorded route because provider-global identity differs or required route or gateway metadata is incomplete. Refer to [Use Shared Gateway Routes](../inference/manage-inference/use-shared-gateway-routes) for the route-sharing workflow. `openshellDriver` and `openshellVersion` are always strings (falling back to `"unknown"` when the registry has no value), so consumers can rely on `typeof` checks. +`agent` is always a string and reports `openclaw` when the registry records no agent for the sandbox. `failureLayer` is `null` when no preflight failure was detected and otherwise one of `docker_unreachable`, `sandbox_container_stopped`, or `sandbox_dashboard_port_conflict`; when set, `inferenceHealth` is suppressed to `null` so automation does not see a stale remote-provider healthy status during a local outage. `dockerPaused` is `true` when NemoClaw detects that the Docker-driver sandbox container is paused. In that case, text output keeps OpenShell's authoritative phase but prints a `docker unpause ` recovery hint instead of sending you directly to rebuild. @@ -3587,6 +3592,9 @@ For gateway-based messaging agents, it also reports messaging overlap warnings w Use `$$nemoclaw status` when you need one sandbox's live health and recovery guidance. Pass `--json` for machine-readable output with registered sandboxes, service state, inference routes, and health details. +Each JSON sandbox row reports `agent` as a string, never `null`. +The row reports `openclaw` when the registry records no agent for the sandbox. +This command reads the registry without gateway recovery, so it never reports `unknown`. For each listed sandbox, the text output includes the configured inference provider and model plus the number of active SSH sessions when the session probe is available. Host-service PID lookup honors `NEMOCLAW_SANDBOX_NAME`, then `NEMOCLAW_SANDBOX`, then `SANDBOX_NAME`, then the registry default. diff --git a/src/lib/inventory/index.ts b/src/lib/inventory/index.ts index 59d7f57a255..b5b2950c7f8 100644 --- a/src/lib/inventory/index.ts +++ b/src/lib/inventory/index.ts @@ -85,7 +85,7 @@ export interface SandboxInventoryRow { openshellDriver: string | null; openshellVersion: string | null; policies: string[]; - agent: string | null; + agent: string; dashboardPort?: number | null; isDefault: boolean; activeSessionCount: number | null; @@ -166,7 +166,7 @@ export interface StatusSandboxRow { openshellDriver: string | null; openshellVersion: string | null; policies: string[]; - agent: string | null; + agent: string; dashboardPort?: number | null; isDefault: boolean; } @@ -195,6 +195,21 @@ function safeStatusString(value: string | null | undefined): string | null { return redactFull(value); } +/** + * Resolve the agent every inventory surface reports. The registry stores `null` + * or omits `agent` for an OpenClaw sandbox, so text and JSON must resolve that + * marker here or they report different agents for the same sandbox. + * + * #5714: a sandbox recovered display-only from the live gateway has an unknown + * agent (the gateway sandbox list does not expose it). Surface "unknown" rather + * than the OpenClaw default, which would misrepresent a Hermes or Deep Agents + * Code sandbox as OpenClaw. + */ +function resolveDisplayAgent(sandbox: SandboxEntry): string { + if (sandbox.agent) return sandbox.agent; + return sandbox.recoveredFromGateway ? "unknown" : "openclaw"; +} + /** * Project a stored or recovered {@link SandboxEntry} into a display row, * resolving inference/GPU fields and marking gateway-recovered rows so unknown @@ -224,11 +239,7 @@ function buildSandboxInventoryRow( openshellDriver: safeStatusString(sandbox.openshellDriver || null), openshellVersion: safeStatusString(sandbox.openshellVersion || null), policies: Array.isArray(sandbox.policies) ? sandbox.policies : [], - // #5714: a sandbox recovered display-only from the live gateway has an - // unknown agent (the gateway sandbox list does not expose it). Surface - // "unknown" instead of letting the renderer's `|| "openclaw"` default - // misrepresent a Deep Agents/Hermes sandbox as OpenClaw. - agent: sandbox.agent || (sandbox.recoveredFromGateway ? "unknown" : null), + agent: resolveDisplayAgent(sandbox), ...(sandbox.dashboardPort != null ? { dashboardPort: sandbox.dashboardPort } : {}), isDefault: sandbox.name === defaultSandbox, activeSessionCount, @@ -345,7 +356,7 @@ export function renderSandboxInventoryText( : "CPU sandbox"; const presets = sandbox.policies.length > 0 ? sandbox.policies.join(", ") : "none"; const sessionDot = (sandbox.activeSessionCount ?? 0) > 0 ? " ●" : ""; - const agent = sandbox.agent || "openclaw"; + const agent = sandbox.agent; // #5714: for a gateway-recovered row, surface the trusted live PHASE // (e.g. Ready) from `openshell sandbox list` so `list` agrees with // `nemoclaw status`; normal registry rows have no live phase. @@ -409,7 +420,7 @@ function buildStatusSandboxRow( .filter((policy): policy is string => typeof policy === "string") .map((policy) => safeStatusString(policy) || policy) : [], - agent: safeStatusString(sandbox.agent || null), + agent: redactFull(resolveDisplayAgent(sandbox)), ...(dashboardPort != null ? { dashboardPort } : {}), isDefault, }; diff --git a/test/sandbox-agent-surface-parity.test.ts b/test/sandbox-agent-surface-parity.test.ts new file mode 100644 index 00000000000..58eebd635e4 --- /dev/null +++ b/test/sandbox-agent-surface-parity.test.ts @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { testTimeoutOptions } from "./helpers/timeouts"; + +const CLI = path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"); +const SANDBOX = "my-assist"; + +describe("agent parity across sandbox inventory surfaces", () => { + let home: string; + let binDir: string; + + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-agent-parity-")); + binDir = path.join(home, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + + fs.writeFileSync( + path.join(binDir, "openshell"), + [ + "#!/usr/bin/env bash", + 'case "$*" in', + " status)", + " echo 'Status: Disconnected'", + " exit 1", + " ;;", + " *)", + " exit 1", + " ;;", + "esac", + ].join("\n"), + { mode: 0o755 }, + ); + + const stateDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + [SANDBOX]: { + name: SANDBOX, + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + agent: null, + }, + }, + defaultSandbox: SANDBOX, + }), + { mode: 0o600 }, + ); + }); + + afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); + }); + + function runCli(args: string[]): { code: number; stdout: string; stderr: string } { + const result = spawnSync(process.execPath, [CLI, ...args], { + encoding: "utf-8", + timeout: 60_000, + env: { + ...process.env, + HOME: home, + PATH: `${binDir}:${process.env.PATH || ""}`, + NEMOCLAW_HEALTH_POLL_COUNT: "1", + NEMOCLAW_HEALTH_POLL_INTERVAL: "0", + NEMOCLAW_STATUS_PROBE_TIMEOUT_MS: "2000", + NEMOCLAW_TEST_NO_SLEEP: "1", + NEMOCLAW_GATEWAY_PORT: "", + }, + }); + return { + code: result.status ?? -1, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; + } + + function runCliJson(args: string[]): Record { + const { stdout, stderr } = runCli(args); + try { + return JSON.parse(stdout) as Record; + } catch (error) { + throw new Error( + `Expected JSON on stdout for \`${args.join(" ")}\`: ${String(error)}\nstdout: ${stdout}\nstderr: ${stderr}`, + ); + } + } + + function firstSandboxAgent(payload: Record): unknown { + const sandboxes = payload.sandboxes as Array> | undefined; + expect(Array.isArray(sandboxes)).toBe(true); + expect(sandboxes).toHaveLength(1); + return sandboxes?.[0].agent; + } + + it( + "reports the same agent from list --json, global status --json, scoped status --json, and list text", + testTimeoutOptions(120_000), + () => { + const listAgent = firstSandboxAgent(runCliJson(["list", "--json"])); + const globalStatusAgent = firstSandboxAgent(runCliJson(["status", "--json"])); + const scopedStatusAgent = runCliJson([SANDBOX, "status", "--json"]).agent; + const listText = runCli(["list"]); + + expect(listAgent).toBe("openclaw"); + expect(globalStatusAgent).toBe("openclaw"); + expect(scopedStatusAgent).toBe("openclaw"); + expect(`${listText.stdout}${listText.stderr}`).toContain("agent: openclaw"); + }, + ); +});