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
4 changes: 2 additions & 2 deletions ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"src/lib/adapters/openshell/timeouts.ts": 33,
"src/lib/agent/defs.ts": 32,
"src/lib/cli/branding.ts": 80,
"src/lib/cli/nemoclaw-oclif-command.ts": 104,
"src/lib/cli/nemoclaw-oclif-command.ts": 105,
"src/lib/cli/terminal-style.ts": 42,
"src/lib/core/json-types.ts": 34,
"src/lib/core/ports.ts": 89,
Expand All @@ -25,7 +25,7 @@
"src/lib/messaging/channels/index.ts": 25,
"src/lib/onboard/gateway-binding.ts": 53,
"src/lib/runner.ts": 84,
"src/lib/security/redact.ts": 53,
"src/lib/security/redact.ts": 52,
"src/lib/state/mcp-lifecycle-lock.ts": 21,
"src/lib/state/onboard-session.ts": 35,
"src/lib/state/registry.ts": 96,
Expand Down
23 changes: 23 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1596,6 +1596,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. If a sandbox is named `doctor`, use `$$nemoclaw doctor --text` to select the global text report explicitly; bare `$$nemoclaw doctor` keeps the existing name-first sandbox connection behavior.

<AgentOnly variant="openclaw,hermes">
For gateway-based agents, it also reports messaging channel conflicts within the selected
Expand Down Expand Up @@ -3171,6 +3172,28 @@ 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.
If a sandbox is named `doctor`, bare `$$nemoclaw doctor` connects to that sandbox under the name-first grammar. Use `$$nemoclaw doctor --text` for the global human-readable report or `$$nemoclaw doctor --json` for the global JSON report.

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

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. |
| `--text` | Emit the global human-readable report explicitly. |

### `$$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
90 changes: 90 additions & 0 deletions src/commands/doctor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// 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", async (importOriginal) => ({
...(await importOriginal<typeof import("../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(["--text"], 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();
});
});
38 changes: 38 additions & 0 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { Flags } from "@oclif/core";
import { redactDoctorReport, runGlobalDoctor } from "../lib/actions/sandbox/doctor";
import { NemoClawCommand } from "../lib/cli/nemoclaw-oclif-command";
import { withStdoutRedirectedToStderr } from "../lib/cli/stdout-guard";

export default class DoctorCommand extends NemoClawCommand {
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|--text]"];
static examples = [
"<%= config.bin %> doctor",
"<%= config.bin %> doctor --text",
"<%= config.bin %> doctor --json",
];
static flags = {
text: Flags.boolean({
description: "Emit the human-readable report explicitly",
exclusive: ["json"],
}),
};

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 ? redactDoctorReport(report) : undefined;
}
}
5 changes: 2 additions & 3 deletions src/commands/sandbox/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@
// SPDX-License-Identifier: Apache-2.0

import { Args, Flags } from "@oclif/core";
import { runSandboxDoctor } from "../../lib/actions/sandbox/doctor";
import { redactDoctorReport, runSandboxDoctor } from "../../lib/actions/sandbox/doctor";
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 Expand Up @@ -53,7 +52,7 @@ export default class SandboxDoctorCliCommand extends NemoClawCommand {
// report itself so programmatic consumers of the resolved value — not
// just the logJson-printed stdout (#3657) — never see token-shaped
// values in check details.
return redactForLog(report);
return redactDoctorReport(report);
}
const doctorArgs = flags.fix ? ["--fix"] : [];
await runSandboxDoctor(args.sandboxName, doctorArgs, { quietJson: false });
Expand Down
3 changes: 2 additions & 1 deletion src/commands/sandbox/oclif-command-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ vi.mock("../../lib/sandbox/config", () => ({
SandboxConfigError: mocks.SandboxConfigError,
}));

vi.mock("../../lib/actions/sandbox/doctor", () => ({
vi.mock("../../lib/actions/sandbox/doctor", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/actions/sandbox/doctor")>()),
runSandboxDoctor: mocks.runSandboxDoctor,
}));

Expand Down
Loading
Loading