Repository navigation
fix(cli): add global host and gateway doctor #11012
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 5 commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
4f4c02b
fix(cli): add global host and gateway doctor
prekshivyas b764829
fix(cli): keep global doctor read-only and redacted
prekshivyas b044994
fix(cli): centralize global doctor routing
prekshivyas 081763f
fix(cli): report invalid gateway management in doctor
prekshivyas 97b4ee3
Merge branch 'main' into codex/fix-global-doctor-10212
prekshivyas bf4c9ca
Merge branch 'main' into codex/fix-global-doctor-10212
sandl99 965eab2
fix(cli): address global doctor review findings
sandl99 39d800b
Merge branch 'main' into codex/fix-global-doctor-10212
prekshivyas 0d233f9
Merge remote-tracking branch 'origin/main' into codex/pr11012-finish
ericksoa 6f10104
fix(cli): preserve global doctor boundaries
ericksoa cba85aa
fix(cli): migrate colliding doctor probe route
ericksoa 3098bac
fix(cli): disambiguate global doctor text output
ericksoa f9fc6c8
fix(cli): preserve legacy doctor sandbox routing
ericksoa 6a85a8f
test(cli): preserve doctor report exports in adapter mock
ericksoa 36349c3
test(cli): cover pending doctor migration intent
ericksoa File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 { | ||
| 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; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.", | ||
| ); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.