Skip to content
Merged
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
8 changes: 8 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
<AgentOnly variant="openclaw,hermes">
Expand Down Expand Up @@ -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 <container>` recovery hint instead of sending you directly to rebuild.
Expand Down Expand Up @@ -3587,6 +3592,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