diff --git a/docs/inference/switch-inference-providers.md b/docs/inference/switch-inference-providers.md index 03923bf7dbc..4797dce72f0 100644 --- a/docs/inference/switch-inference-providers.md +++ b/docs/inference/switch-inference-providers.md @@ -34,32 +34,33 @@ No restart is required. ## Switch to a Different Model -Switching happens through the OpenShell inference route. -Use the provider and model that match the upstream you want to use. -This is one of the cases where a NemoClaw workflow intentionally uses `openshell`; see [CLI Selection Guide](../reference/cli-selection-guide.md) for the general boundary. +Use `nemoclaw inference set` with the provider and model that match the upstream you want to use. +The command updates the OpenShell inference route and synchronizes the running OpenClaw config so `agents.defaults.model.primary` continues to match the routed model. + +Pass `--sandbox ` when you do not want to use the default registered sandbox. ### NVIDIA Endpoints ```console -$ openshell inference set --provider nvidia-prod --model nvidia/nemotron-3-super-120b-a12b +$ nemoclaw inference set --provider nvidia-prod --model nvidia/nemotron-3-super-120b-a12b ``` ### OpenAI ```console -$ openshell inference set --provider openai-api --model gpt-5.4 +$ nemoclaw inference set --provider openai-api --model gpt-5.4 ``` ### Anthropic ```console -$ openshell inference set --provider anthropic-prod --model claude-sonnet-4-6 +$ nemoclaw inference set --provider anthropic-prod --model claude-sonnet-4-6 ``` ### Google Gemini ```console -$ openshell inference set --provider gemini-api --model gemini-2.5-flash +$ nemoclaw inference set --provider gemini-api --model gemini-2.5-flash ``` ### Compatible Endpoints @@ -67,15 +68,13 @@ $ openshell inference set --provider gemini-api --model gemini-2.5-flash If you onboarded a custom compatible endpoint, switch models with the provider created for that endpoint: ```console -$ openshell inference set --provider compatible-endpoint --model +$ nemoclaw inference set --provider compatible-endpoint --model ``` ```console -$ openshell inference set --provider compatible-anthropic-endpoint --model +$ nemoclaw inference set --provider compatible-anthropic-endpoint --model ``` -If the provider itself needs to change, rerun `nemoclaw onboard`. - #### Switching from Responses API to Chat Completions If onboarding selected `/v1/responses` but the agent fails at runtime (for @@ -111,28 +110,14 @@ session and the baked image. ## Cross-Provider Switching -Switching to a different provider family (for example, from NVIDIA Endpoints to Anthropic) requires updating both the gateway route and the sandbox config. - -Set the gateway route on the host: +Switching to a different provider family (for example, from NVIDIA Endpoints to Anthropic) also uses `nemoclaw inference set`. +The command updates both the gateway route and the OpenClaw provider namespace in the running sandbox config. ```console -$ openshell inference set --provider anthropic-prod --model claude-sonnet-4-6 --no-verify +$ nemoclaw inference set --provider anthropic-prod --model claude-sonnet-4-6 --no-verify ``` -Then set the override env vars and recreate the sandbox so they take effect at startup: - -```console -$ export NEMOCLAW_MODEL_OVERRIDE="anthropic/claude-sonnet-4-6" -$ export NEMOCLAW_INFERENCE_API_OVERRIDE="anthropic-messages" -$ nemoclaw onboard --resume --recreate-sandbox -``` - -The entrypoint patches `openclaw.json` at container startup with the override values. -You do not need to rebuild the image. -Remove the env vars and recreate the sandbox to revert to the original model. - -`NEMOCLAW_INFERENCE_API_OVERRIDE` accepts `openai-completions` (for NVIDIA, OpenAI, Gemini, compatible endpoints) or `anthropic-messages` (for Anthropic and Anthropic-compatible endpoints). -This variable is only needed when switching between provider families. +Use `--no-verify` only when OpenShell cannot verify the provider at switch time but you have already confirmed the provider and credential. ## Tune Model Metadata @@ -203,9 +188,8 @@ The output includes the active provider, model, and endpoint. - The host keeps provider credentials. - The sandbox continues to use `inference.local`. -- Same-provider model switches take effect immediately via the gateway route alone. -- Cross-provider switches also require `NEMOCLAW_MODEL_OVERRIDE` (and `NEMOCLAW_INFERENCE_API_OVERRIDE`) plus a sandbox recreate so the entrypoint patches the config at startup. -- Overrides are applied at container startup. Changing or removing env vars requires a sandbox recreate to take effect. +- `nemoclaw inference set` patches the selected running OpenClaw sandbox config and recomputes its config hash. +- Use `nemoclaw onboard --resume --recreate-sandbox` for build-time settings such as context window, max tokens, reasoning mode, heartbeat cadence, or image contents. - Local Ollama and local vLLM routes use local provider tokens rather than `OPENAI_API_KEY`. Rebuilds of older local-inference sandboxes clear the stale OpenAI credential requirement automatically. ## Related Topics diff --git a/docs/inference/use-local-inference.md b/docs/inference/use-local-inference.md index 6f68edcc292..6e93bac8601 100644 --- a/docs/inference/use-local-inference.md +++ b/docs/inference/use-local-inference.md @@ -347,10 +347,10 @@ Refer to [Switch Inference Models](switch-inference-providers.md) for the full p For compatible endpoints, the command is: ```console -$ openshell inference set --provider compatible-endpoint --model +$ nemoclaw inference set --provider compatible-endpoint --model ``` -If the provider itself needs to change (for example, switching from vLLM to a cloud API), rerun `nemoclaw onboard`. +If the provider itself needs to change (for example, switching from vLLM to a cloud API), pass the new provider to `nemoclaw inference set`. ## Next Steps diff --git a/docs/manage-sandboxes/lifecycle.md b/docs/manage-sandboxes/lifecycle.md index 3b5f8fadc30..efa861751dc 100644 --- a/docs/manage-sandboxes/lifecycle.md +++ b/docs/manage-sandboxes/lifecycle.md @@ -137,7 +137,7 @@ Recover from a misconfigured sandbox without re-running the full onboard wizard Change the active model or provider at runtime without rebuilding the sandbox: ```console -$ openshell inference set -g nemoclaw --model --provider +$ nemoclaw inference set --model --provider ``` Refer to [Switch Inference Providers](../inference/switch-inference-providers.md) for provider-specific model IDs and API compatibility notes. diff --git a/docs/reference/cli-selection-guide.md b/docs/reference/cli-selection-guide.md index 08468d33373..b3c3edaea0d 100644 --- a/docs/reference/cli-selection-guide.md +++ b/docs/reference/cli-selection-guide.md @@ -99,10 +99,9 @@ Use `openshell` when the docs explicitly call for a live OpenShell gateway opera $ openshell term ``` -- Change the live gateway inference route: +- Inspect the live gateway inference route: ```console - $ openshell inference set -g nemoclaw --provider --model $ openshell inference get -g nemoclaw ``` @@ -182,13 +181,13 @@ Approved endpoints are session-scoped unless you also add them to the policy thr ### Change Models or Providers -For a same-provider model switch, change the live OpenShell inference route: +Use the NemoClaw command for model or provider switches so the OpenShell route and the running OpenClaw config stay consistent: ```console -$ openshell inference set -g nemoclaw --provider nvidia-prod --model nvidia/nemotron-3-super-120b-a12b +$ nemoclaw inference set --provider nvidia-prod --model nvidia/nemotron-3-super-120b-a12b ``` -For a provider-family change or a build-time OpenClaw setting change, rerun onboarding so the sandbox configuration is recreated consistently: +For a build-time OpenClaw setting change, rerun onboarding so the sandbox configuration is recreated consistently: ```console $ nemoclaw onboard --resume --recreate-sandbox diff --git a/docs/reference/commands.md b/docs/reference/commands.md index ccc622f805f..d1e77776383 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -865,6 +865,21 @@ $ nemoclaw status $ nemoclaw status --json ``` +### `nemoclaw inference set` + +Switch the active inference provider or model for a NemoClaw-managed OpenClaw sandbox. +The command updates the OpenShell gateway route, patches the selected running OpenClaw config so the agent's primary model identity matches the route, recomputes the config hash, and updates the NemoClaw registry. + +By default, the command syncs the default registered sandbox. +Pass `--sandbox ` to target a different OpenClaw sandbox. + +```console +$ nemoclaw inference set --provider --model [--sandbox ] [--no-verify] +``` + +Supported provider names are `nvidia-prod`, `nvidia-nim`, `nvidia-router`, `openai-api`, `anthropic-prod`, `compatible-anthropic-endpoint`, `gemini-api`, `compatible-endpoint`, `ollama-local`, and `vllm-local`. +Use `--no-verify` only when OpenShell cannot verify the provider at switch time but you have already confirmed the provider and credential. + ### `nemoclaw setup` :::{warning} diff --git a/docs/security/best-practices.md b/docs/security/best-practices.md index 8952b1deeee..b67d4ea2151 100644 --- a/docs/security/best-practices.md +++ b/docs/security/best-practices.md @@ -119,7 +119,7 @@ flowchart TB * - Inference - Credential exposure, unauthorized model access, cost overruns. - OpenShell gateway - - Yes. Use `openshell inference set`. + - Yes. Use `nemoclaw inference set`. ::: diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 1251b9eef55..02a6163bf15 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -480,6 +480,91 @@ PYOVERRIDE [ "$_write_rc" -eq 0 ] || return "$_write_rc" } +# ── Agent identity reconciliation with provider routing ─────────── +# After the host-side `openshell inference set` swaps the gateway's +# inference provider entry, agents.defaults.model.primary in +# openclaw.json can drift from models.providers..models[0].name. +# When that happens the gateway routes requests to the new model but +# the agent self-reports the old one. Realign the two on every +# sandbox start so the next session boots with a consistent identity. +# Runs after apply_model_override so explicit NEMOCLAW_MODEL_OVERRIDE +# values still win. No-op when already in sync. +# Ref: https://github.com/NVIDIA/NemoClaw/issues/3175 + +reconcile_agent_model_with_provider() { + if [ "$(id -u)" -ne 0 ]; then + return 0 + fi + + local config_file="/sandbox/.openclaw/openclaw.json" + local hash_file="/sandbox/.openclaw/.config-hash" + + [ -f "$config_file" ] || return 0 + + if [ -L "$config_file" ] || [ -L "$hash_file" ]; then + return 0 + fi + + local provider_model_ref + provider_model_ref="$( + python3 - "$config_file" <<'PYRECONCILE_READ' +import json, sys +try: + with open(sys.argv[1]) as f: + cfg = json.load(f) +except Exception: + sys.exit(0) +primary = cfg.get("agents", {}).get("defaults", {}).get("model", {}).get("primary") +provider = cfg.get("models", {}).get("providers", {}).get("inference", {}) +models = provider.get("models") if isinstance(provider, dict) else None +if not isinstance(models, list) or not models: + sys.exit(0) +first = models[0] +if not isinstance(first, dict): + sys.exit(0) +provider_ref = first.get("name") +if not isinstance(provider_ref, str) or not provider_ref: + provider_id = first.get("id") + if not isinstance(provider_id, str) or not provider_id: + sys.exit(0) + provider_ref = provider_id if provider_id.startswith("inference/") else f"inference/{provider_id}" +if not isinstance(primary, str) or primary == provider_ref: + sys.exit(0) +print(provider_ref) +PYRECONCILE_READ + )" + + if [ -z "$provider_model_ref" ]; then + return 0 + fi + + printf '[config] Reconciling agent identity with provider model: %s (#3175)\n' "$provider_model_ref" >&2 + + prepare_openclaw_config_for_write "$config_file" "$hash_file" + local _write_rc=0 + + python3 - "$config_file" "$provider_model_ref" <<'PYRECONCILE_WRITE' || _write_rc=$? +import json, sys +config_file, provider_model = sys.argv[1], sys.argv[2] +with open(config_file) as f: + cfg = json.load(f) +cfg.setdefault("agents", {}).setdefault("defaults", {}).setdefault("model", {})["primary"] = provider_model +with open(config_file, "w") as f: + json.dump(cfg, f, indent=2) +PYRECONCILE_WRITE + + if [ "$_write_rc" -eq 0 ]; then + if (cd /sandbox/.openclaw && sha256sum openclaw.json >"$hash_file"); then + printf '[SECURITY] Config hash recomputed after agent identity reconciliation\n' >&2 + else + _write_rc=$? + fi + fi + + restore_openclaw_config_after_write "$config_file" "$hash_file" + [ "$_write_rc" -eq 0 ] || return "$_write_rc" +} + # ── Runtime CORS origin override ────────────────────────────────── # Adds a browser origin to gateway.controlUi.allowedOrigins at startup # without rebuilding the sandbox image. Useful for custom domains/ports. @@ -1572,6 +1657,7 @@ if [ "$(id -u)" -ne 0 ]; then fi normalize_mutable_config_perms apply_model_override + reconcile_agent_model_with_provider apply_cors_override export_gateway_token write_runtime_shell_env @@ -1664,6 +1750,7 @@ fi verify_config_integrity_if_locked /sandbox/.openclaw normalize_mutable_config_perms apply_model_override +reconcile_agent_model_with_provider apply_cors_override export_gateway_token write_runtime_shell_env diff --git a/src/commands/inference/set.ts b/src/commands/inference/set.ts new file mode 100644 index 00000000000..d13b8700c6f --- /dev/null +++ b/src/commands/inference/set.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import Command from "../../lib/commands/inference/set"; +import { withCommandDisplay } from "../../lib/cli/command-display"; + +export default withCommandDisplay(Command, [ + { + usage: "nemoclaw inference set", + description: "Switch inference and sync OpenClaw model identity", + flags: "--provider --model [--sandbox ] [--no-verify]", + group: "Services", + scope: "global", + order: 37, + }, +]); diff --git a/src/lib/actions/inference-set.test.ts b/src/lib/actions/inference-set.test.ts new file mode 100644 index 00000000000..7b6a231ba5d --- /dev/null +++ b/src/lib/actions/inference-set.test.ts @@ -0,0 +1,322 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import type { ConfigObject } from "../security/credential-filter"; +import type { AgentConfigTarget } from "../sandbox-config"; +import type { Session } from "../state/onboard-session"; +import type { SandboxEntry } from "../state/registry"; + +vi.mock("../adapters/openshell/runtime", () => ({ + runOpenshell: vi.fn(), +})); + +vi.mock("../inference/local", () => ({ + DEFAULT_OLLAMA_MODEL: "llama3.1", +})); + +vi.mock("../sandbox-config", () => ({ + readSandboxConfig: vi.fn(), + recomputeSandboxConfigHash: vi.fn(), + resolveAgentConfig: vi.fn(), + writeSandboxConfig: vi.fn(), +})); + +vi.mock("../shields/audit", () => ({ + appendAuditEntry: vi.fn(), +})); + +import { + type InferenceSetDeps, + patchOpenClawInferenceConfig, + runInferenceSet, +} from "./inference-set"; + +const OPENCLAW_TARGET: AgentConfigTarget = { + agentName: "openclaw", + configPath: "/sandbox/.openclaw/openclaw.json", + configDir: "/sandbox/.openclaw", + format: "json", + configFile: "openclaw.json", + sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], +}; + +function baseSession(overrides: Partial = {}): Session { + return { + version: 1, + sessionId: "session-1", + resumable: true, + status: "complete", + mode: "onboard", + startedAt: "2026-05-11T00:00:00.000Z", + updatedAt: "2026-05-11T00:00:00.000Z", + lastStepStarted: null, + lastCompletedStep: null, + failure: null, + agent: "openclaw", + sandboxName: "alpha", + provider: "nvidia-prod", + model: "moonshotai/kimi-k2.6", + endpointUrl: "https://inference.local/v1", + credentialEnv: "OPENAI_API_KEY", + preferredInferenceApi: null, + nimContainer: null, + routerPid: null, + routerCredentialHash: null, + webSearchConfig: null, + policyPresets: null, + messagingChannels: null, + messagingChannelConfig: null, + migratedLegacyValueHashes: null, + gpuPassthrough: false, + telegramConfig: null, + metadata: { gatewayName: "nemoclaw", fromDockerfile: null }, + steps: {}, + ...overrides, + }; +} + +function createDeps(options: { + config: ConfigObject; + entry?: SandboxEntry | null; + session?: Session | null; + openshellStatus?: number; +}): InferenceSetDeps & { + calls: { + runOpenshell: ReturnType; + writeSandboxConfig: ReturnType; + recomputeSandboxConfigHash: ReturnType; + updateSandbox: ReturnType; + updateSession: ReturnType; + appendAuditEntry: ReturnType; + log: ReturnType; + }; + getSession: () => Session | null; +} { + let session = options.session ?? null; + const calls = { + runOpenshell: vi.fn(() => ({ status: options.openshellStatus ?? 0, stdout: "", stderr: "" })), + writeSandboxConfig: vi.fn(), + recomputeSandboxConfigHash: vi.fn(), + updateSandbox: vi.fn(() => true), + updateSession: vi.fn((mutator: (value: Session) => Session | void) => { + const current = session ?? baseSession(); + session = mutator(current) ?? current; + return session; + }), + appendAuditEntry: vi.fn(), + log: vi.fn(), + }; + return { + getDefaultSandbox: () => "alpha", + getSandbox: () => options.entry ?? { name: "alpha", agent: null }, + updateSandbox: calls.updateSandbox, + loadSession: () => session, + updateSession: calls.updateSession, + resolveAgentConfig: () => OPENCLAW_TARGET, + readSandboxConfig: () => options.config, + writeSandboxConfig: calls.writeSandboxConfig, + recomputeSandboxConfigHash: calls.recomputeSandboxConfigHash, + runOpenshell: calls.runOpenshell, + appendAuditEntry: calls.appendAuditEntry, + log: calls.log, + calls, + getSession: () => session, + }; +} + +describe("patchOpenClawInferenceConfig", () => { + it("writes provider-qualified model refs while preserving model metadata", () => { + const config: ConfigObject = { + agents: { defaults: { model: { primary: "inference/moonshotai/kimi-k2.6" } } }, + models: { + mode: "merge", + providers: { + inference: { + baseUrl: "https://inference.local/v1", + apiKey: "unused", + api: "openai-completions", + models: [ + { + id: "moonshotai/kimi-k2.6", + name: "inference/moonshotai/kimi-k2.6", + contextWindow: 131072, + maxTokens: 8192, + reasoning: true, + compat: { supportsStore: false }, + }, + ], + }, + }, + }, + }; + + const result = patchOpenClawInferenceConfig( + config, + "nvidia-prod", + "nvidia/nemotron-3-super-120b-a12b", + ); + + expect(result.changed).toBe(true); + expect(config.agents).toEqual({ + defaults: { model: { primary: "inference/nvidia/nemotron-3-super-120b-a12b" } }, + }); + expect(config.models).toEqual({ + mode: "merge", + providers: { + inference: { + baseUrl: "https://inference.local/v1", + apiKey: "unused", + api: "openai-completions", + models: [ + { + id: "nvidia/nemotron-3-super-120b-a12b", + name: "inference/nvidia/nemotron-3-super-120b-a12b", + contextWindow: 131072, + maxTokens: 8192, + reasoning: true, + }, + ], + }, + }, + }); + }); + + it("is a no-op when OpenClaw already matches the requested route", () => { + const config: ConfigObject = { + agents: { defaults: { model: { primary: "inference/nvidia/model-a" } } }, + models: { + mode: "merge", + providers: { + inference: { + baseUrl: "https://inference.local/v1", + apiKey: "unused", + api: "openai-completions", + models: [{ id: "nvidia/model-a", name: "inference/nvidia/model-a" }], + }, + }, + }, + }; + + const result = patchOpenClawInferenceConfig(config, "nvidia-prod", "nvidia/model-a"); + + expect(result.changed).toBe(false); + }); + + it("switches Anthropic routes to the Anthropic provider namespace", () => { + const config: ConfigObject = { agents: {}, models: { providers: {} } }; + + patchOpenClawInferenceConfig(config, "anthropic-prod", "claude-sonnet-4-6"); + + expect(config.agents).toEqual({ + defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } }, + }); + expect(config.models).toEqual({ + mode: "merge", + providers: { + anthropic: { + baseUrl: "https://inference.local", + apiKey: "unused", + api: "anthropic-messages", + models: [{ id: "claude-sonnet-4-6", name: "anthropic/claude-sonnet-4-6" }], + }, + }, + }); + }); +}); + +describe("runInferenceSet", () => { + it("updates OpenShell, OpenClaw config, registry, and the matching onboard session", async () => { + const config: ConfigObject = { + agents: { defaults: { model: { primary: "inference/moonshotai/kimi-k2.6" } } }, + models: { + providers: { + inference: { + api: "openai-completions", + models: [{ id: "moonshotai/kimi-k2.6", name: "inference/moonshotai/kimi-k2.6" }], + }, + }, + }, + }; + const deps = createDeps({ config, session: baseSession() }); + + const result = await runInferenceSet( + { + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + noVerify: true, + }, + deps, + ); + + expect(deps.calls.runOpenshell).toHaveBeenCalledWith( + [ + "inference", + "set", + "-g", + "nemoclaw", + "--provider", + "nvidia-prod", + "--model", + "nvidia/nemotron-3-super-120b-a12b", + "--no-verify", + ], + { ignoreError: true }, + ); + expect(config.agents).toEqual({ + defaults: { model: { primary: "inference/nvidia/nemotron-3-super-120b-a12b" } }, + }); + expect(deps.calls.writeSandboxConfig).toHaveBeenCalledWith("alpha", OPENCLAW_TARGET, config); + expect(deps.calls.recomputeSandboxConfigHash).toHaveBeenCalledWith("alpha", OPENCLAW_TARGET); + expect(deps.calls.updateSandbox).toHaveBeenCalledWith("alpha", { + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + }); + expect(deps.getSession()).toMatchObject({ + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + endpointUrl: "https://inference.local/v1", + }); + expect(deps.calls.appendAuditEntry).toHaveBeenCalledWith( + expect.objectContaining({ + action: "shields_down", + sandbox: "alpha", + reason: "inference set openclaw:nvidia-prod:nvidia/nemotron-3-super-120b-a12b", + }), + ); + expect(result).toMatchObject({ + sandboxName: "alpha", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + primaryModelRef: "inference/nvidia/nemotron-3-super-120b-a12b", + configChanged: true, + sessionUpdated: true, + }); + }); + + it("refuses non-OpenClaw sandboxes before changing OpenShell inference", async () => { + const deps = createDeps({ + config: {}, + entry: { name: "hermes", agent: "hermes" }, + }); + + await expect( + runInferenceSet({ provider: "nvidia-prod", model: "nvidia/model-a" }, deps), + ).rejects.toThrow(/currently supports OpenClaw/); + + expect(deps.calls.runOpenshell).not.toHaveBeenCalled(); + expect(deps.calls.writeSandboxConfig).not.toHaveBeenCalled(); + }); + + it("does not write sandbox state when openshell inference set fails", async () => { + const deps = createDeps({ config: {}, openshellStatus: 17 }); + + await expect( + runInferenceSet({ provider: "nvidia-prod", model: "nvidia/model-a" }, deps), + ).rejects.toThrow(/OpenShell inference route update failed/); + + expect(deps.calls.writeSandboxConfig).not.toHaveBeenCalled(); + expect(deps.calls.updateSandbox).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts new file mode 100644 index 00000000000..3738d9917d3 --- /dev/null +++ b/src/lib/actions/inference-set.ts @@ -0,0 +1,340 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SpawnSyncReturns } from "node:child_process"; + +import { runOpenshell } from "../adapters/openshell/runtime"; +import { + getProviderSelectionConfig, + getSandboxInferenceConfig, + type SandboxInferenceConfig, +} from "../inference/config"; +import type { ConfigObject, ConfigValue } from "../security/credential-filter"; +import { isConfigObject, isConfigValue } from "../security/credential-filter"; +import { + readSandboxConfig, + recomputeSandboxConfigHash, + resolveAgentConfig, + type AgentConfigTarget, + writeSandboxConfig, +} from "../sandbox-config"; +import { appendAuditEntry } from "../shields/audit"; +import * as onboardSession from "../state/onboard-session"; +import * as registry from "../state/registry"; +import type { SandboxEntry } from "../state/registry"; +import { isSafeModelId } from "../validation"; + +export interface InferenceSetOptions { + provider: string; + model: string; + sandboxName?: string | null; + noVerify?: boolean; +} + +export interface InferenceSetResult { + sandboxName: string; + provider: string; + model: string; + primaryModelRef: string; + providerKey: string; + configChanged: boolean; + sessionUpdated: boolean; +} + +type OpenshellRunResult = Pick, "status" | "stdout" | "stderr">; + +export interface InferenceSetDeps { + getDefaultSandbox: () => string | null; + getSandbox: (name: string) => SandboxEntry | null; + updateSandbox: (name: string, updates: Partial) => boolean; + loadSession: () => onboardSession.Session | null; + updateSession: ( + mutator: (session: onboardSession.Session) => onboardSession.Session | void, + ) => onboardSession.Session; + resolveAgentConfig: (sandboxName: string) => AgentConfigTarget; + readSandboxConfig: (sandboxName: string, target: AgentConfigTarget) => ConfigObject; + writeSandboxConfig: ( + sandboxName: string, + target: AgentConfigTarget, + config: ConfigObject, + ) => void; + recomputeSandboxConfigHash: (sandboxName: string, target: AgentConfigTarget) => void; + runOpenshell: (args: string[], opts?: { ignoreError?: boolean }) => OpenshellRunResult; + appendAuditEntry: typeof appendAuditEntry; + log: (message: string) => void; +} + +export class InferenceSetError extends Error { + constructor( + message: string, + readonly exitCode = 1, + ) { + super(message); + this.name = "InferenceSetError"; + } +} + +const SUPPORTED_PROVIDER_NAMES = [ + "nvidia-prod", + "nvidia-nim", + "nvidia-router", + "openai-api", + "anthropic-prod", + "compatible-anthropic-endpoint", + "gemini-api", + "compatible-endpoint", + "ollama-local", + "vllm-local", +] as const; + +function defaultDeps(): InferenceSetDeps { + return { + getDefaultSandbox: registry.getDefault, + getSandbox: registry.getSandbox, + updateSandbox: registry.updateSandbox, + loadSession: onboardSession.loadSession, + updateSession: onboardSession.updateSession, + resolveAgentConfig, + readSandboxConfig, + writeSandboxConfig, + recomputeSandboxConfigHash, + runOpenshell: (args, opts) => runOpenshell(args, opts), + appendAuditEntry, + log: console.log, + }; +} + +function trimRequired(value: string | null | undefined, label: string): string { + const trimmed = String(value ?? "").trim(); + if (!trimmed) throw new InferenceSetError(`${label} is required.`); + return trimmed; +} + +function assertSupportedProvider(provider: string, model: string): void { + if (getProviderSelectionConfig(provider, model) || provider === "nvidia-router") return; + throw new InferenceSetError( + `Unsupported provider '${provider}'. Supported providers: ${SUPPORTED_PROVIDER_NAMES.join(", ")}.`, + 2, + ); +} + +function normalizeSandboxAgent(agentName: string | null | undefined): string { + const trimmed = typeof agentName === "string" ? agentName.trim() : ""; + return trimmed || "openclaw"; +} + +function resolveTargetSandbox( + sandboxName: string | null | undefined, + deps: Pick, +): { sandboxName: string; entry: SandboxEntry } { + const targetName = sandboxName?.trim() || deps.getDefaultSandbox(); + if (!targetName) { + throw new InferenceSetError( + "No sandbox selected. Pass --sandbox or create a sandbox with nemoclaw onboard.", + 2, + ); + } + + const entry = deps.getSandbox(targetName); + if (!entry) { + throw new InferenceSetError(`Sandbox '${targetName}' is not registered.`, 2); + } + if (normalizeSandboxAgent(entry.agent) !== "openclaw") { + throw new InferenceSetError( + `nemoclaw inference set currently supports OpenClaw sandboxes; '${targetName}' uses '${entry.agent}'.`, + 2, + ); + } + return { sandboxName: targetName, entry }; +} + +function ensureObject(record: ConfigObject, key: string): ConfigObject { + const existing = record[key]; + if (isConfigObject(existing)) return existing; + const created: ConfigObject = {}; + record[key] = created; + return created; +} + +function cloneConfigObject(value: ConfigValue | undefined): ConfigObject { + if (!isConfigObject(value)) return {}; + return { ...value }; +} + +function asConfigObject(value: Record): ConfigObject { + const result: ConfigObject = {}; + for (const [key, entry] of Object.entries(value)) { + if (isConfigValue(entry as ConfigValue)) result[key] = entry as ConfigValue; + } + return result; +} + +function updateAgentPrimary(config: ConfigObject, primaryModelRef: string): void { + const agents = ensureObject(config, "agents"); + const defaults = ensureObject(agents, "defaults"); + const model = ensureObject(defaults, "model"); + model.primary = primaryModelRef; +} + +function buildProviderConfig( + existing: ConfigObject, + model: string, + route: SandboxInferenceConfig, +): ConfigObject { + const firstExistingModel = Array.isArray(existing.models) + ? cloneConfigObject(existing.models[0]) + : {}; + delete firstExistingModel.compat; + firstExistingModel.id = model; + firstExistingModel.name = route.primaryModelRef; + if (route.inferenceCompat) { + firstExistingModel.compat = asConfigObject(route.inferenceCompat); + } + + return { + ...existing, + baseUrl: route.inferenceBaseUrl, + apiKey: typeof existing.apiKey === "string" && existing.apiKey ? existing.apiKey : "unused", + api: route.inferenceApi, + models: [firstExistingModel], + }; +} + +export function patchOpenClawInferenceConfig( + config: ConfigObject, + provider: string, + model: string, + preferredInferenceApi: string | null = null, +): { changed: boolean; route: SandboxInferenceConfig } { + const before = JSON.stringify(config); + const route = getSandboxInferenceConfig(model, provider, preferredInferenceApi); + + updateAgentPrimary(config, route.primaryModelRef); + + const models = ensureObject(config, "models"); + models.mode = "merge"; + const providers = ensureObject(models, "providers"); + const existingProvider = cloneConfigObject(providers[route.providerKey]); + providers[route.providerKey] = buildProviderConfig(existingProvider, model, route); + + return { changed: before !== JSON.stringify(config), route }; +} + +function updateMatchingOnboardSession( + sandboxName: string, + provider: string, + model: string, + deps: Pick, +): boolean { + const session = deps.loadSession(); + if (!session || session.sandboxName !== sandboxName) return false; + deps.updateSession((current) => { + if (current.sandboxName !== sandboxName) return current; + current.provider = provider; + current.model = model; + current.endpointUrl = + getProviderSelectionConfig(provider, model)?.endpointUrl ?? current.endpointUrl; + return current; + }); + return true; +} + +function openshellInferenceSetArgs(options: { + provider: string; + model: string; + noVerify?: boolean; +}): string[] { + const args = [ + "inference", + "set", + "-g", + "nemoclaw", + "--provider", + options.provider, + "--model", + options.model, + ]; + if (options.noVerify) args.push("--no-verify"); + return args; +} + +function getPreferredInferenceApi(config: ConfigObject): string | null { + const models = config.models; + if (!isConfigObject(models)) return null; + const providers = models.providers; + if (!isConfigObject(providers)) return null; + const inferenceProvider = providers.inference; + if (!isConfigObject(inferenceProvider)) return null; + return typeof inferenceProvider.api === "string" ? inferenceProvider.api : null; +} + +export async function runInferenceSet( + options: InferenceSetOptions, + deps: InferenceSetDeps = defaultDeps(), +): Promise { + const provider = trimRequired(options.provider, "provider"); + const model = trimRequired(options.model, "model"); + assertSupportedProvider(provider, model); + if (!isSafeModelId(model)) { + throw new InferenceSetError( + "Invalid model id. Model values may only contain letters, numbers, '.', '_', ':', '/', and '-'.", + 2, + ); + } + + const { sandboxName } = resolveTargetSandbox(options.sandboxName, deps); + const target = deps.resolveAgentConfig(sandboxName); + if (target.agentName !== "openclaw") { + throw new InferenceSetError( + `nemoclaw inference set currently supports OpenClaw configs; '${sandboxName}' uses '${target.agentName}'.`, + 2, + ); + } + + const config = deps.readSandboxConfig(sandboxName, target); + const patched = patchOpenClawInferenceConfig( + config, + provider, + model, + getPreferredInferenceApi(config), + ); + + deps.log(` Setting OpenShell inference route: ${provider} / ${model}`); + const setResult = deps.runOpenshell(openshellInferenceSetArgs({ provider, model, noVerify: options.noVerify }), { + ignoreError: true, + }); + if (setResult.status !== 0) { + throw new InferenceSetError( + `OpenShell inference route update failed with exit ${setResult.status ?? 1}.`, + setResult.status ?? 1, + ); + } + + deps.log(` Syncing OpenClaw model identity in sandbox '${sandboxName}'...`); + deps.writeSandboxConfig(sandboxName, target, config); + deps.recomputeSandboxConfigHash(sandboxName, target); + + if (!deps.updateSandbox(sandboxName, { provider, model })) { + throw new InferenceSetError(`Failed to update NemoClaw registry for sandbox '${sandboxName}'.`); + } + const sessionUpdated = updateMatchingOnboardSession(sandboxName, provider, model, deps); + + deps.appendAuditEntry({ + action: "shields_down", + sandbox: sandboxName, + timestamp: new Date().toISOString(), + reason: `inference set openclaw:${provider}:${model}`, + }); + + deps.log(` Inference route synced for '${sandboxName}': ${patched.route.primaryModelRef}`); + + return { + sandboxName, + provider, + model, + primaryModelRef: patched.route.primaryModelRef, + providerKey: patched.route.providerKey, + configChanged: patched.changed, + sessionUpdated, + }; +} diff --git a/src/lib/actions/root-help.ts b/src/lib/actions/root-help.ts index 127b9106020..09801c3c8aa 100644 --- a/src/lib/actions/root-help.ts +++ b/src/lib/actions/root-help.ts @@ -73,7 +73,7 @@ export function help(): void { lines.push(""); lines.push(` ${G}Reconfiguration (after onboard):${R}`); lines.push( - ` ${D}• Change inference model: openshell inference set -g nemoclaw --model --provider ${R}`, + ` ${D}• Change inference model: nemoclaw inference set --model --provider ${R}`, ); lines.push(` ${D}• Add network presets: use the policy-add command on your sandbox${R}`); lines.push( diff --git a/src/lib/cli/command-registry.test.ts b/src/lib/cli/command-registry.test.ts index 9813d7f5212..cdcf03266f3 100644 --- a/src/lib/cli/command-registry.test.ts +++ b/src/lib/cli/command-registry.test.ts @@ -17,10 +17,10 @@ import type { CommandDef } from "./command-registry"; describe("command-registry", () => { describe("COMMANDS array", () => { - it("should contain exactly 53 commands", () => { - // 24 global (19 visible + 5 hidden help/version aliases) + it("should contain exactly 54 commands", () => { + // 25 global (20 visible + 5 hidden help/version aliases) // 29 sandbox (23 visible + 6 hidden shields/config) - expect(COMMANDS).toHaveLength(53); + expect(COMMANDS).toHaveLength(54); }); it("should have no duplicate usage strings", () => { @@ -39,9 +39,9 @@ describe("command-registry", () => { }); describe("globalCommands()", () => { - it("should return exactly 24 entries", () => { - // 19 visible + 5 hidden (help, --help, -h, --version, -v) - expect(globalCommands()).toHaveLength(24); + it("should return exactly 25 entries", () => { + // 20 visible + 5 hidden (help, --help, -h, --version, -v) + expect(globalCommands()).toHaveLength(25); }); it("every entry has scope global", () => { @@ -65,10 +65,10 @@ describe("command-registry", () => { }); describe("visibleCommands()", () => { - it("should exclude 11 hidden commands (42 visible)", () => { + it("should exclude 11 hidden commands (43 visible)", () => { // 5 hidden global (help, --help, -h, --version, -v) + // 6 hidden sandbox (shields×3, config get/set/rotate-token) - expect(visibleCommands()).toHaveLength(42); + expect(visibleCommands()).toHaveLength(43); }); it("no visible command has hidden=true", () => { @@ -146,7 +146,7 @@ describe("command-registry", () => { }); describe("globalCommandTokens()", () => { - it("returns the exact set of 21 tokens matching the old GLOBAL_COMMANDS", () => { + it("returns the exact set of 22 tokens matching the global dispatch commands", () => { const tokens = globalCommandTokens(); const expected = new Set([ "onboard", @@ -165,6 +165,7 @@ describe("command-registry", () => { "backup-all", "upgrade-sandboxes", "gc", + "inference", "help", "--help", "-h", diff --git a/src/lib/cli/oclif-dispatch.test.ts b/src/lib/cli/oclif-dispatch.test.ts index 045ce60be73..09deb52a939 100644 --- a/src/lib/cli/oclif-dispatch.test.ts +++ b/src/lib/cli/oclif-dispatch.test.ts @@ -22,6 +22,11 @@ describe("resolveGlobalOclifDispatch", () => { commandId: "tunnel:start", args: [], }); + expect(resolveGlobalOclifDispatch("inference", ["set", "--provider", "nvidia-prod"])).toEqual({ + kind: "oclif", + commandId: "inference:set", + args: ["--provider", "nvidia-prod"], + }); expect(resolveGlobalOclifDispatch("--version", [])).toEqual({ kind: "oclif", commandId: "root:version", @@ -39,6 +44,12 @@ describe("resolveGlobalOclifDispatch", () => { kind: "usageError", lines: ["tunnel "], }); + expect(resolveGlobalOclifDispatch("inference", ["get"])).toEqual({ + kind: "usageError", + lines: [ + "inference set --provider --model [--sandbox ] [--no-verify]", + ], + }); expect(resolveGlobalOclifDispatch("credentials", ["bogus"])).toEqual({ kind: "unknownSubcommand", command: "credentials", diff --git a/src/lib/cli/oclif-dispatch.ts b/src/lib/cli/oclif-dispatch.ts index 42bc3e8d888..12aee762408 100644 --- a/src/lib/cli/oclif-dispatch.ts +++ b/src/lib/cli/oclif-dispatch.ts @@ -117,6 +117,17 @@ export function resolveGlobalOclifDispatch(cmd: string, args: string[]): Dispatc return { kind: "usageError", lines: ["tunnel "] }; } + if (cmd === "inference") { + const sub = args[0]; + if (sub === "set") return oclif("inference:set", args.slice(1)); + return { + kind: "usageError", + lines: [ + "inference set --provider --model [--sandbox ] [--no-verify]", + ], + }; + } + if (cmd === "credentials") { const sub = args[0]; if (!sub || sub === "help" || sub === "--help" || sub === "-h") return oclif("credentials", []); diff --git a/src/lib/commands/global-oclif-command-adapters.test.ts b/src/lib/commands/global-oclif-command-adapters.test.ts index ee9395883d7..1cc1c4f18d9 100644 --- a/src/lib/commands/global-oclif-command-adapters.test.ts +++ b/src/lib/commands/global-oclif-command-adapters.test.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ renderSandboxInventoryText: vi.fn(), runBackupAllAction: vi.fn(), runGarbageCollectImagesAction: vi.fn(), + runInferenceSet: vi.fn(), runOnboardAction: vi.fn(), runSetupAction: vi.fn(), runSetupSparkAction: vi.fn(), @@ -42,6 +43,14 @@ vi.mock("../actions/global", () => ({ runUpgradeSandboxesAction: mocks.runUpgradeSandboxesAction, })); +vi.mock("../actions/inference-set", () => ({ + InferenceSetError: class InferenceSetError extends Error { + exitCode = 1; + }, + runInferenceSet: mocks.runInferenceSet, +})); + +import InferenceSetCommand from "./inference/set"; import ListCommand from "./list"; import BackupAllCommand from "./maintenance/backup-all"; import GarbageCollectImagesCommand from "./maintenance/gc"; @@ -60,6 +69,15 @@ describe("global oclif command adapters", () => { mocks.buildStatusCommandDeps.mockReturnValue({ statusDeps: true }); mocks.getSandboxInventory.mockResolvedValue({ sandboxes: [] }); mocks.getStatusReport.mockReturnValue({ sandboxes: [] }); + mocks.runInferenceSet.mockResolvedValue({ + sandboxName: "alpha", + provider: "nvidia-prod", + model: "nvidia/model-a", + primaryModelRef: "inference/nvidia/model-a", + providerKey: "inference", + configChanged: true, + sessionUpdated: false, + }); }); afterEach(() => { @@ -112,4 +130,26 @@ describe("global oclif command adapters", () => { expect(mocks.runSetupAction).toHaveBeenCalledWith(["--fresh"]); expect(mocks.runSetupSparkAction).toHaveBeenCalledWith(["--control-ui-port", "18080"]); }); + + it("maps inference set flags into the inference action", async () => { + await InferenceSetCommand.run( + [ + "--provider", + "nvidia-prod", + "--model", + "nvidia/nemotron-3-super-120b-a12b", + "--sandbox", + "alpha", + "--no-verify", + ], + rootDir, + ); + + expect(mocks.runInferenceSet).toHaveBeenCalledWith({ + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + sandboxName: "alpha", + noVerify: true, + }); + }); }); diff --git a/src/lib/commands/inference/set.ts b/src/lib/commands/inference/set.ts new file mode 100644 index 00000000000..9d9a77bc0bc --- /dev/null +++ b/src/lib/commands/inference/set.ts @@ -0,0 +1,58 @@ +// 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 "../../actions/inference-set"; +import { NemoClawCommand } from "../../cli/nemoclaw-oclif-command"; + +export default class InferenceSetCommand extends NemoClawCommand { + static id = "inference:set"; + static strict = true; + static summary = "Switch the NemoClaw inference model"; + static description = + "Update the OpenShell inference route and sync the running OpenClaw sandbox model identity."; + static usage = [ + "inference set --provider --model [--sandbox ] [--no-verify]", + ]; + static examples = [ + "<%= config.bin %> inference set --provider nvidia-prod --model nvidia/nemotron-3-super-120b-a12b", + "<%= config.bin %> inference set --provider openai-api --model gpt-5.4 --sandbox my-assistant", + ]; + static flags = { + provider: Flags.string({ + description: "OpenShell inference provider name", + required: true, + }), + model: Flags.string({ + description: "Model id to route through the selected provider", + required: true, + }), + sandbox: Flags.string({ + description: "Registered OpenClaw sandbox to sync; defaults to the NemoClaw default sandbox", + }), + "no-verify": Flags.boolean({ + description: "Pass --no-verify through to openshell inference set", + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(InferenceSetCommand); + try { + await runInferenceSet({ + provider: flags.provider, + model: flags.model, + sandboxName: flags.sandbox ?? null, + noVerify: flags["no-verify"] === true, + }); + } catch (error) { + if (error instanceof InferenceSetError) { + this.error(error.message, { exit: error.exitCode }); + } + throw error; + } + } +} diff --git a/src/lib/inference/config.ts b/src/lib/inference/config.ts index 4e0bddab25d..bfe9e0ca241 100644 --- a/src/lib/inference/config.ts +++ b/src/lib/inference/config.ts @@ -45,6 +45,14 @@ export interface GatewayInference { model: string | null; } +export interface SandboxInferenceConfig { + providerKey: string; + primaryModelRef: string; + inferenceBaseUrl: string; + inferenceApi: string; + inferenceCompat: Record | null; +} + export function getProviderSelectionConfig( provider: string, model?: string, @@ -123,7 +131,59 @@ export function getProviderSelectionConfig( export function getOpenClawPrimaryModel(provider: string, model?: string): string { const resolvedModel = model || (provider === "ollama-local" ? DEFAULT_OLLAMA_MODEL : DEFAULT_CLOUD_MODEL); - return `${MANAGED_PROVIDER_ID}/${resolvedModel}`; + return getSandboxInferenceConfig(resolvedModel, provider).primaryModelRef; +} + +export function getSandboxInferenceConfig( + model: string, + provider: string | null = null, + preferredInferenceApi: string | null = null, +): SandboxInferenceConfig { + let providerKey: string; + let primaryModelRef: string; + let inferenceBaseUrl = INFERENCE_ROUTE_URL; + let inferenceApi = preferredInferenceApi || "openai-completions"; + let inferenceCompat: Record | null = null; + + switch (provider) { + case "openai-api": + providerKey = "openai"; + primaryModelRef = `openai/${model}`; + break; + case "anthropic-prod": + case "compatible-anthropic-endpoint": + providerKey = "anthropic"; + primaryModelRef = `anthropic/${model}`; + inferenceBaseUrl = "https://inference.local"; + inferenceApi = "anthropic-messages"; + break; + case "gemini-api": + providerKey = MANAGED_PROVIDER_ID; + primaryModelRef = `${MANAGED_PROVIDER_ID}/${model}`; + inferenceCompat = { + supportsStore: false, + }; + break; + case "compatible-endpoint": + providerKey = MANAGED_PROVIDER_ID; + primaryModelRef = `${MANAGED_PROVIDER_ID}/${model}`; + inferenceCompat = { + supportsStore: false, + }; + break; + case "nvidia-router": + providerKey = MANAGED_PROVIDER_ID; + primaryModelRef = `${MANAGED_PROVIDER_ID}/${model}`; + break; + case "nvidia-prod": + case "nvidia-nim": + default: + providerKey = MANAGED_PROVIDER_ID; + primaryModelRef = `${MANAGED_PROVIDER_ID}/${model}`; + break; + } + + return { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat }; } export function parseGatewayInference(output: string | null | undefined): GatewayInference | null { diff --git a/src/lib/onboard/providers.ts b/src/lib/onboard/providers.ts index 51891397bd4..d435c2c6446 100644 --- a/src/lib/onboard/providers.ts +++ b/src/lib/onboard/providers.ts @@ -9,6 +9,7 @@ const { DEFAULT_CLOUD_MODEL, OLLAMA_LOCAL_CREDENTIAL_ENV, VLLM_LOCAL_CREDENTIAL_ENV, + getSandboxInferenceConfig, } = require("../inference/config"); const { isSafeModelId } = require("../validation"); const { compactText } = require("../core/url-utils"); @@ -90,14 +91,6 @@ const REMOTE_PROVIDER_CONFIG = { // Providers that run on the host and need the local-inference policy preset. const LOCAL_INFERENCE_PROVIDERS = ["ollama-local", "vllm-local"]; -type SandboxInferenceConfig = { - providerKey: string; - primaryModelRef: string; - inferenceBaseUrl: string; - inferenceApi: string; - inferenceCompat: Record | null; -}; - // Re-exported alias matching the existing onboard.ts call sites. The canonical // definitions live in inference-config.ts so that getProviderSelectionConfig // (which writes the sandbox-side config) and the gateway-registration path @@ -306,60 +299,6 @@ function upsertMessagingProviders(tokenDefs, _runOpenshell) { return upserted; } -// ── Sandbox inference config ───────────────────────────────────── - -function getSandboxInferenceConfig( - model: string, - provider: string | null = null, - preferredInferenceApi: string | null = null, -): SandboxInferenceConfig { - let providerKey; - let primaryModelRef; - let inferenceBaseUrl = "https://inference.local/v1"; - let inferenceApi = preferredInferenceApi || "openai-completions"; - let inferenceCompat = null; - - switch (provider) { - case "openai-api": - providerKey = "openai"; - primaryModelRef = `openai/${model}`; - break; - case "anthropic-prod": - case "compatible-anthropic-endpoint": - providerKey = "anthropic"; - primaryModelRef = `anthropic/${model}`; - inferenceBaseUrl = "https://inference.local"; - inferenceApi = "anthropic-messages"; - break; - case "gemini-api": - providerKey = "inference"; - primaryModelRef = `inference/${model}`; - inferenceCompat = { - supportsStore: false, - }; - break; - case "compatible-endpoint": - providerKey = "inference"; - primaryModelRef = `inference/${model}`; - inferenceCompat = { - supportsStore: false, - }; - break; - case "nvidia-router": - providerKey = "inference"; - primaryModelRef = `inference/${model}`; - break; - case "nvidia-prod": - case "nvidia-nim": - default: - providerKey = "inference"; - primaryModelRef = `inference/${model}`; - break; - } - - return { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat }; -} - module.exports = { BUILD_ENDPOINT_URL, OPENAI_ENDPOINT_URL, diff --git a/src/lib/sandbox-config.ts b/src/lib/sandbox-config.ts index eeb3c1cfbca..8a65447a41a 100644 --- a/src/lib/sandbox-config.ts +++ b/src/lib/sandbox-config.ts @@ -19,6 +19,7 @@ const path = require("path"); const { promises: dnsPromises } = require("node:dns"); const { isIP } = require("node:net"); const { validateName } = require("./runner"); +const { shellQuote } = require("./core/shell-quote"); const { dockerExecFileSync } = require("./adapters/docker/exec"); const credentialFilter: typeof import("./security/credential-filter") = require("./security/credential-filter"); const { stripCredentials, isConfigObject, isConfigValue, isCredentialField } = credentialFilter; @@ -47,7 +48,7 @@ const K3S_CONTAINER = "openshell-cluster-nemoclaw"; // to read/write that agent's config from the host. // --------------------------------------------------------------------------- -interface AgentConfigTarget { +export interface AgentConfigTarget { /** Agent name (e.g. "openclaw", "hermes") */ agentName: string; /** Absolute path inside sandbox to the config file */ @@ -359,6 +360,97 @@ function readSandboxConfig(sandboxName: string, target: AgentConfigTarget): Conf } } +function writeSandboxConfig( + sandboxName: string, + target: AgentConfigTarget, + config: ConfigObject, +): void { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-config-")); + const tmpFile = path.join(tmpDir, target.configFile); + try { + fs.writeFileSync(tmpFile, serializeConfig(config, target.format), { mode: 0o600 }); + + const content = fs.readFileSync(tmpFile, "utf-8"); + dockerExecFileSync( + [ + "exec", + "-i", + K3S_CONTAINER, + "kubectl", + "exec", + "-n", + "openshell", + sandboxName, + "-c", + "agent", + "-i", + "--", + "sh", + "-c", + `cat > ${shellQuote(target.configPath)}`, + ], + { input: content, stdio: ["pipe", "pipe", "pipe"], timeout: 15000 }, + ); + + try { + dockerExecFileSync( + [ + "exec", + K3S_CONTAINER, + "kubectl", + "exec", + "-n", + "openshell", + sandboxName, + "-c", + "agent", + "--", + "chown", + "sandbox:sandbox", + target.configPath, + ], + { stdio: ["ignore", "pipe", "pipe"], timeout: 15000 }, + ); + } catch { + // Best effort — chown failure is non-fatal. + } + } finally { + try { + fs.unlinkSync(tmpFile); + fs.rmdirSync(tmpDir); + } catch { + // Best effort. + } + } +} + +function recomputeSandboxConfigHash(sandboxName: string, target: AgentConfigTarget): void { + if (!target.sensitiveFiles?.includes(`${target.configDir}/.config-hash`)) return; + dockerExecFileSync( + [ + "exec", + K3S_CONTAINER, + "kubectl", + "exec", + "-n", + "openshell", + sandboxName, + "-c", + "agent", + "--", + "sh", + "-c", + [ + `cd ${shellQuote(target.configDir)}`, + `sha256sum ${shellQuote(target.configFile)} > .config-hash`, + "(chown sandbox:sandbox .config-hash 2>/dev/null || true)", + "(chmod 660 .config-hash 2>/dev/null || true)", + ].join(" && "), + ], + { stdio: ["ignore", "pipe", "pipe"], timeout: 15000 }, + ); +} + // --------------------------------------------------------------------------- // URL validation (strict SSRF checks for config set) // --------------------------------------------------------------------------- @@ -992,6 +1084,8 @@ export { parseConfigGetArgs, resolveAgentConfig, readSandboxConfig, + writeSandboxConfig, + recomputeSandboxConfigHash, extractDotpath, setDotpath, validateConfigDotpath, diff --git a/test/nemoclaw-start-reconcile.test.ts b/test/nemoclaw-start-reconcile.test.ts new file mode 100644 index 00000000000..9d8347651fb --- /dev/null +++ b/test/nemoclaw-start-reconcile.test.ts @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; + +const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); + +describe("agent identity reconciliation with provider (#3175)", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + + function extractShellFunction(name: string): string { + const match = src.match(new RegExp(`${name}\\(\\) \\{([\\s\\S]*?)^\\}`, "m")); + if (!match) { + throw new Error(`Expected ${name} in scripts/nemoclaw-start.sh`); + } + return `${name}() {${match[1]}\n}`; + } + + function runReconcile(initialConfig: unknown, env: Record = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reconcile-")); + const openclawDir = path.join(root, ".openclaw"); + fs.mkdirSync(openclawDir, { recursive: true }); + const configPath = path.join(openclawDir, "openclaw.json"); + const hashPath = path.join(openclawDir, ".config-hash"); + fs.writeFileSync(configPath, JSON.stringify(initialConfig)); + fs.writeFileSync(hashPath, "oldhash\n"); + fs.chmodSync(openclawDir, 0o2770); + fs.chmodSync(configPath, 0o660); + fs.chmodSync(hashPath, 0o660); + + const helperFns = [ + extractShellFunction("openclaw_config_dir_owner"), + extractShellFunction("prepare_openclaw_config_for_write"), + extractShellFunction("restore_openclaw_config_after_write"), + ] + .join("\n") + .replaceAll("/sandbox", root); + const fn = extractShellFunction("reconcile_agent_model_with_provider").replaceAll( + "/sandbox", + root, + ); + const wrapper = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "id() { echo 0; }", + "chown() { return 0; }", + `stat() { if [ "$1" = "-c" ] && [ "$2" = "%U" ] && [ "$3" = ${JSON.stringify(openclawDir)} ]; then echo sandbox; return 0; fi; command stat "$@"; }`, + 'relax_config_for_write() { chmod 644 "$@"; }', + 'lock_config_after_write() { chmod 444 "$@"; }', + helperFns, + fn, + "reconcile_agent_model_with_provider", + ].join("\n"); + const script = path.join(root, "run.sh"); + fs.writeFileSync(script, wrapper, { mode: 0o700 }); + const result = spawnSync("bash", [script], { + encoding: "utf-8", + env: { ...process.env, ...env }, + }); + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + const hash = fs.readFileSync(hashPath, "utf-8"); + fs.rmSync(root, { recursive: true, force: true }); + return { result, config, hash }; + } + + it("aligns agents.defaults.model.primary to inference provider's first model when they drift", () => { + const { result, config, hash } = runReconcile({ + agents: { defaults: { model: { primary: "inference/old-model" } } }, + models: { + providers: { + inference: { + api: "openai-completions", + models: [{ id: "nvidia/new-model", name: "inference/nvidia/new-model" }], + }, + }, + }, + }); + + expect(result.status).toBe(0); + expect(config.agents.defaults.model.primary).toBe("inference/nvidia/new-model"); + expect(hash).not.toBe("oldhash\n"); + expect(hash).toContain("openclaw.json"); + }); + + it("is a no-op when primary already matches the provider's model", () => { + const { result, config, hash } = runReconcile({ + agents: { defaults: { model: { primary: "inference/nvidia/same-model" } } }, + models: { + providers: { + inference: { + api: "openai-completions", + models: [{ id: "nvidia/same-model", name: "inference/nvidia/same-model" }], + }, + }, + }, + }); + + expect(result.status).toBe(0); + expect(config.agents.defaults.model.primary).toBe("inference/nvidia/same-model"); + expect(hash).toBe("oldhash\n"); + }); + + it("falls back to an inference-qualified model ref when provider metadata lacks name", () => { + const { result, config, hash } = runReconcile({ + agents: { defaults: { model: { primary: "inference/old-model" } } }, + models: { + providers: { + inference: { + api: "openai-completions", + models: [{ id: "nvidia/new-model" }], + }, + }, + }, + }); + + expect(result.status).toBe(0); + expect(config.agents.defaults.model.primary).toBe("inference/nvidia/new-model"); + expect(hash).not.toBe("oldhash\n"); + expect(hash).toContain("openclaw.json"); + }); + + it("is a no-op when openclaw.json has no inference provider", () => { + const { result, config, hash } = runReconcile({ + agents: { defaults: { model: { primary: "inference/old-model" } } }, + models: { providers: {} }, + }); + + expect(result.status).toBe(0); + expect(config.agents.defaults.model.primary).toBe("inference/old-model"); + expect(hash).toBe("oldhash\n"); + }); + + it("is a no-op when openclaw.json is missing required keys", () => { + const { result, config, hash } = runReconcile({ unrelated: true }); + + expect(result.status).toBe(0); + expect(config).toEqual({ unrelated: true }); + expect(hash).toBe("oldhash\n"); + }); +}); diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index ebdd2410514..0ec308bb332 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -262,6 +262,7 @@ describe("nemoclaw-start non-root fallback", () => { 'verify_config_integrity_if_locked() { :; }', 'normalize_mutable_config_perms() { :; }', 'apply_model_override() { :; }', + 'reconcile_agent_model_with_provider() { :; }', 'apply_cors_override() { :; }', 'export_gateway_token() { :; }', 'write_runtime_shell_env() { :; }', @@ -1697,6 +1698,7 @@ describe("Telegram diagnostics (#2766)", () => { 'verify_config_integrity_if_locked() { echo "ORDER:verify"; }', 'normalize_mutable_config_perms() { echo "ORDER:normalize"; }', 'apply_model_override() { :; }', + 'reconcile_agent_model_with_provider() { :; }', 'apply_cors_override() { :; }', 'export_gateway_token() { :; }', 'write_runtime_shell_env() { :; }',