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");
+ },
+ );
+});