diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 3d54a051797..c4066681055 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -1633,16 +1633,24 @@ If cloudflared is installed but not running, the host-service section reports wh Show the active live inference provider and model from the NemoClaw-managed OpenShell gateway. Use this command when you want the direct runtime route without the rest of the sandbox status output. +It is also available in sandbox-first form as `nemohermes inference get`. ```bash nemohermes inference get nemohermes inference get --json ``` +The sandbox-first grammar `nemohermes inference get` is also accepted and reads the same gateway-wide route, so it stays symmetric with `nemohermes inference set`. + +```bash +nemohermes my-assistant inference get +``` + ### `nemohermes inference set` Switch the active inference provider or model for a NemoClaw-managed OpenClaw or Hermes sandbox. The command updates the OpenShell gateway route, patches the selected running agent config so it matches the route, recomputes the config hash, and updates the NemoClaw registry. +It is also available in sandbox-first form as `nemohermes inference set --provider --model `. For Hermes, the patch updates `/sandbox/.hermes/config.yaml` (`model.default`, `model.base_url`, `model.provider: custom`, API-family mode when needed, and the OpenShell proxy API-key placeholder) and does not rebuild or restart the gateway. Keeping the placeholder preserves dashboard and API authentication after provider switches. @@ -1656,6 +1664,13 @@ Run `nemohermes shields down`, apply the inference change, then run `nemo nemohermes inference set --provider --model [--sandbox ] [--no-verify] [--endpoint-url ] [--credential-env ] [--inference-api ] ``` +You can also name the sandbox in sandbox-first position instead of passing `--sandbox`. +`nemohermes inference set --provider --model ` targets `` directly and is equivalent to `nemohermes inference set --provider --model --sandbox `. + +```bash +nemohermes my-assistant inference set --provider nvidia-prod --model nvidia/nemotron-3-super-120b-a12b +``` + Pass both `--provider` and `--model` when you want NemoClaw to update the OpenShell inference route and sync the selected sandbox's agent config. If you only want the lower-level OpenShell route operation, run `openshell inference set -g nemoclaw --model --provider ` directly. When either flag is missing, `nemohermes inference set` prints that OpenShell command instead of an oclif flag-validation error. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 926127f9078..c8acb2341f2 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -2011,16 +2011,24 @@ If cloudflared is installed but not running, the host-service section reports wh Show the active live inference provider and model from the NemoClaw-managed OpenShell gateway. Use this command when you want the direct runtime route without the rest of the sandbox status output. +It is also available in sandbox-first form as `$$nemoclaw inference get`. ```bash $$nemoclaw inference get $$nemoclaw inference get --json ``` +The sandbox-first grammar `$$nemoclaw inference get` is also accepted and reads the same gateway-wide route, so it stays symmetric with `$$nemoclaw inference set`. + +```bash +$$nemoclaw my-assistant inference get +``` + ### `$$nemoclaw inference set` Switch the active inference provider or model for a NemoClaw-managed OpenClaw or Hermes sandbox. The command updates the OpenShell gateway route, patches the selected running agent config so it matches the route, recomputes the config hash, and updates the NemoClaw registry. +It is also available in sandbox-first form as `$$nemoclaw inference set --provider --model `. @@ -2043,6 +2051,13 @@ Run `$$nemoclaw shields down`, apply the inference change, then run `$$ne $$nemoclaw inference set --provider --model [--sandbox ] [--no-verify] [--endpoint-url ] [--credential-env ] [--inference-api ] ``` +You can also name the sandbox in sandbox-first position instead of passing `--sandbox`. +`$$nemoclaw inference set --provider --model ` targets `` directly and is equivalent to `$$nemoclaw inference set --provider --model --sandbox `. + +```bash +$$nemoclaw my-assistant inference set --provider nvidia-prod --model nvidia/nemotron-3-super-120b-a12b +``` + Pass both `--provider` and `--model` when you want NemoClaw to update the OpenShell inference route and sync the selected sandbox's agent config. If you only want the lower-level OpenShell route operation, run `openshell inference set -g nemoclaw --model --provider ` directly. When either flag is missing, `$$nemoclaw inference set` prints that OpenShell command instead of an oclif flag-validation error. diff --git a/src/commands/inference/set.ts b/src/commands/inference/set.ts index af75128638d..6d5398b7c44 100644 --- a/src/commands/inference/set.ts +++ b/src/commands/inference/set.ts @@ -3,21 +3,13 @@ import { Flags } from "@oclif/core"; -function nonEmptyFlag(description: string) { - return Flags.string({ - description, - parse: async (input: string) => { - const trimmed = input.trim(); - if (!trimmed) throw new Error(`${description} cannot be empty`); - return trimmed; - }, - }); -} - import { InferenceSetError, runInferenceSet } from "../../lib/actions/inference-set"; import { CLI_NAME } from "../../lib/cli/branding"; +import { nonEmptyFlag } from "../../lib/cli/flag-helpers"; import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; +// Global inference:set is paired with the sandbox-first sandbox:inference:set +// command; both delegate to the shared runInferenceSet action. export default class InferenceSetCommand extends NemoClawCommand { static id = "inference:set"; static strict = true; diff --git a/src/commands/sandbox/inference/get.ts b/src/commands/sandbox/inference/get.ts new file mode 100644 index 00000000000..8f5e5d3659f --- /dev/null +++ b/src/commands/sandbox/inference/get.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { InferenceGetError, runInferenceGet } from "../../../lib/actions/inference-get"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; +import { sandboxNameArg } from "../../../lib/sandbox/command-support"; + +// Sandbox-first mirror of the global inference:get command; both delegate to +// the shared runInferenceGet action that reads the gateway-wide route. +export default class SandboxInferenceGetCommand extends NemoClawCommand { + static id = "sandbox:inference:get"; + static strict = true; + static enableJsonFlag = true; + static summary = "Show the active NemoClaw inference route"; + static description = + "Read the live OpenShell inference route through the NemoClaw CLI. The route is gateway-wide; the sandbox name is accepted so the sandbox-scoped grammar mirrors `inference set`."; + static usage = [" inference get [--json]"]; + static examples = [ + "<%= config.bin %> my-assistant inference get", + "<%= config.bin %> my-assistant inference get --json", + ]; + static args = { + sandboxName: sandboxNameArg, + }; + static flags = {}; + + public async run(): Promise { + await this.parse(SandboxInferenceGetCommand); + try { + const result = await runInferenceGet({ quiet: this.jsonEnabled() }); + if (this.jsonEnabled()) return result; + } catch (error) { + if (error instanceof InferenceGetError) { + this.failWithLines([error.message], error.exitCode); + return; + } + throw error; + } + } +} diff --git a/src/commands/sandbox/inference/oclif-command-adapters.test.ts b/src/commands/sandbox/inference/oclif-command-adapters.test.ts new file mode 100644 index 00000000000..c3d96e6ee9c --- /dev/null +++ b/src/commands/sandbox/inference/oclif-command-adapters.test.ts @@ -0,0 +1,374 @@ +// 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"; + +const mocks = vi.hoisted(() => ({ + runInferenceGet: vi.fn(), + runInferenceSet: vi.fn(), +})); + +vi.mock("../../../lib/actions/inference-set", () => ({ + InferenceSetError: class InferenceSetError extends Error { + exitCode: number; + + constructor(message: string, exitCode = 1) { + super(message); + this.name = "InferenceSetError"; + this.exitCode = exitCode; + } + }, + runInferenceSet: mocks.runInferenceSet, +})); + +vi.mock("../../../lib/actions/inference-get", () => ({ + InferenceGetError: class InferenceGetError extends Error { + exitCode: number; + + constructor(message: string, exitCode = 1) { + super(message); + this.name = "InferenceGetError"; + this.exitCode = exitCode; + } + }, + runInferenceGet: mocks.runInferenceGet, +})); + +import { InferenceGetError } from "../../../lib/actions/inference-get"; +import { InferenceSetError } from "../../../lib/actions/inference-set"; +import SandboxInferenceGetCommand from "./get"; +import SandboxInferenceSetCommand from "./set"; + +const rootDir = process.cwd(); + +describe("sandbox inference oclif command adapters (#5977)", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.runInferenceSet.mockResolvedValue({ + sandboxName: "alpha", + provider: "nvidia-prod", + model: "nvidia/model-a", + primaryModelRef: "inference/nvidia/model-a", + providerKey: "inference", + configChanged: true, + sessionUpdated: false, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("forwards the positional sandbox name and custom-provider flags to runInferenceSet", async () => { + await SandboxInferenceSetCommand.run( + [ + "alpha", + "--provider", + "compatible-endpoint", + "--model", + "nvidia/nemotron-3-super-120b-a12b", + "--no-verify", + "--endpoint-url", + "https://example.test/v1", + "--credential-env", + "COMPATIBLE_API_KEY", + "--inference-api", + "openai-completions", + ], + rootDir, + ); + + expect(mocks.runInferenceSet).toHaveBeenCalledWith({ + provider: "compatible-endpoint", + model: "nvidia/nemotron-3-super-120b-a12b", + sandboxName: "alpha", + noVerify: true, + endpointUrl: "https://example.test/v1", + credentialEnv: "COMPATIBLE_API_KEY", + inferenceApi: "openai-completions", + }); + }); + + it("prints the missing-flags redirect without calling runInferenceSet", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const previousExitCode = process.exitCode; + process.exitCode = undefined; + try { + await expect(SandboxInferenceSetCommand.run(["alpha"], rootDir)).resolves.toBeUndefined(); + + expect(mocks.runInferenceSet).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(error).toHaveBeenCalledWith( + expect.stringContaining("inference set requires --provider and --model"), + ); + } finally { + process.exitCode = previousExitCode; + error.mockRestore(); + } + }); + + it("rejects an empty --provider before runInferenceSet is called", async () => { + await expect( + SandboxInferenceSetCommand.run( + ["alpha", "--provider", " ", "--model", "nvidia/model-a"], + rootDir, + ), + ).rejects.toThrow(/provider name cannot be empty/i); + + expect(mocks.runInferenceSet).not.toHaveBeenCalled(); + }); + + it("rejects an empty --model before runInferenceSet is called (#5977)", async () => { + await expect( + SandboxInferenceSetCommand.run( + ["alpha", "--provider", "nvidia-prod", "--model", " "], + rootDir, + ), + ).rejects.toThrow(/model id .* cannot be empty/i); + + expect(mocks.runInferenceSet).not.toHaveBeenCalled(); + }); + + it("maps the sandbox inference get --json output into oclif JSON handling", async () => { + mocks.runInferenceGet.mockResolvedValueOnce({ + provider: "nvidia-prod", + model: "nvidia/model-a", + }); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + try { + await SandboxInferenceGetCommand.run(["alpha", "--json"], rootDir); + + expect(mocks.runInferenceGet).toHaveBeenCalledWith({ quiet: true }); + expect(JSON.parse(String(log.mock.calls.at(-1)?.[0]))).toEqual({ + provider: "nvidia-prod", + model: "nvidia/model-a", + }); + } finally { + log.mockRestore(); + } + }); + + it("surfaces the 'route not configured' get failure with its message and exit code (#5977)", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const previousExitCode = process.exitCode; + process.exitCode = undefined; + try { + mocks.runInferenceGet.mockRejectedValueOnce( + new InferenceGetError("OpenShell inference route is not configured.", 1), + ); + + await expect(SandboxInferenceGetCommand.run(["alpha"], rootDir)).resolves.toBeUndefined(); + expect(process.exitCode).toBe(1); + expect(error).toHaveBeenCalledWith("OpenShell inference route is not configured."); + } finally { + process.exitCode = previousExitCode; + error.mockRestore(); + } + }); + + it("surfaces a typed set validation failure (unsupported provider) with its exit code (#5977)", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const previousExitCode = process.exitCode; + process.exitCode = undefined; + try { + mocks.runInferenceSet.mockRejectedValueOnce( + new InferenceSetError("Unsupported inference provider 'bogus-provider'.", 2), + ); + + await expect( + SandboxInferenceSetCommand.run( + ["alpha", "--provider", "bogus-provider", "--model", "nvidia/model-a"], + rootDir, + ), + ).resolves.toBeUndefined(); + expect(process.exitCode).toBe(2); + expect(error).toHaveBeenCalledWith("Unsupported inference provider 'bogus-provider'."); + } finally { + process.exitCode = previousExitCode; + error.mockRestore(); + } + }); + + it("surfaces a typed set validation failure (unsafe model id) with its exit code (#5977)", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const previousExitCode = process.exitCode; + process.exitCode = undefined; + try { + mocks.runInferenceSet.mockRejectedValueOnce( + new InferenceSetError("Unsafe model id 'nvidia/model a'.", 2), + ); + + await expect( + SandboxInferenceSetCommand.run( + ["alpha", "--provider", "nvidia-prod", "--model", "nvidia/model a"], + rootDir, + ), + ).resolves.toBeUndefined(); + expect(process.exitCode).toBe(2); + expect(error).toHaveBeenCalledWith("Unsafe model id 'nvidia/model a'."); + } finally { + process.exitCode = previousExitCode; + error.mockRestore(); + } + }); + + it("surfaces typed endpoint-url validation failures from the action layer with exit code 2 (#5977)", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const previousExitCode = process.exitCode; + process.exitCode = undefined; + try { + mocks.runInferenceSet.mockRejectedValueOnce( + new InferenceSetError("Custom endpoint URL must use http(s).", 2), + ); + await expect( + SandboxInferenceSetCommand.run( + [ + "alpha", + "--provider", + "compatible-endpoint", + "--model", + "m", + "--endpoint-url", + "ftp://x", + ], + rootDir, + ), + ).resolves.toBeUndefined(); + expect(process.exitCode).toBe(2); + expect(error).toHaveBeenLastCalledWith("Custom endpoint URL must use http(s)."); + + mocks.runInferenceSet.mockRejectedValueOnce( + new InferenceSetError("Custom endpoint URL must not embed credentials.", 2), + ); + await expect( + SandboxInferenceSetCommand.run( + [ + "alpha", + "--provider", + "compatible-endpoint", + "--model", + "m", + "--endpoint-url", + "https://u:p@x/v1", + ], + rootDir, + ), + ).resolves.toBeUndefined(); + expect(process.exitCode).toBe(2); + expect(error).toHaveBeenLastCalledWith("Custom endpoint URL must not embed credentials."); + + mocks.runInferenceSet.mockRejectedValueOnce( + new InferenceSetError("Custom endpoint URL must include a scheme.", 2), + ); + await expect( + SandboxInferenceSetCommand.run( + ["alpha", "--provider", "compatible-endpoint", "--model", "m", "--endpoint-url", "x/v1"], + rootDir, + ), + ).resolves.toBeUndefined(); + expect(process.exitCode).toBe(2); + expect(error).toHaveBeenLastCalledWith("Custom endpoint URL must include a scheme."); + } finally { + process.exitCode = previousExitCode; + error.mockRestore(); + } + }); + + it("surfaces typed credential-env, inference-api, and metadata validation failures with exit code 2 (#5977)", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const previousExitCode = process.exitCode; + process.exitCode = undefined; + try { + mocks.runInferenceSet.mockRejectedValueOnce( + new InferenceSetError( + "credential-env must be COMPATIBLE_API_KEY for compatible-endpoint.", + 2, + ), + ); + await expect( + SandboxInferenceSetCommand.run( + [ + "alpha", + "--provider", + "compatible-endpoint", + "--model", + "m", + "--credential-env", + "SOME_OTHER_KEY", + ], + rootDir, + ), + ).resolves.toBeUndefined(); + expect(process.exitCode).toBe(2); + expect(error).toHaveBeenLastCalledWith( + "credential-env must be COMPATIBLE_API_KEY for compatible-endpoint.", + ); + + mocks.runInferenceSet.mockRejectedValueOnce( + new InferenceSetError("inference-api 'bogus-api' is not supported.", 2), + ); + await expect( + SandboxInferenceSetCommand.run( + [ + "alpha", + "--provider", + "compatible-endpoint", + "--model", + "m", + "--inference-api", + "bogus-api", + ], + rootDir, + ), + ).resolves.toBeUndefined(); + expect(process.exitCode).toBe(2); + expect(error).toHaveBeenLastCalledWith("inference-api 'bogus-api' is not supported."); + + mocks.runInferenceSet.mockRejectedValueOnce( + new InferenceSetError( + "Custom endpoint metadata is only allowed for compatible providers.", + 2, + ), + ); + await expect( + SandboxInferenceSetCommand.run( + ["alpha", "--provider", "nvidia-prod", "--model", "m", "--endpoint-url", "https://x/v1"], + rootDir, + ), + ).resolves.toBeUndefined(); + expect(process.exitCode).toBe(2); + expect(error).toHaveBeenLastCalledWith( + "Custom endpoint metadata is only allowed for compatible providers.", + ); + } finally { + process.exitCode = previousExitCode; + error.mockRestore(); + } + }); + + it("records typed inference action failures without throwing oclif ExitError", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const previousExitCode = process.exitCode; + process.exitCode = undefined; + try { + mocks.runInferenceGet.mockRejectedValueOnce(new InferenceGetError("route missing", 3)); + mocks.runInferenceSet.mockRejectedValueOnce(new InferenceSetError("route rejected", 4)); + + await expect(SandboxInferenceGetCommand.run(["alpha"], rootDir)).resolves.toBeUndefined(); + expect(process.exitCode).toBe(3); + expect(error).toHaveBeenCalledWith("route missing"); + + await expect( + SandboxInferenceSetCommand.run( + ["alpha", "--provider", "nvidia-prod", "--model", "nvidia/model-a"], + rootDir, + ), + ).resolves.toBeUndefined(); + expect(process.exitCode).toBe(4); + expect(error).toHaveBeenCalledWith("route rejected"); + } finally { + process.exitCode = previousExitCode; + error.mockRestore(); + } + }); +}); diff --git a/src/commands/sandbox/inference/set.ts b/src/commands/sandbox/inference/set.ts new file mode 100644 index 00000000000..3631c0fa87a --- /dev/null +++ b/src/commands/sandbox/inference/set.ts @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Flags } from "@oclif/core"; + +import { InferenceSetError, runInferenceSet } from "../../../lib/actions/inference-set"; +import { CLI_NAME } from "../../../lib/cli/branding"; +import { nonEmptyFlag } from "../../../lib/cli/flag-helpers"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; +import { sandboxNameArg } from "../../../lib/sandbox/command-support"; + +// Sandbox-first mirror of the global inference:set command; both delegate to +// the shared runInferenceSet action. Flags only enforce the non-empty contract +// here — deep validation (provider allowlist, model id charset, custom endpoint +// URL/credential/API normalization) is intentionally centralized in +// runInferenceSet so the global and sandbox-first grammars share one +// validation surface (covered by test/lib/actions/inference-set.test.ts). +export default class SandboxInferenceSetCommand extends NemoClawCommand { + static id = "sandbox:inference:set"; + static strict = true; + static summary = "Switch the NemoClaw inference model"; + static description = + "Update the OpenShell inference route and sync the named OpenClaw or Hermes sandbox config. Mirrors `inference set --sandbox ` with the sandbox name in sandbox-first position."; + static usage = [ + " inference set --provider --model [--no-verify] [--endpoint-url ] [--credential-env ] [--inference-api ]", + ]; + static examples = [ + "<%= config.bin %> my-assistant inference set --provider nvidia-prod --model nvidia/nemotron-3-super-120b-a12b", + "<%= config.bin %> my-assistant inference set --provider openai-api --model gpt-5.4", + ]; + static args = { + sandboxName: sandboxNameArg, + }; + static flags = { + provider: nonEmptyFlag("OpenShell inference provider name"), + model: nonEmptyFlag("Model id to route through the selected provider"), + "no-verify": Flags.boolean({ + description: "Pass --no-verify through to openshell inference set", + }), + "endpoint-url": Flags.string({ + description: "Trusted endpoint URL to persist when switching to a compatible custom provider", + }), + "credential-env": Flags.string({ + description: + "Trusted credential env name to persist when switching to a compatible custom provider", + }), + "inference-api": Flags.string({ + description: + "Trusted API family to persist for compatible custom providers (openai-completions, anthropic-messages, openai-responses)", + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(SandboxInferenceSetCommand); + if (!flags.provider || !flags.model) { + this.printOpenShellRedirect(); + return; + } + try { + await runInferenceSet({ + provider: flags.provider, + model: flags.model, + sandboxName: args.sandboxName, + noVerify: flags["no-verify"] === true, + endpointUrl: flags["endpoint-url"] ?? null, + credentialEnv: flags["credential-env"] ?? null, + inferenceApi: flags["inference-api"] ?? null, + }); + } catch (error) { + if (error instanceof InferenceSetError) { + this.failWithLines([error.message], error.exitCode); + return; + } + throw error; + } + } + + private printOpenShellRedirect(): void { + this.failWithLines( + [ + ` ${CLI_NAME} inference set requires --provider and --model.`, + "", + " To change only the OpenShell route, run:", + " openshell inference set -g nemoclaw --model --provider ", + ` To also sync the sandbox config, pass --provider and --model to ${CLI_NAME} inference set.`, + "", + ` Run '${CLI_NAME} help' for NemoClaw commands.`, + ], + 1, + ); + } +} diff --git a/src/lib/cli/flag-helpers.ts b/src/lib/cli/flag-helpers.ts new file mode 100644 index 00000000000..4adb16a0e23 --- /dev/null +++ b/src/lib/cli/flag-helpers.ts @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Flags } from "@oclif/core"; + +/** + * Build a string oclif flag that trims its input and rejects empty or + * whitespace-only values. Shared by the global `inference set` command and its + * sandbox-first mirror so both enforce the same non-empty contract at the + * command boundary before delegating deeper validation to the shared inference + * action layer. + */ +export function nonEmptyFlag(description: string) { + return Flags.string({ + description, + parse: async (input: string) => { + const trimmed = input.trim(); + if (!trimmed) throw new Error(`${description} cannot be empty`); + return trimmed; + }, + }); +} diff --git a/src/lib/cli/public-display-defaults.ts b/src/lib/cli/public-display-defaults.ts index 640a15b21ac..8c15407c53a 100644 --- a/src/lib/cli/public-display-defaults.ts +++ b/src/lib/cli/public-display-defaults.ts @@ -315,6 +315,23 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { flags: "(--dry-run)", }, ], + "sandbox:inference:get": [ + { + group: "Services", + order: 36.1, + flags: "[--json]", + hidden: true, + }, + ], + "sandbox:inference:set": [ + { + group: "Services", + order: 37.1, + description: "Switch inference and sync the named agent config", + flags: "--provider --model [--no-verify]", + hidden: true, + }, + ], "sandbox:logs": [ { group: "Sandbox Management", diff --git a/test/cli/dispatch-basics.test.ts b/test/cli/dispatch-basics.test.ts index 37e64f49015..ec3fe2d9569 100644 --- a/test/cli/dispatch-basics.test.ts +++ b/test/cli/dispatch-basics.test.ts @@ -215,6 +215,69 @@ describe("CLI dispatch", () => { expect(r.out.includes("Unknown command")).toBeTruthy(); }); + it("routes a missing-sandbox inference action through name validation, not Unknown action (#5977)", () => { + // `inference` is a known sandbox action token, so a missing sandbox name + // must surface the sandbox-not-found path — never the NemoClaw-owned + // `Unknown action: inference` reporter that originally broke the workflow. + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-inference-missing-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + fs.writeFileSync( + path.join(localBin, "openshell"), + ["#!/usr/bin/env bash", "exit 1"].join("\n"), + { mode: 0o755 }, + ); + + try { + const r = runWithEnv( + "missing-sb inference get", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NEMOCLAW_HEALTH_POLL_COUNT: "0", + }, + execTimeout(30_000), + ); + expect(r.code).toBe(1); + expect(r.out).toContain("Sandbox 'missing-sb' does not exist"); + expect(r.out).not.toContain("Unknown action: inference"); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it("lists inference among Valid actions when reporting an unknown sandbox action (#5977)", () => { + // The reporter-facing action list is derived from registered sandbox + // commands; the new sandbox-scoped inference route must appear there so + // users discover it instead of hitting the old dead end. + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-inference-valid-actions-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + fs.writeFileSync( + path.join(localBin, "openshell"), + ["#!/usr/bin/env bash", "exit 1"].join("\n"), + { mode: 0o755 }, + ); + writeSandboxRegistry(home, "alpha"); + + try { + const r = runWithEnv( + "alpha bogus-action-5977", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NEMOCLAW_HEALTH_POLL_COUNT: "0", + }, + execTimeout(30_000), + ); + expect(r.code).toBe(1); + expect(r.out).toContain("Unknown action: bogus-action-5977"); + expect(r.out).toMatch(/Valid actions:.*\binference\b/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + it("points OpenShell-only commands at openshell instead of sandbox connect (#3388)", () => { const term = run("term"); expect(term.code).toBe(1); diff --git a/test/package-contract/cli/command-registry.test.ts b/test/package-contract/cli/command-registry.test.ts index 9ecb1ec7a66..4a41297396a 100644 --- a/test/package-contract/cli/command-registry.test.ts +++ b/test/package-contract/cli/command-registry.test.ts @@ -56,13 +56,14 @@ describe("command-registry", () => { }); describe("sandboxCommands()", () => { - it("should return exactly 50 entries", () => { - // 44 visible + 6 hidden (shields×3 + config get/set/rotate-token). - // 44 visible includes the sessions group (root + list + reset + delete + - // export), the agents quartet (add + apply + delete + list), the - // singular `agent` passthrough that forwards to `openclaw agent`, and - // the download + upload host-side openshell wrappers. - expect(sandboxCommands()).toHaveLength(50); + it("should return exactly 52 entries", () => { + // 44 visible + 8 hidden (shields×3 + config get/set/rotate-token + + // inference get/set). 44 visible includes the sessions group (root + + // list + reset + delete + export), the agents quartet (add + apply + + // delete + list), the singular `agent` passthrough that forwards to + // `openclaw agent`, and the download + upload host-side openshell + // wrappers. + expect(sandboxCommands()).toHaveLength(52); }); it("every entry has scope sandbox", () => { @@ -85,9 +86,9 @@ describe("command-registry", () => { }); describe("hidden commands", () => { - it("exactly 12 hidden commands: help/version aliases + shields + config", () => { + it("exactly 14 hidden commands: help/version aliases + shields + config + inference", () => { const hidden = COMMANDS.filter((c) => c.hidden); - expect(hidden).toHaveLength(12); + expect(hidden).toHaveLength(14); const usages = hidden.map((c) => c.usage).sort(); expect(usages).toEqual([ "nemoclaw --help", @@ -97,6 +98,8 @@ describe("command-registry", () => { "nemoclaw config get", "nemoclaw config rotate-token", "nemoclaw config set", + "nemoclaw inference get", + "nemoclaw inference set", "nemoclaw shields down", "nemoclaw shields status", "nemoclaw shields up", @@ -218,9 +221,9 @@ describe("command-registry", () => { }); describe("sandboxActionTokens()", () => { - it("returns exactly 30 unique action tokens including empty string", () => { + it("returns exactly 31 unique action tokens including empty string", () => { const tokens = sandboxActionTokens(); - expect(tokens).toHaveLength(30); + expect(tokens).toHaveLength(31); // Must contain every first-level sandbox action plus the empty default action. const expected = new Set([ "agent", @@ -231,6 +234,7 @@ describe("command-registry", () => { "exec", "status", "doctor", + "inference", "logs", "policy-add", "policy-explain", diff --git a/test/package-contract/cli/public-argv-translation.test.ts b/test/package-contract/cli/public-argv-translation.test.ts index e6b075ebc64..f7bbd4336a6 100644 --- a/test/package-contract/cli/public-argv-translation.test.ts +++ b/test/package-contract/cli/public-argv-translation.test.ts @@ -211,6 +211,54 @@ describe("translatePublicSandboxArgv", () => { ); }); + it("translates sandbox-scoped inference get/set to native oclif argv (#5977)", () => { + expectNative( + translatePublicSandboxArgv("hermes-sb-5977", "inference", ["get"]), + "sandbox:inference:get", + ["hermes-sb-5977"], + ); + expectNative( + translatePublicSandboxArgv("hermes-sb-5977", "inference", ["get", "--json"]), + "sandbox:inference:get", + ["hermes-sb-5977", "--json"], + ); + expectNative( + translatePublicSandboxArgv("hermes-sb-5977", "inference", [ + "set", + "--provider", + "nvidia-prod", + "--model", + "nvidia/nemotron-3-super-120b-a12b", + ]), + "sandbox:inference:set", + [ + "hermes-sb-5977", + "--provider", + "nvidia-prod", + "--model", + "nvidia/nemotron-3-super-120b-a12b", + ], + ); + }); + + it("routes bare/help sandbox-scoped inference to the oclif parent like config does (#5977)", () => { + // `inference` exposes only get/set leaves (no `sandbox:inference` parent), + // so bare and --help forms defer to oclif exactly as `config` does above — + // never the NemoClaw-owned `Unknown action` path that broke this workflow. + expectNative( + translatePublicSandboxArgv("hermes-sb-5977", "inference", ["--help"]), + "sandbox:inference", + ["--help"], + ["sandbox", "inference", "--help"], + ); + expectNative( + translatePublicSandboxArgv("hermes-sb-5977", "inference", []), + "sandbox:inference", + ["--help"], + ["sandbox", "inference", "--help"], + ); + }); + it("translates nested sandbox subcommands and defaults", () => { expectNative(translatePublicSandboxArgv("alpha", "channels", []), "sandbox:channels:list", [ "alpha",