Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
8 changes: 8 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,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.
<AgentOnly variant="openclaw,hermes">
Expand Down Expand Up @@ -1562,6 +1566,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 <container>` recovery hint instead of sending you directly to rebuild.
Expand Down Expand Up @@ -3569,6 +3574,9 @@ For gateway-based messaging agents, it also reports messaging overlap warnings w
</AgentOnly>
Use `$$nemoclaw <name> 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.

Expand Down
29 changes: 20 additions & 9 deletions src/lib/inventory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 <name> status`; normal registry rows have no live phase.
Expand Down Expand Up @@ -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,
};
Expand Down
121 changes: 121 additions & 0 deletions test/sandbox-agent-surface-parity.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
const { stdout, stderr } = runCli(args);
try {
return JSON.parse(stdout) as Record<string, unknown>;
} catch (error) {
throw new Error(
`Expected JSON on stdout for \`${args.join(" ")}\`: ${String(error)}\nstdout: ${stdout}\nstderr: ${stderr}`,
);
}
}

function firstSandboxAgent(payload: Record<string, unknown>): unknown {
const sandboxes = payload.sandboxes as Array<Record<string, unknown>> | 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");
},
);
});
Loading