Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
21 changes: 21 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1569,6 +1569,7 @@ If the sandbox is running an older Deep Agents Code version than this NemoClaw r
### `$$nemoclaw <name> doctor`

Run a focused health check for one sandbox and the host services it depends on. The command checks the local CLI build, Docker daemon, OpenShell CLI, NemoClaw gateway container, gateway port mapping, live sandbox state, inference route, configured-provider model invocation, Ollama reachability, and the cloudflared tunnel state.
Use `$$nemoclaw doctor` when you need only the host and gateway checks or when no sandbox exists yet.

<AgentOnly variant="openclaw,hermes">
For gateway-based agents, it also reports messaging channel conflicts within the selected
Expand Down Expand Up @@ -3144,6 +3145,26 @@ This command remains as a compatibility alias to `$$nemoclaw onboard` and accept
$$nemoclaw setup-spark
```

### `$$nemoclaw doctor`

Run read-only checks for the host and the NemoClaw gateway without selecting a sandbox.
The command works before you create a sandbox.
It checks the NemoClaw CLI build, the selected host runtime provider, the OpenShell CLI, the sandbox registry, and the gateway selected by `NEMOCLAW_GATEWAY_PORT`.
It does not start, select, restart, or repair a gateway.
It does not run sandbox, inference, messaging, agent-version, config-permission, or agent-service checks.
Use `$$nemoclaw <name> doctor` when you need those sandbox checks.

```bash
$$nemoclaw doctor [--json]
```

The command exits nonzero when a required check fails.
Pass `--json` for a redacted, schema-versioned report with `scope: "global"` and no sandbox field.

| Flag | Description |
| --- | --- |
| `--json` | Emit the global report as JSON. |

### `$$nemoclaw debug`

Collect diagnostics for bug reports. Gathers system info, Docker state, gateway logs, and sandbox status into a summary or tarball. Use `--sandbox <name>` to target a specific sandbox, `--quick` for a smaller snapshot, or `--output <path>` to save a tarball that you can attach to an issue.
Expand Down
89 changes: 89 additions & 0 deletions src/commands/doctor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { testTimeoutOptions } from "../../test/helpers/timeouts";

const mocks = vi.hoisted(() => ({
runGlobalDoctor: vi.fn(),
}));

vi.mock("../lib/actions/sandbox/doctor", () => ({
runGlobalDoctor: mocks.runGlobalDoctor,
}));

import DoctorCommand from "./doctor";

const rootDir = process.cwd();

describe("global doctor command", () => {
beforeEach(() => {
vi.clearAllMocks();
process.exitCode = undefined;
mocks.runGlobalDoctor.mockResolvedValue({
schemaVersion: 1,
scope: "global",
status: "ok",
failed: 0,
warnings: 0,
checks: [],
});
});

afterEach(() => {
vi.restoreAllMocks();
process.exitCode = undefined;
});

it(
"runs the read-only text diagnosis without a sandbox (#10212)",
testTimeoutOptions(30_000),
async () => {
await DoctorCommand.run([], rootDir);

expect(mocks.runGlobalDoctor).toHaveBeenCalledWith();
expect(process.exitCode).toBeUndefined();
},
);

it("returns redacted JSON and a nonzero status for failed checks (#10212)", async () => {
mocks.runGlobalDoctor.mockResolvedValueOnce({
schemaVersion: 1,
scope: "global",
status: "fail",
failed: 1,
warnings: 0,
checks: [
{
group: "Gateway",
label: "OpenShell status",
status: "fail",
detail: "Authorization: Bearer sk-abc123DEF456ghi789",
},
],
});

const report = (await DoctorCommand.run(["--json"], rootDir)) as {
checks: Array<{ detail: string }>;
scope: string;
};

expect(mocks.runGlobalDoctor).toHaveBeenCalledWith({ quiet: true });
expect(process.exitCode).toBe(1);
expect(report.scope).toBe("global");
expect(report.checks[0]?.detail).toBe("Authorization: Bearer <REDACTED>");
expect(JSON.stringify(report)).not.toContain("sk-abc123DEF456ghi789");
});

it("rejects global --fix before running health checks (#10212)", async () => {
await expect(DoctorCommand.run(["--fix"], rootDir)).rejects.toThrow(/fix/i);

expect(mocks.runGlobalDoctor).not.toHaveBeenCalled();
});

it("shows global help without running health checks (#10212)", async () => {
await expect(DoctorCommand.run(["--help"], rootDir)).rejects.toThrow(/EEXIT: 0/);

expect(mocks.runGlobalDoctor).not.toHaveBeenCalled();
});
});
29 changes: 29 additions & 0 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { Command, Flags } from "@oclif/core";
import { runGlobalDoctor } from "../lib/actions/sandbox/doctor";
import { redactForLog, withStdoutRedirectedToStderr } from "../lib/cli/doctor-command-support";

export default class DoctorCommand extends Command {
Comment thread
sandl99 marked this conversation as resolved.
Resolved
static baseFlags = { help: Flags.help({ char: "h" }) };
static id = "doctor";
static strict = true;
static enableJsonFlag = true;
static summary = "Diagnose host and gateway health";
static description =
"Run read-only host, runtime provider, OpenShell CLI, sandbox registry, and NemoClaw gateway checks. Use `<name> doctor` for one sandbox.";
static usage = ["doctor [--json]"];
static examples = ["<%= config.bin %> doctor", "<%= config.bin %> doctor --json"];
static flags = {};

public async run(): Promise<unknown> {
await this.parse(DoctorCommand);
const json = this.jsonEnabled();
const report = json
? await withStdoutRedirectedToStderr(() => runGlobalDoctor({ quiet: true }))
: await runGlobalDoctor();
if (report.failed > 0) process.exitCode = 1;
return json ? redactForLog(report) : undefined;
}
}
6 changes: 4 additions & 2 deletions src/commands/sandbox/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@

import { Args, Flags } from "@oclif/core";
import { runSandboxDoctor } from "../../lib/actions/sandbox/doctor";
import {
redactForLog,
withStdoutRedirectedToStderr,
} from "../../lib/cli/doctor-command-support";
import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command";
import { withStdoutRedirectedToStderr } from "../../lib/cli/stdout-guard";
import { redactForLog } from "../../lib/security/redact";

export default class SandboxDoctorCliCommand extends NemoClawCommand {
static id = "sandbox:doctor";
Expand Down
218 changes: 218 additions & 0 deletions src/lib/actions/doctor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
dockerInspectGateway: vi.fn(),
gatewayDoctorStartHint: vi.fn(),
getNamedGatewayLifecycleState: vi.fn(),
inspectHost: vi.fn(),
listSandboxes: vi.fn(),
recoverNamedGatewayRuntime: vi.fn(),
resolveOpenshell: vi.fn(),
shouldInspectLegacyGatewayContainer: vi.fn(),
}));

vi.mock("../adapters/openshell/resolve", () => ({
resolveOpenshell: mocks.resolveOpenshell,
}));

vi.mock("../gateway-runtime-action", () => ({
getNamedGatewayLifecycleState: mocks.getNamedGatewayLifecycleState,
recoverNamedGatewayRuntime: mocks.recoverNamedGatewayRuntime,
}));

vi.mock("../onboard/gateway-binding", () => ({
resolveGatewayName: () => "nemoclaw",
resolveSandboxGatewayName: vi.fn(),
}));

vi.mock("../onboard/runtime-provider/access", () => ({
CURRENT_RUNTIME_PROVIDER_BUNDLES: [],
RuntimeProviderSelectionError: class RuntimeProviderSelectionError extends Error {},
requireRuntimeProviderBundle: vi.fn(),
resolveCurrentRuntimeProviderBundle: () => ({
preflightDoctor: { inspectHost: mocks.inspectHost },
}),
}));

vi.mock("../runner", () => ({ ROOT: "/repo" }));

vi.mock("../state/registry", () => ({
listSandboxes: mocks.listSandboxes,
}));

vi.mock("./sandbox/doctor-lifecycle-registration", () => ({
buildPortableRuntimeCheck: () => null,
}));

vi.mock("./sandbox/doctor-system-checks", () => ({
dockerInspectGateway: mocks.dockerInspectGateway,
gatewayDoctorStartHint: mocks.gatewayDoctorStartHint,
oneLine: (value = "") => String(value).replace(/\s+/g, " ").trim(),
shouldInspectLegacyGatewayContainer: mocks.shouldInspectLegacyGatewayContainer,
}));

import { runGlobalDoctor } from "./sandbox/doctor";

describe("global doctor action", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(fs, "existsSync").mockReturnValue(true);
mocks.resolveOpenshell.mockReturnValue("/usr/bin/openshell");
mocks.inspectHost.mockReturnValue({
group: "Host",
label: "Runtime provider",
status: "ok",
detail: "available",
});
mocks.listSandboxes.mockReturnValue({ sandboxes: [], defaultSandbox: null });
mocks.gatewayDoctorStartHint.mockReturnValue(
"Start the gateway again with `nemoclaw onboard`. Then retry this command.",
);
mocks.getNamedGatewayLifecycleState.mockReturnValue({
state: "healthy_named",
status: "Status: Connected",
gatewayInfo: "Gateway: nemoclaw",
activeGateway: "nemoclaw",
});
mocks.shouldInspectLegacyGatewayContainer.mockReturnValue(false);
});

afterEach(() => {
vi.restoreAllMocks();
});

it("checks host and gateway health with zero sandboxes without recovery (#10212)", async () => {
const report = await runGlobalDoctor({ quiet: true });

expect(report).toMatchObject({
schemaVersion: 1,
scope: "global",
status: "ok",
failed: 0,
warnings: 0,
});
expect(report).not.toHaveProperty("sandbox");
expect(report.checks).toEqual(
expect.arrayContaining([
expect.objectContaining({ group: "Host", label: "CLI build", status: "ok" }),
expect.objectContaining({ group: "Host", label: "Runtime provider", status: "ok" }),
expect.objectContaining({ group: "Host", label: "OpenShell CLI", status: "ok" }),
expect.objectContaining({ group: "Host", label: "Sandbox registry", status: "ok" }),
expect.objectContaining({ group: "Gateway", label: "OpenShell status", status: "ok" }),
]),
);
expect(report.checks.some((check) => check.group === "Sandbox")).toBe(false);
expect(report.checks.some((check) => check.group === "Inference")).toBe(false);
expect(report.checks.some((check) => check.group === "Messaging")).toBe(false);
expect(report.checks.some((check) => check.group === "Local services")).toBe(false);
expect(mocks.getNamedGatewayLifecycleState).toHaveBeenCalledWith("nemoclaw", {
ignoreProbeErrors: true,
});
expect(mocks.recoverNamedGatewayRuntime).not.toHaveBeenCalled();
});

it("reports a registry read failure without exposing its error text (#10212)", async () => {
mocks.listSandboxes.mockImplementationOnce(() => {
throw new Error("Authorization: Bearer sk-secret-value in /private/registry.json");
});

const report = await runGlobalDoctor({ quiet: true });
const registryCheck = report.checks.find((check) => check.label === "Sandbox registry");

expect(report.status).toBe("fail");
expect(registryCheck).toMatchObject({
status: "fail",
detail: "could not read the host sandbox registry",
});
expect(JSON.stringify(report)).not.toContain("sk-secret-value");
expect(JSON.stringify(report)).not.toContain("/private/registry.json");
});

it("reports skipped gateway health when the OpenShell CLI is missing (#10212)", async () => {
mocks.resolveOpenshell.mockReturnValueOnce(null);

const report = await runGlobalDoctor({ quiet: true });

expect(report.status).toBe("fail");
expect(report.checks).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: "OpenShell CLI", status: "fail" }),
expect.objectContaining({
group: "Gateway",
label: "OpenShell status",
status: "fail",
detail: "skipped because the OpenShell CLI is not installed",
}),
]),
);
expect(mocks.getNamedGatewayLifecycleState).not.toHaveBeenCalled();
expect(mocks.recoverNamedGatewayRuntime).not.toHaveBeenCalled();
});

it("reports invalid gateway management without hiding gateway health (#10212)", async () => {
mocks.gatewayDoctorStartHint.mockImplementationOnce(() => {
throw new Error("invalid declaration at /private/gateway-management.json");
});
mocks.getNamedGatewayLifecycleState.mockReturnValueOnce({
state: "missing_named",
status: "Status: Disconnected",
gatewayInfo: "",
activeGateway: null,
});

const report = await runGlobalDoctor({ quiet: true });

expect(report).toMatchObject({
schemaVersion: 1,
scope: "global",
status: "fail",
});
expect(report.checks).toEqual(
expect.arrayContaining([
expect.objectContaining({
group: "Gateway",
label: "Gateway management",
status: "fail",
hint: "check the gateway-management declaration file permissions and JSON, then retry",
}),
expect.objectContaining({
group: "Gateway",
label: "OpenShell status",
status: "fail",
hint: "check the gateway-management declaration file permissions and JSON, then retry",
}),
]),
);
expect(JSON.stringify(report)).not.toContain("/private/gateway-management.json");
expect(mocks.getNamedGatewayLifecycleState).toHaveBeenCalledWith("nemoclaw", {
ignoreProbeErrors: true,
});
expect(mocks.recoverNamedGatewayRuntime).not.toHaveBeenCalled();
});

it("renders actionable text without naming a sandbox (#10212)", async () => {
mocks.getNamedGatewayLifecycleState.mockReturnValueOnce({
state: "missing_named",
status: "Status: Disconnected",
gatewayInfo: "",
activeGateway: null,
});
const lines: string[] = [];
vi.spyOn(console, "log").mockImplementation((line = "") => lines.push(String(line)));

const report = await runGlobalDoctor();
const output = lines.join("\n");

expect(report.status).toBe("fail");
expect(output).toContain("NemoClaw doctor");
expect(output).not.toContain("NemoClaw doctor:");
expect(output).toContain(
"Start the gateway again with `nemoclaw onboard`. Then retry this command.",
);
});
});
Loading
Loading