From 4fa445567502018782e7991baa47d047afe93308 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 10 May 2026 06:29:18 -0700 Subject: [PATCH 1/6] feat(hermes): add provider onboarding foundation Replay the Hermes Provider onboarding foundation from NousResearch PR #3237 while keeping durable credential ownership in OpenShell provider storage. Co-authored-by: Shannon Sands Signed-off-by: Shannon Sands Signed-off-by: Aaron Erickson --- agents/hermes/config/messaging-config.ts | 3 + src/lib/actions/sandbox/rebuild.ts | 125 ++++++- src/lib/hermes-provider-auth.test.ts | 150 +++++++++ src/lib/hermes-provider-auth.ts | 186 +++++++++++ src/lib/inference/config.test.ts | 36 ++ src/lib/inference/config.ts | 42 +++ src/lib/inference/model-prompts.test.ts | 34 ++ src/lib/inference/model-prompts.ts | 59 +++- src/lib/inference/nous-models.test.ts | 87 +++++ src/lib/inference/nous-models.ts | 122 +++++++ src/lib/messaging-channel-config.test.ts | 3 + src/lib/oauth-device-code.test.ts | 133 ++++++++ src/lib/oauth-device-code.ts | 379 +++++++++++++++++++++ src/lib/onboard.ts | 382 ++++++++++++++++++++-- src/lib/onboard/providers.ts | 22 +- src/lib/sandbox-channels.test.ts | 9 + src/lib/sandbox-channels.ts | 5 + src/lib/security/redact.ts | 2 +- src/lib/state/onboard-session.test.ts | 22 ++ src/lib/state/onboard-session.ts | 16 + src/lib/state/sandbox-session.test.ts | 14 + src/lib/state/sandbox-session.ts | 6 +- test/generate-hermes-config.test.ts | 2 + test/hermes-provider-foundation.test.ts | 144 ++++++++ test/rebuild-credential-preflight.test.ts | 71 ++++ 25 files changed, 2012 insertions(+), 42 deletions(-) create mode 100644 src/lib/hermes-provider-auth.test.ts create mode 100644 src/lib/hermes-provider-auth.ts create mode 100644 src/lib/inference/nous-models.test.ts create mode 100644 src/lib/inference/nous-models.ts create mode 100644 src/lib/oauth-device-code.test.ts create mode 100644 src/lib/oauth-device-code.ts create mode 100644 test/hermes-provider-foundation.test.ts diff --git a/agents/hermes/config/messaging-config.ts b/agents/hermes/config/messaging-config.ts index 871609e6f5f..eb27fdeb568 100644 --- a/agents/hermes/config/messaging-config.ts +++ b/agents/hermes/config/messaging-config.ts @@ -41,6 +41,9 @@ export function buildMessagingEnvLines( if (allowedIds.telegram?.length) { envLines.push(`TELEGRAM_ALLOWED_USERS=${allowedIds.telegram.map(String).join(",")}`); } + if (allowedIds.slack?.length) { + envLines.push(`SLACK_ALLOWED_USERS=${allowedIds.slack.map(String).join(",")}`); + } return envLines; } diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index a60b7e73399..79ac00acfb7 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -12,6 +12,17 @@ import { const { hydrateCredentialEnv } = require("../../onboard") as { hydrateCredentialEnv: (name: string) => string | null; }; +const hermesProviderAuth = require("../../hermes-provider-auth") as { + HERMES_PROVIDER_NAME: string; + HERMES_NOUS_API_KEY_CREDENTIAL_ENV: string; + isHermesProviderRegistered: (runOpenshellFn: typeof runOpenshell) => boolean; + registerHermesInferenceProvider: ( + apiKey: string, + runOpenshellFn: typeof runOpenshell, + credentialEnv?: string, + baseUrl?: string, + ) => void; +}; const { LOCAL_INFERENCE_PROVIDERS, REMOTE_PROVIDER_CONFIG } = require("../../onboard/providers") as { LOCAL_INFERENCE_PROVIDERS: string[]; REMOTE_PROVIDER_CONFIG: Record; @@ -61,6 +72,84 @@ function getRebuildCredentialEnvFromRegistry(provider: string | null | undefined return remoteConfig?.credentialEnv || null; } +function normalizeHermesRebuildAuthMethod(value: unknown): "oauth" | "api_key" | null { + const normalized = String(value || "") + .trim() + .toLowerCase() + .replace(/[\s-]+/g, "_"); + if (!normalized) return null; + if (normalized === "oauth" || normalized === "nous_oauth" || normalized === "nous_portal_oauth") { + return "oauth"; + } + if ( + normalized === "api" || + normalized === "key" || + normalized === "api_key" || + normalized === "apikey" || + normalized === "nous_api_key" + ) { + return "api_key"; + } + return null; +} + +function nonEmptyString(value: unknown): string | null { + const normalized = String(value || "").trim(); + return normalized || null; +} + +function preflightHermesProviderCredentials( + session: Session | null, + credentialEnv: string | null, + log: (msg: string) => void, +): boolean { + const authMethod = + normalizeHermesRebuildAuthMethod(session?.hermesAuthMethod) || + (credentialEnv === hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV ? "api_key" : null); + + if (hermesProviderAuth.isHermesProviderRegistered(runOpenshell)) { + log("Hermes Provider rebuild preflight: provider is registered in OpenShell"); + return true; + } + + if (authMethod === "api_key") { + const envKey = + nonEmptyString(process.env[hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV]) || + nonEmptyString(process.env.NEMOCLAW_PROVIDER_KEY); + log( + `Hermes Provider rebuild preflight: OpenShell provider missing; ${hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV} env=${envKey ? "present" : "missing"}`, + ); + if (envKey) { + try { + hermesProviderAuth.registerHermesInferenceProvider( + envKey, + runOpenshell, + hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV, + ); + return true; + } catch (err) { + log( + `Hermes Provider rebuild preflight: failed to register OpenShell provider: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + } + + console.error(""); + console.error(` ${_RD}Rebuild preflight failed:${R} Hermes Provider is not registered in OpenShell.`); + console.error(" Hermes Provider credentials must be stored in OpenShell, not host-side files."); + if (authMethod === "api_key") { + console.error( + ` Export ${hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV} and rerun rebuild, or re-run ${CLI_NAME} onboard to register it.`, + ); + } else { + console.error(` Re-run ${CLI_NAME} onboard interactively to authorize Hermes Provider and register it with OpenShell.`); + } + console.error(""); + console.error(" Sandbox is untouched — no data was lost."); + return false; +} + /** * Rebuild a live sandbox while preserving registered agent state and policies. * @@ -160,23 +249,27 @@ export async function rebuildSandbox( // credential when onboard runs in non-interactive mode. Checking now // lets us abort with the sandbox still intact. See #2273. const session = onboardSession.loadSession(); + const sessionMatchesTarget = session?.sandboxName === sandboxName; let rebuildCredentialEnv: string | null = null; - if (session && session.sandboxName && session.sandboxName !== sandboxName) { + if (!sessionMatchesTarget) { // Session belongs to a different sandbox — its credentialEnv may be // wrong (e.g. hermes session while rebuilding openclaw). Resolve the // target sandbox provider from the registry instead so destructive // operations still get a credential preflight for the sandbox being rebuilt. rebuildCredentialEnv = getRebuildCredentialEnvFromRegistry(sb.provider); - log( - `Preflight warning: session belongs to '${session.sandboxName}', not '${sandboxName}' — using registry credential env ${rebuildCredentialEnv || "(none)"}`, - ); - console.log( - ` ${D}Note: onboard session belongs to '${session.sandboxName}', not '${sandboxName}'. ` + - `Using the '${sandboxName}' registry entry for credential preflight.${R}`, - ); + if (session?.sandboxName) { + log( + `Preflight warning: session belongs to '${session.sandboxName}', not '${sandboxName}' — using registry credential env ${rebuildCredentialEnv || "(none)"}`, + ); + console.log( + ` ${D}Note: onboard session belongs to '${session.sandboxName}', not '${sandboxName}'. ` + + `Using the '${sandboxName}' registry entry for credential preflight.${R}`, + ); + } } else { rebuildCredentialEnv = session?.credentialEnv || null; } + const rebuildProvider = sessionMatchesTarget ? session?.provider || sb.provider : sb.provider; // Legacy migration: pre-fix local-inference sandboxes (GH #2519, GH #2625) // recorded credentialEnv="OPENAI_API_KEY" in onboard-session.json even // though the sandbox does not actually need a host OpenAI key (ollama-local @@ -201,6 +294,22 @@ export async function rebuildSandbox( ); rebuildCredentialEnv = null; } + if (rebuildProvider === hermesProviderAuth.HERMES_PROVIDER_NAME) { + if ( + !preflightHermesProviderCredentials( + sessionMatchesTarget ? session : null, + rebuildCredentialEnv, + log, + ) + ) { + bail("Missing Hermes Provider credentials"); + return; + } + // Hermes Provider credentials belong to OpenShell provider storage. Do not + // fall through to the generic env-var preflight, which would incorrectly + // demand OPENAI_API_KEY/NOUS_API_KEY after the provider is registered. + rebuildCredentialEnv = null; + } if (rebuildCredentialEnv) { // hydrateCredentialEnv migrates any pre-fix legacy credentials.json // into process.env once, so users upgrading from a release that wrote diff --git a/src/lib/hermes-provider-auth.test.ts b/src/lib/hermes-provider-auth.test.ts new file mode 100644 index 00000000000..62ceea134ab --- /dev/null +++ b/src/lib/hermes-provider-auth.test.ts @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; + +import { afterEach, describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const DIST_AUTH = path.join( + import.meta.dirname, + "..", + "..", + "dist", + "lib", + "hermes-provider-auth.js", +); + +function clearDistModule(modulePath: string): void { + try { + delete require.cache[require.resolve(modulePath)]; + } catch { + // not loaded + } +} + +function loadAuth(): Record { + clearDistModule(DIST_AUTH); + return require(DIST_AUTH); +} + +afterEach(() => { + clearDistModule(DIST_AUTH); +}); + +describe("Hermes provider OpenShell credential handoff", () => { + it("registers Nous API-key inference in OpenShell without host-side persistence", async () => { + const originalHome = process.env.HOME; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-api-key-")); + try { + process.env.HOME = tmp; + const auth = loadAuth(); + const calls: Array<{ args: string[]; env?: Record }> = []; + const state = await auth.ensureHermesProviderApiKeyCredentials("my-assistant", { + apiKey: "nous-key-1", + runOpenshell: (args: string[], opts: { env?: Record } = {}) => { + calls.push({ args, env: opts.env }); + if (args[0] === "provider" && args[1] === "get") { + return { status: 1, stdout: "", stderr: "" }; + } + return { status: 0, stdout: "", stderr: "" }; + }, + }); + + expect(state.auth_method).toBe("api_key"); + expect(state.credential_env).toBe("NOUS_API_KEY"); + expect(calls.some((call) => call.args.includes("hermes-provider"))).toBe(true); + expect(calls.some((call) => call.args.includes("NOUS_API_KEY"))).toBe(true); + expect(calls.some((call) => call.env?.NOUS_API_KEY === "nous-key-1")).toBe(true); + expect(fs.existsSync(path.join(tmp, ".nemoclaw", "hermes-oauth"))).toBe(false); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("uses OAuth only as an in-memory minting step before OpenShell registration", async () => { + const originalHome = process.env.HOME; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-oauth-")); + try { + process.env.HOME = tmp; + const auth = loadAuth(); + const fetchCalls: Array<{ url: string; auth: string | null; body: string }> = []; + const providerCalls: Array<{ args: string[]; env?: Record }> = []; + const state = await auth.ensureHermesProviderOAuthCredentials("my-assistant", { + allowInteractiveLogin: true, + fetch: (async (url, init) => { + const headers = new Headers(init?.headers); + fetchCalls.push({ + url: String(url), + auth: headers.get("authorization"), + body: String(init?.body ?? ""), + }); + if (String(url).endsWith("/api/oauth/device/code")) { + return new Response( + JSON.stringify({ + device_code: "device-1", + user_code: "USER-1", + verification_uri: "https://portal.example/verify", + expires_in: 900, + interval: 1, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (String(url).endsWith("/api/oauth/token")) { + return new Response( + JSON.stringify({ + access_token: "access-2", + refresh_token: "refresh-2", + expires_in: 900, + token_type: "Bearer", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response( + JSON.stringify({ + api_key: "agent-key-1", + key_id: "agent-key-id", + expires_in: 1800, + inference_base_url: "https://staging.nous.example/v1", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }) as typeof fetch, + log: () => {}, + noBrowser: true, + runOpenshell: (args: string[], opts: { env?: Record } = {}) => { + providerCalls.push({ args, env: opts.env }); + if (args[0] === "provider" && args[1] === "get") { + return { status: 1, stdout: "", stderr: "" }; + } + return { status: 0, stdout: "", stderr: "" }; + }, + }); + + expect(state.auth_method).toBe("oauth"); + expect(state.credential_env).toBe("OPENAI_API_KEY"); + expect(state.inference_base_url).toBe("https://staging.nous.example/v1"); + expect(fetchCalls.some((call) => call.auth === "Bearer access-2")).toBe(true); + expect( + providerCalls.some((call) => call.env?.OPENAI_API_KEY === "agent-key-1"), + ).toBe(true); + expect( + providerCalls.some((call) => + call.args.includes("OPENAI_BASE_URL=https://staging.nous.example/v1"), + ), + ).toBe(true); + expect(fs.existsSync(path.join(tmp, ".nemoclaw", "hermes-oauth"))).toBe(false); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/src/lib/hermes-provider-auth.ts b/src/lib/hermes-provider-auth.ts new file mode 100644 index 00000000000..5c356d8b247 --- /dev/null +++ b/src/lib/hermes-provider-auth.ts @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Hermes Provider credential orchestration. + * + * NemoClaw may collect or mint a Hermes/Nous credential during onboarding, but + * it does not durably persist that secret on the host. Durable credential + * ownership stays with OpenShell provider registration. + */ + +import type { StdioOptions } from "node:child_process"; + +import * as oauth from "./oauth-device-code"; + +const onboardProviders = require("./onboard/providers") as { + providerExistsInGateway: (name: string, runOpenshell: RunOpenshell) => boolean; + upsertProvider: ( + name: string, + type: string, + credentialEnv: string, + baseUrl: string | null, + env: NodeJS.ProcessEnv, + runOpenshell: RunOpenshell, + ) => { ok: boolean; status?: number; message?: string }; +}; + +export const HERMES_PROVIDER_NAME = "hermes-provider"; +export const HERMES_INFERENCE_CREDENTIAL_ENV = "OPENAI_API_KEY"; +export const HERMES_NOUS_API_KEY_CREDENTIAL_ENV = "NOUS_API_KEY"; +export const AGENT_KEY_MIN_TTL_SECONDS = 1800; + +export type HermesAuthMethod = "oauth" | "api_key"; + +type RunOpenshellResult = { + status?: number | null; + stdout?: string | Buffer | null; + stderr?: string | Buffer | null; +}; + +export type RunOpenshell = ( + args: string[], + opts?: { + env?: NodeJS.ProcessEnv; + stdio?: StdioOptions; + ignoreError?: boolean; + timeout?: number; + }, +) => RunOpenshellResult; + +export type HermesProviderCredentialState = { + auth_method: HermesAuthMethod; + provider: typeof HERMES_PROVIDER_NAME; + credential_env: string; + inference_base_url: string; + agent_key_expires_at?: string | null; +}; + +function nonEmptyString(value: unknown): string | null { + const normalized = String(value || "").trim(); + return normalized || null; +} + +function agentKeyExpiresAt(minted: oauth.AgentKeyResponse): string | null { + if (minted.expires_at) return minted.expires_at; + if (typeof minted.expires_in === "number" && Number.isFinite(minted.expires_in)) { + return new Date(Date.now() + minted.expires_in * 1000).toISOString(); + } + return null; +} + +export function isHermesProviderRegistered(runOpenshell: RunOpenshell): boolean { + return onboardProviders.providerExistsInGateway(HERMES_PROVIDER_NAME, runOpenshell); +} + +export function registerHermesInferenceProvider( + apiKey: string, + runOpenshell: RunOpenshell, + credentialEnv = HERMES_INFERENCE_CREDENTIAL_ENV, + baseUrl = oauth.DEFAULT_INFERENCE_BASE_URL, +): void { + const normalizedApiKey = nonEmptyString(apiKey); + if (!normalizedApiKey) { + throw new Error("Hermes Provider credential is empty"); + } + const result = onboardProviders.upsertProvider( + HERMES_PROVIDER_NAME, + "openai", + credentialEnv, + baseUrl, + { [credentialEnv]: normalizedApiKey }, + runOpenshell, + ); + if (!result.ok) { + throw new Error(result.message || `failed to upsert provider '${HERMES_PROVIDER_NAME}'`); + } +} + +export async function ensureHermesProviderOAuthCredentials( + _sandboxName: string, + { + allowInteractiveLogin = true, + runOpenshell = null, + log = console.error, + fetch = undefined, + noBrowser = false, + baseUrl = oauth.DEFAULT_INFERENCE_BASE_URL, + }: { + allowInteractiveLogin?: boolean; + runOpenshell?: RunOpenshell | null; + log?: (line: string) => void; + fetch?: typeof globalThis.fetch; + noBrowser?: boolean; + baseUrl?: string; + } = {}, +): Promise { + if (!runOpenshell) { + throw new Error("OpenShell runner is required for Hermes Provider credential storage"); + } + if (!allowInteractiveLogin) { + return null; + } + + const tokens = await oauth.runDeviceCodeFlow({ fetch, log, noBrowser }); + const minted = await oauth.mintAgentKeyWithAccessToken(tokens.access_token, { + fetch, + minTtlSeconds: AGENT_KEY_MIN_TTL_SECONDS, + }); + const inferenceBaseUrl = minted.inference_base_url || baseUrl; + registerHermesInferenceProvider( + minted.api_key, + runOpenshell, + HERMES_INFERENCE_CREDENTIAL_ENV, + inferenceBaseUrl, + ); + return { + auth_method: "oauth", + provider: HERMES_PROVIDER_NAME, + credential_env: HERMES_INFERENCE_CREDENTIAL_ENV, + inference_base_url: inferenceBaseUrl, + agent_key_expires_at: agentKeyExpiresAt(minted), + }; +} + +export async function ensureHermesProviderApiKeyCredentials( + _sandboxName: string, + { + apiKey = null, + runOpenshell = null, + baseUrl = oauth.DEFAULT_INFERENCE_BASE_URL, + }: { + apiKey?: string | null; + runOpenshell?: RunOpenshell | null; + baseUrl?: string; + } = {}, +): Promise { + if (!runOpenshell) { + throw new Error("OpenShell runner is required for Hermes Provider credential storage"); + } + const normalizedApiKey = nonEmptyString(apiKey); + if (!normalizedApiKey) return null; + + registerHermesInferenceProvider( + normalizedApiKey, + runOpenshell, + HERMES_NOUS_API_KEY_CREDENTIAL_ENV, + baseUrl, + ); + return { + auth_method: "api_key", + provider: HERMES_PROVIDER_NAME, + credential_env: HERMES_NOUS_API_KEY_CREDENTIAL_ENV, + inference_base_url: baseUrl, + }; +} + +module.exports = { + HERMES_PROVIDER_NAME, + HERMES_INFERENCE_CREDENTIAL_ENV, + HERMES_NOUS_API_KEY_CREDENTIAL_ENV, + AGENT_KEY_MIN_TTL_SECONDS, + isHermesProviderRegistered, + registerHermesInferenceProvider, + ensureHermesProviderOAuthCredentials, + ensureHermesProviderApiKeyCredentials, +}; diff --git a/src/lib/inference/config.test.ts b/src/lib/inference/config.test.ts index d8eb37e7734..1d79aa210ae 100644 --- a/src/lib/inference/config.test.ts +++ b/src/lib/inference/config.test.ts @@ -6,9 +6,11 @@ import { describe, it, expect } from "vitest"; // Import from compiled dist/ for correct coverage attribution. import { CLOUD_MODEL_OPTIONS, + DEFAULT_HERMES_PROVIDER_MODEL, DEFAULT_OLLAMA_MODEL, DEFAULT_ROUTE_CREDENTIAL_ENV, DEFAULT_ROUTE_PROFILE, + HERMES_PROVIDER_MODEL_OPTIONS, INFERENCE_ROUTE_URL, MANAGED_PROVIDER_ID, OLLAMA_LOCAL_CREDENTIAL_ENV, @@ -31,6 +33,23 @@ describe("inference selection config", () => { ]); }); + it("aligns Hermes Provider defaults with the Hermes Agent Nous catalog", () => { + expect(DEFAULT_HERMES_PROVIDER_MODEL).toBe("moonshotai/kimi-k2.6"); + expect(HERMES_PROVIDER_MODEL_OPTIONS.slice(0, 10)).toEqual([ + "moonshotai/kimi-k2.6", + "xiaomi/mimo-v2.5-pro", + "xiaomi/mimo-v2.5", + "tencent/hy3-preview", + "anthropic/claude-opus-4.7", + "anthropic/claude-opus-4.6", + "anthropic/claude-sonnet-4.6", + "anthropic/claude-sonnet-4.5", + "anthropic/claude-haiku-4.5", + "openai/gpt-5.5", + ]); + expect(HERMES_PROVIDER_MODEL_OPTIONS.length).toBeGreaterThan(10); + }); + it("maps ollama-local to the sandbox inference route and default model", () => { // Local Ollama uses a dedicated credential env so the sandbox-side // config never points at OPENAI_API_KEY (GH #2519). @@ -103,6 +122,19 @@ describe("inference selection config", () => { provider: "compatible-endpoint", providerLabel: "Other OpenAI-compatible endpoint", }); + expect(getProviderSelectionConfig("hermes-provider", "anthropic/claude-opus-4.7")).toEqual({ + endpointType: "custom", + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + model: "anthropic/claude-opus-4.7", + profile: DEFAULT_ROUTE_PROFILE, + credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, + provider: "hermes-provider", + providerLabel: "Hermes Provider", + }); + expect(getProviderSelectionConfig("hermes-provider")).toEqual( + expect.objectContaining({ model: DEFAULT_HERMES_PROVIDER_MODEL }), + ); // Full-object assertion for one local provider — uses dedicated // credential env, not OPENAI_API_KEY (GH #2519). expect(getProviderSelectionConfig("vllm-local", "meta-llama")).toEqual({ @@ -131,6 +163,7 @@ describe("inference selection config", () => { "compatible-anthropic-endpoint", "gemini-api", "compatible-endpoint", + "hermes-provider", "vllm-local", "ollama-local", ]; @@ -166,6 +199,9 @@ describe("inference selection config", () => { expect(getProviderSelectionConfig("compatible-anthropic-endpoint")?.model).toBe( "custom-anthropic-model", ); + expect(getProviderSelectionConfig("hermes-provider")?.model).toBe( + DEFAULT_HERMES_PROVIDER_MODEL, + ); expect(getProviderSelectionConfig("vllm-local")?.model).toBe("vllm-local"); }); diff --git a/src/lib/inference/config.ts b/src/lib/inference/config.ts index 4e0bddab25d..5c51e89f54d 100644 --- a/src/lib/inference/config.ts +++ b/src/lib/inference/config.ts @@ -9,7 +9,42 @@ import { DEFAULT_OLLAMA_MODEL } from "./local"; export const INFERENCE_ROUTE_URL = "https://inference.local/v1"; +export const NOUS_RECOMMENDED_MODELS_URL = + "https://portal.nousresearch.com/api/nous/recommended-models"; export const DEFAULT_CLOUD_MODEL = "nvidia/nemotron-3-super-120b-a12b"; +export const HERMES_PROVIDER_MODEL_OPTIONS = [ + "moonshotai/kimi-k2.6", + "xiaomi/mimo-v2.5-pro", + "xiaomi/mimo-v2.5", + "tencent/hy3-preview", + "anthropic/claude-opus-4.7", + "anthropic/claude-opus-4.6", + "anthropic/claude-sonnet-4.6", + "anthropic/claude-sonnet-4.5", + "anthropic/claude-haiku-4.5", + "openai/gpt-5.5", + "openai/gpt-5.4-mini", + "openai/gpt-5.3-codex", + "google/gemini-3-pro-preview", + "google/gemini-3-flash-preview", + "google/gemini-3.1-pro-preview", + "google/gemini-3.1-flash-lite-preview", + "qwen/qwen3.5-plus-02-15", + "qwen/qwen3.5-35b-a3b", + "stepfun/step-3.5-flash", + "minimax/minimax-m2.7", + "minimax/minimax-m2.5", + "minimax/minimax-m2.5:free", + "z-ai/glm-5.1", + "z-ai/glm-5v-turbo", + "z-ai/glm-5-turbo", + "x-ai/grok-4.20-beta", + "nvidia/nemotron-3-super-120b-a12b", + "arcee-ai/trinity-large-thinking", + "openai/gpt-5.5-pro", + "openai/gpt-5.4-nano", +] as const; +export const DEFAULT_HERMES_PROVIDER_MODEL = HERMES_PROVIDER_MODEL_OPTIONS[0]; export const CLOUD_MODEL_OPTIONS = [ { id: "nvidia/nemotron-3-super-120b-a12b", label: "Nemotron 3 Super 120B" }, { id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", label: "Nemotron 3 Nano Omni 30B" }, @@ -101,6 +136,13 @@ export function getProviderSelectionConfig( credentialEnv: "COMPATIBLE_API_KEY", providerLabel: "Other OpenAI-compatible endpoint", }; + case "hermes-provider": + return { + ...base, + model: model || DEFAULT_HERMES_PROVIDER_MODEL, + credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, + providerLabel: "Hermes Provider", + }; case "vllm-local": return { ...base, diff --git a/src/lib/inference/model-prompts.test.ts b/src/lib/inference/model-prompts.test.ts index 6332dd2b052..45b2664f6de 100644 --- a/src/lib/inference/model-prompts.test.ts +++ b/src/lib/inference/model-prompts.test.ts @@ -118,6 +118,40 @@ describe("model prompt helpers", () => { expect(result).toBe("custom-model"); }); + it("opens the full model list from long curated remote catalogs", async () => { + const modelOptions = Array.from({ length: 12 }, (_, index) => `model-${index + 1}`); + const writeLine = vi.fn(); + const result = await promptRemoteModel("Hermes Provider", "hermesProvider", "model-1", null, { + promptFn: promptSequence(["4", "11"]), + writeLine, + remoteModelOptions: { hermesProvider: modelOptions }, + topLevelModelLimit: 3, + otherShowsFullList: true, + }); + + expect(result).toBe("model-11"); + expect(writeLine).toHaveBeenCalledWith(" 4) Other..."); + expect(writeLine).toHaveBeenCalledWith(" Hermes Provider full model list:"); + expect(writeLine).toHaveBeenCalledWith(" 11) model-11"); + }); + + it("limits top-level remote catalogs before manual-entry fallback", async () => { + const modelOptions = Array.from({ length: 12 }, (_, index) => `model-${index + 1}`); + const writeLine = vi.fn(); + const result = await promptRemoteModel("Hermes Provider", "hermesProvider", "model-12", null, { + promptFn: promptSequence(["", "custom-model"]), + writeLine, + remoteModelOptions: { hermesProvider: modelOptions }, + topLevelModelLimit: 3, + otherShowsFullList: false, + }); + + expect(result).toBe("custom-model"); + expect(writeLine).toHaveBeenCalledWith(" 3) model-3"); + expect(writeLine).not.toHaveBeenCalledWith(" 4) model-4"); + expect(writeLine).toHaveBeenCalledWith(" 4) Other..."); + }); + it("retries invalid input models until validation succeeds", async () => { const promptFn = promptSequence(["bad model", "other", "candidate"]); const errorLine = vi.fn(); diff --git a/src/lib/inference/model-prompts.ts b/src/lib/inference/model-prompts.ts index 5629c958860..94fe19e9114 100644 --- a/src/lib/inference/model-prompts.ts +++ b/src/lib/inference/model-prompts.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { CLOUD_MODEL_OPTIONS } from "./config"; +import { CLOUD_MODEL_OPTIONS, HERMES_PROVIDER_MODEL_OPTIONS } from "./config"; import { isSafeModelId } from "../validation"; import { validateNvidiaEndpointModel } from "./provider-models"; @@ -21,6 +21,7 @@ export const REMOTE_MODEL_OPTIONS: Record = { "gemini-2.5-flash", "gemini-2.5-flash-lite", ], + hermesProvider: [...HERMES_PROVIDER_MODEL_OPTIONS], }; export interface PromptValidationResult { @@ -42,6 +43,10 @@ export interface ModelPromptOptions { backToSelection?: string; /** Pre-fill this model ID as the default in interactive prompts. */ defaultModelId?: string; + /** Show only this many remote models in the first menu before offering Other. */ + topLevelModelLimit?: number; + /** When true, Other opens the full model list before falling back to manual entry. */ + otherShowsFullList?: boolean; } function getNavigationChoice(value = ""): "back" | "exit" | null { @@ -179,13 +184,63 @@ export async function promptRemoteModel( const deps = resolvePromptOptions(options); const modelOptions = deps.remoteModelOptions[providerKey] || []; const defaultIndex = Math.max(0, modelOptions.indexOf(defaultModel)); + const topLevelLimit = + options.topLevelModelLimit && options.topLevelModelLimit > 0 + ? Math.min(options.topLevelModelLimit, modelOptions.length) + : modelOptions.length; + const shouldOfferFullList = + options.otherShowsFullList === true && topLevelLimit < modelOptions.length; + const visibleOptions = modelOptions.slice(0, topLevelLimit); + const defaultChoice = + defaultIndex >= visibleOptions.length + ? visibleOptions.length + 1 + : Math.min(defaultIndex, Math.max(visibleOptions.length - 1, 0)) + 1; deps.writeLine(""); deps.writeLine(` ${label} models:`); + visibleOptions.forEach((option, index) => { + deps.writeLine(` ${index + 1}) ${option}`); + }); + deps.writeLine(` ${visibleOptions.length + 1}) Other...`); + deps.writeLine(""); + + const choice = await deps.promptFn(` Choose model [${defaultChoice}]: `); + const navigation = deps.getNavigationChoiceFn(choice); + if (navigation === "back") { + return deps.backToSelection; + } + if (navigation === "exit") { + deps.exitFn(); + } + const index = parseInt(choice || String(defaultChoice), 10) - 1; + if (Number.isFinite(index) && index >= 0 && index < visibleOptions.length) { + return visibleOptions[index]; + } + if (index === visibleOptions.length) { + return shouldOfferFullList + ? promptFullRemoteModelList(label, modelOptions, defaultModel, validator, deps) + : promptManualModelId(` ${label} model id: `, label, validator, deps); + } + + return promptManualModelId(` ${label} model id: `, label, validator, deps); +} + +async function promptFullRemoteModelList( + label: string, + modelOptions: string[], + defaultModel: string, + validator: ((model: string) => PromptValidationResult) | null, + options: ModelPromptOptions, +): Promise { + const deps = resolvePromptOptions(options); + const defaultIndex = Math.max(0, modelOptions.indexOf(defaultModel)); + + deps.writeLine(""); + deps.writeLine(` ${label} full model list:`); modelOptions.forEach((option, index) => { deps.writeLine(` ${index + 1}) ${option}`); }); - deps.writeLine(` ${modelOptions.length + 1}) Other...`); + deps.writeLine(` ${modelOptions.length + 1}) Custom model id...`); deps.writeLine(""); const choice = await deps.promptFn(` Choose model [${defaultIndex + 1}]: `); diff --git a/src/lib/inference/nous-models.test.ts b/src/lib/inference/nous-models.test.ts new file mode 100644 index 00000000000..8c39e3b6954 --- /dev/null +++ b/src/lib/inference/nous-models.test.ts @@ -0,0 +1,87 @@ +// 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 { + extractNousRecommendedModelOptions, + getHermesProviderModelOptions, + mergeModelOptions, +} from "../../../dist/lib/inference/nous-models"; + +describe("Nous recommended model helpers", () => { + it("prepends paid portal recommendations and keeps fallback models for the full list", () => { + const models = extractNousRecommendedModelOptions( + { + paidRecommendedModels: [ + { modelName: "paid/model-b", position: 1 }, + { modelName: "paid/model-a", position: 0 }, + ], + freeRecommendedModels: [ + { modelName: "free/model-c", position: 0 }, + { modelName: "bad model", position: 1 }, + ], + }, + ["paid/model-b", "fallback/model-d"], + ); + + expect(models).toEqual([ + "paid/model-a", + "paid/model-b", + "free/model-c", + "fallback/model-d", + ]); + }); + + it("falls back when the portal payload has no usable model ids", () => { + expect( + extractNousRecommendedModelOptions( + { paidRecommendedModels: [{ modelName: "not safe" }] }, + ["fallback/model-a", "fallback/model-b"], + ), + ).toEqual(["fallback/model-a", "fallback/model-b"]); + }); + + it("deduplicates and filters model option groups", () => { + expect( + mergeModelOptions( + ["model/a", "model/a", "bad model"], + ["model/b", "model/a"], + ), + ).toEqual(["model/a", "model/b"]); + }); + + it("fetches the portal catalog when available", async () => { + const fetchFn = vi.fn(async () => ({ + ok: true, + json: async () => ({ + paidRecommendedModels: [{ modelName: "portal/model-a", position: 0 }], + }), + })); + + await expect( + getHermesProviderModelOptions({ + fallbackModels: ["fallback/model-b"], + fetchFn, + timeoutMs: 0, + url: "https://example.test/models", + }), + ).resolves.toEqual(["portal/model-a", "fallback/model-b"]); + expect(fetchFn).toHaveBeenCalledWith("https://example.test/models", expect.any(Object)); + }); + + it("uses the fallback catalog when the portal request fails", async () => { + const fetchFn = vi.fn(async () => ({ + ok: false, + json: async () => ({}), + })); + + await expect( + getHermesProviderModelOptions({ + fallbackModels: ["fallback/model-a"], + fetchFn, + timeoutMs: 0, + }), + ).resolves.toEqual(["fallback/model-a"]); + }); +}); diff --git a/src/lib/inference/nous-models.ts b/src/lib/inference/nous-models.ts new file mode 100644 index 00000000000..c4217d50589 --- /dev/null +++ b/src/lib/inference/nous-models.ts @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + HERMES_PROVIDER_MODEL_OPTIONS, + NOUS_RECOMMENDED_MODELS_URL, +} from "./config"; +import { isSafeModelId } from "../validation"; + +const DEFAULT_FETCH_TIMEOUT_MS = 2500; + +type FetchResponseLike = { + ok: boolean; + json: () => Promise; +}; + +type FetchLike = ( + url: string, + init?: { signal?: AbortSignal }, +) => Promise; + +type RecommendedModelEntry = { + modelName?: unknown; + position?: unknown; +}; + +export type NousRecommendedModelsPayload = { + paidRecommendedModels?: RecommendedModelEntry[]; + freeRecommendedModels?: RecommendedModelEntry[]; +}; + +export type HermesProviderModelOptionsParams = { + fetchFn?: FetchLike; + fallbackModels?: readonly string[]; + timeoutMs?: number; + url?: string; +}; + +function asRecommendedEntries(value: unknown): RecommendedModelEntry[] { + return Array.isArray(value) ? value : []; +} + +function sortByPortalPosition(entries: RecommendedModelEntry[]): RecommendedModelEntry[] { + return [...entries].sort((a, b) => { + const left = typeof a.position === "number" ? a.position : Number.MAX_SAFE_INTEGER; + const right = typeof b.position === "number" ? b.position : Number.MAX_SAFE_INTEGER; + return left - right; + }); +} + +export function mergeModelOptions(...groups: readonly (readonly string[])[]): string[] { + const merged: string[] = []; + const seen = new Set(); + for (const group of groups) { + for (const model of group) { + const candidate = String(model || "").trim(); + if (!candidate || !isSafeModelId(candidate) || seen.has(candidate)) continue; + seen.add(candidate); + merged.push(candidate); + } + } + return merged; +} + +export function extractNousRecommendedModelOptions( + payload: unknown, + fallbackModels: readonly string[] = HERMES_PROVIDER_MODEL_OPTIONS, +): string[] { + const source = (payload || {}) as NousRecommendedModelsPayload; + const paidModels = sortByPortalPosition( + asRecommendedEntries(source.paidRecommendedModels), + ).map((entry) => String(entry.modelName || "")); + const freeModels = sortByPortalPosition( + asRecommendedEntries(source.freeRecommendedModels), + ).map((entry) => String(entry.modelName || "")); + const recommended = mergeModelOptions(paidModels, freeModels); + + if (recommended.length === 0) { + return mergeModelOptions(fallbackModels); + } + return mergeModelOptions(recommended, fallbackModels); +} + +export async function getHermesProviderModelOptions( + params: HermesProviderModelOptionsParams = {}, +): Promise { + const fallbackModels = mergeModelOptions( + params.fallbackModels ?? HERMES_PROVIDER_MODEL_OPTIONS, + ); + const fetchFn = params.fetchFn ?? (globalThis.fetch as unknown as FetchLike | undefined); + if (typeof fetchFn !== "function") { + return fallbackModels; + } + + const timeoutMs = + typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) + ? Math.max(0, params.timeoutMs) + : DEFAULT_FETCH_TIMEOUT_MS; + const controller = new AbortController(); + const timeout = + timeoutMs > 0 + ? setTimeout(() => { + controller.abort(); + }, timeoutMs) + : null; + + try { + const response = await fetchFn(params.url ?? NOUS_RECOMMENDED_MODELS_URL, { + signal: controller.signal, + }); + if (!response.ok) { + return fallbackModels; + } + return extractNousRecommendedModelOptions(await response.json(), fallbackModels); + } catch { + return fallbackModels; + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} diff --git a/src/lib/messaging-channel-config.test.ts b/src/lib/messaging-channel-config.test.ts index 02aaac7928c..45826daaefb 100644 --- a/src/lib/messaging-channel-config.test.ts +++ b/src/lib/messaging-channel-config.test.ts @@ -18,6 +18,7 @@ describe("messaging channel config", () => { "DISCORD_SERVER_ID", "DISCORD_USER_ID", "DISCORD_REQUIRE_MENTION", + "SLACK_ALLOWED_USERS", ]); }); @@ -28,12 +29,14 @@ describe("messaging channel config", () => { TELEGRAM_REQUIRE_MENTION: "yes", DISCORD_SERVER_ID: "1491590992753590594", DISCORD_REQUIRE_MENTION: "0", + SLACK_ALLOWED_USERS: " U01ABC2DEF3, U04GHI5JKL6 ", NVIDIA_API_KEY: "not-channel-config", }), ).toEqual({ TELEGRAM_ALLOWED_IDS: "123,456", DISCORD_SERVER_ID: "1491590992753590594", DISCORD_REQUIRE_MENTION: "0", + SLACK_ALLOWED_USERS: "U01ABC2DEF3, U04GHI5JKL6", }); }); diff --git a/src/lib/oauth-device-code.test.ts b/src/lib/oauth-device-code.test.ts new file mode 100644 index 00000000000..ba02830dd8c --- /dev/null +++ b/src/lib/oauth-device-code.test.ts @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + mintAgentKeyWithAccessToken, + pollForToken, + refreshAccessTokenWithRefreshToken, +} from "../../dist/lib/oauth-device-code"; + +describe("pollForToken", () => { + it("rejects successful token responses missing an access token", async () => { + await expect( + pollForToken( + { + device_code: "device-1", + user_code: "USER-1", + verification_uri: "https://portal.example/verify", + expires_in: 900, + interval: 1, + }, + { + sleep: async () => {}, + log: () => {}, + fetch: (async () => + new Response( + JSON.stringify({ + refresh_token: "refresh-1", + expires_in: 900, + token_type: "Bearer", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + )) as typeof fetch, + }, + ), + ).rejects.toMatchObject({ + name: "OAuthError", + code: "token_response_missing_tokens", + }); + }); +}); + +describe("refreshAccessTokenWithRefreshToken", () => { + it("uses the host-side refresh-token grant form body", async () => { + const calls: Array<{ url: string; body: string }> = []; + const token = await refreshAccessTokenWithRefreshToken("refresh-1", { + fetch: (async (url, init) => { + calls.push({ + url: String(url), + body: String(init?.body ?? ""), + }); + return new Response( + JSON.stringify({ + access_token: "access-2", + refresh_token: "refresh-2", + expires_in: 900, + token_type: "Bearer", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }) as typeof fetch, + }); + + expect(token.access_token).toBe("access-2"); + expect(token.refresh_token).toBe("refresh-2"); + expect(calls[0]?.url).toBe( + "https://portal.nousresearch.com/api/oauth/token", + ); + expect(new URLSearchParams(calls[0]?.body).get("grant_type")).toBe( + "refresh_token", + ); + expect(new URLSearchParams(calls[0]?.body).get("refresh_token")).toBe( + "refresh-1", + ); + expect(new URLSearchParams(calls[0]?.body).get("client_id")).toBe( + "hermes-cli", + ); + }); + + it("surfaces refresh-token grant errors", async () => { + await expect( + refreshAccessTokenWithRefreshToken("bad-refresh", { + fetch: (async () => + new Response( + JSON.stringify({ + error: "invalid_grant", + error_description: "refresh token expired", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + )) as typeof fetch, + }), + ).rejects.toMatchObject({ + name: "OAuthError", + code: "invalid_grant", + description: "refresh token expired", + }); + }); +}); + +describe("mintAgentKeyWithAccessToken", () => { + it("mints a short-lived agent key with Authorization bearer auth", async () => { + const calls: Array<{ url: string; auth: string | null; body: string }> = []; + const key = await mintAgentKeyWithAccessToken("access-1", { + minTtlSeconds: 120, + fetch: (async (url, init) => { + const headers = new Headers(init?.headers); + calls.push({ + url: String(url), + auth: headers.get("authorization"), + body: String(init?.body ?? ""), + }); + return new Response( + JSON.stringify({ + api_key: "agent-key-1", + key_id: "key-1", + expires_in: 1800, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }) as typeof fetch, + }); + + expect(key.api_key).toBe("agent-key-1"); + expect(calls[0]?.url).toBe( + "https://portal.nousresearch.com/api/oauth/agent-key", + ); + expect(calls[0]?.auth).toBe("Bearer access-1"); + expect(JSON.parse(calls[0]?.body ?? "{}")).toEqual({ + min_ttl_seconds: 120, + }); + }); +}); diff --git a/src/lib/oauth-device-code.ts b/src/lib/oauth-device-code.ts new file mode 100644 index 00000000000..50e84412f4d --- /dev/null +++ b/src/lib/oauth-device-code.ts @@ -0,0 +1,379 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * OAuth 2.0 Device Authorization Grant helpers for Hermes Provider onboarding. + * + * NemoClaw keeps Nous Portal OAuth on the host. Onboarding stores the + * refresh-token state under ~/.nemoclaw and uses it to mint short-lived + * agent keys for the OpenShell inference provider. The sandbox receives the + * normal OpenShell inference placeholder, never raw Nous OAuth tokens. + */ + +import { spawn } from "node:child_process"; + +export const DEFAULT_PORTAL_BASE_URL = "https://portal.nousresearch.com"; +export const DEFAULT_INFERENCE_BASE_URL = + "https://inference-api.nousresearch.com/v1"; +export const DEFAULT_CLIENT_ID = "hermes-cli"; +export const DEFAULT_SCOPE = "inference:mint_agent_key"; + +const POLL_INTERVAL_MIN_SECONDS = 1; +const POLL_INTERVAL_MAX_SECONDS = 30; +const DEFAULT_TIMEOUT_SECONDS = 15 * 60; + +export interface DeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete?: string; + expires_in: number; + interval: number; +} + +export interface TokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + token_type: string; + scope?: string; + iat?: number; + exp?: number; +} + +export interface AgentKeyResponse { + api_key: string; + key_id?: string; + expires_at?: string; + expires_in?: number; + reused?: boolean; + inference_base_url?: string; +} + +export interface DeviceCodeFlowOptions { + portalBaseUrl?: string; + clientId?: string; + scope?: string; + timeoutSeconds?: number; + noBrowser?: boolean; + now?: () => number; + sleep?: (ms: number) => Promise; + fetch?: typeof fetch; + log?: (line: string) => void; +} + +export class OAuthError extends Error { + code: string; + description?: string; + + constructor(code: string, description?: string) { + super(description ? `${code}: ${description}` : code); + this.name = "OAuthError"; + this.code = code; + this.description = description; + } +} + +export class OAuthTimeoutError extends OAuthError { + constructor() { + super("timeout", "device code expired before user completed approval"); + this.name = "OAuthTimeoutError"; + } +} + +function openBrowser(url: string): void { + let command: string; + let args: string[]; + switch (process.platform) { + case "darwin": + command = "open"; + args = [url]; + break; + case "win32": + command = "cmd"; + args = ["/c", "start", "", url]; + break; + default: + command = "xdg-open"; + args = [url]; + break; + } + try { + const child = spawn(command, args, { detached: true, stdio: "ignore" }); + child.on("error", () => {}); + child.unref(); + } catch { + // Best effort only; the URL is also printed for copy/paste. + } +} + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function clampInterval(value: unknown): number { + const n = + typeof value === "number" && Number.isFinite(value) + ? value + : POLL_INTERVAL_MIN_SECONDS; + return Math.min( + POLL_INTERVAL_MAX_SECONDS, + Math.max(POLL_INTERVAL_MIN_SECONDS, n), + ); +} + +async function postForm( + url: string, + body: Record, + fetchImpl: typeof fetch, +): Promise { + return fetchImpl(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(body).toString(), + }); +} + +export async function requestDeviceCode( + opts: DeviceCodeFlowOptions = {}, +): Promise { + const fetchImpl = opts.fetch ?? fetch; + const portalBaseUrl = opts.portalBaseUrl ?? DEFAULT_PORTAL_BASE_URL; + const clientId = opts.clientId ?? DEFAULT_CLIENT_ID; + const scope = opts.scope ?? DEFAULT_SCOPE; + + const resp = await postForm( + `${portalBaseUrl}/api/oauth/device/code`, + { client_id: clientId, scope }, + fetchImpl, + ); + + if (resp.status !== 200) { + let description = ""; + try { + const payload = (await resp.json()) as { error_description?: string }; + description = payload.error_description ?? ""; + } catch { + // keep generic error below + } + throw new OAuthError( + `device_code_request_failed_http_${resp.status}`, + description || `device-code request returned HTTP ${resp.status}`, + ); + } + + const payload = (await resp.json()) as DeviceCodeResponse; + if (!payload.device_code || !payload.user_code) { + throw new OAuthError( + "device_code_response_invalid", + "device-code response missing required fields", + ); + } + return payload; +} + +export async function pollForToken( + deviceCode: DeviceCodeResponse, + opts: DeviceCodeFlowOptions = {}, +): Promise { + const fetchImpl = opts.fetch ?? fetch; + const sleep = opts.sleep ?? defaultSleep; + const now = opts.now ?? (() => Date.now()); + const portalBaseUrl = opts.portalBaseUrl ?? DEFAULT_PORTAL_BASE_URL; + const clientId = opts.clientId ?? DEFAULT_CLIENT_ID; + const log = opts.log ?? ((line: string) => console.error(line)); + const deadline = + now() + (opts.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS) * 1000; + let interval = clampInterval(deviceCode.interval); + let lastWaitLog = 0; + + while (now() < deadline) { + await sleep(interval * 1000); + if (now() - lastWaitLog > 30_000) { + log(" Waiting for browser approval..."); + lastWaitLog = now(); + } + + const resp = await postForm( + `${portalBaseUrl}/api/oauth/token`, + { + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: deviceCode.device_code, + client_id: clientId, + }, + fetchImpl, + ); + + if (resp.status === 200) { + const payload = (await resp.json()) as TokenResponse; + if (!payload.access_token || !payload.refresh_token) { + throw new OAuthError( + "token_response_missing_tokens", + "portal returned no access_token or refresh_token; cannot complete host-side authorization", + ); + } + return payload; + } + + let errorPayload: { error?: string; error_description?: string } = {}; + try { + errorPayload = (await resp.json()) as typeof errorPayload; + } catch { + // use generic code below + } + const errorCode = errorPayload.error ?? `http_${resp.status}`; + switch (errorCode) { + case "authorization_pending": + continue; + case "slow_down": + interval = clampInterval(interval + 5); + continue; + case "access_denied": + throw new OAuthError( + "access_denied", + "user denied the authorization request", + ); + case "expired_token": + throw new OAuthTimeoutError(); + default: + throw new OAuthError(errorCode, errorPayload.error_description); + } + } + + throw new OAuthTimeoutError(); +} + +export async function refreshAccessTokenWithRefreshToken( + refreshToken: string, + opts: DeviceCodeFlowOptions = {}, +): Promise { + const fetchImpl = opts.fetch ?? fetch; + const portalBaseUrl = opts.portalBaseUrl ?? DEFAULT_PORTAL_BASE_URL; + const clientId = opts.clientId ?? DEFAULT_CLIENT_ID; + + const resp = await postForm( + `${portalBaseUrl}/api/oauth/token`, + { + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + }, + fetchImpl, + ); + + if (resp.status !== 200) { + let errorPayload: { error?: string; error_description?: string } = {}; + try { + errorPayload = (await resp.json()) as typeof errorPayload; + } catch { + // use generic code below + } + throw new OAuthError( + errorPayload.error ?? `refresh_failed_http_${resp.status}`, + errorPayload.error_description ?? + `refresh-token grant returned HTTP ${resp.status}`, + ); + } + + const payload = (await resp.json()) as TokenResponse; + if (!payload.access_token || !payload.refresh_token) { + throw new OAuthError( + "token_response_missing_tokens", + "refresh response missing access_token or refresh_token", + ); + } + return payload; +} + +export async function mintAgentKeyWithAccessToken( + accessToken: string, + opts: DeviceCodeFlowOptions & { minTtlSeconds?: number } = {}, +): Promise { + const fetchImpl = opts.fetch ?? fetch; + const portalBaseUrl = opts.portalBaseUrl ?? DEFAULT_PORTAL_BASE_URL; + const minTtlSeconds = Math.max(60, Math.round(opts.minTtlSeconds ?? 1800)); + + const resp = await fetchImpl(`${portalBaseUrl}/api/oauth/agent-key`, { + method: "POST", + headers: { + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ min_ttl_seconds: minTtlSeconds }), + }); + + if (resp.status !== 200) { + let errorPayload: { error?: string; error_description?: string } = {}; + try { + errorPayload = (await resp.json()) as typeof errorPayload; + } catch { + // use generic code below + } + throw new OAuthError( + errorPayload.error ?? `agent_key_failed_http_${resp.status}`, + errorPayload.error_description ?? + `agent-key mint returned HTTP ${resp.status}`, + ); + } + + const payload = (await resp.json()) as AgentKeyResponse; + if (!payload.api_key) { + throw new OAuthError( + "agent_key_response_missing_api_key", + "agent-key response missing api_key", + ); + } + return payload; +} + +export async function runDeviceCodeFlow( + opts: DeviceCodeFlowOptions = {}, +): Promise { + const log = opts.log ?? ((line: string) => console.error(line)); + + log(""); + log(" Requesting device code from portal.nousresearch.com..."); + const deviceCode = await requestDeviceCode(opts); + const verificationUri = + deviceCode.verification_uri_complete ?? deviceCode.verification_uri; + + log(""); + log(" Hermes Provider OAuth"); + log(" Open this URL in your browser to approve:"); + log(""); + log(` ${verificationUri}`); + log(""); + if (!deviceCode.verification_uri_complete) { + log(` Then enter this code: ${deviceCode.user_code}`); + log(""); + } + log(" Waiting for approval (timeout: 15 min)..."); + + if (!opts.noBrowser) { + openBrowser(verificationUri); + } + + const token = await pollForToken(deviceCode, opts); + log(""); + log(" ✓ Hermes Provider authorization complete"); + log(""); + return token; +} + +module.exports = { + DEFAULT_PORTAL_BASE_URL, + DEFAULT_INFERENCE_BASE_URL, + DEFAULT_CLIENT_ID, + DEFAULT_SCOPE, + OAuthError, + OAuthTimeoutError, + requestDeviceCode, + pollForToken, + refreshAccessTokenWithRefreshToken, + mintAgentKeyWithAccessToken, + runDeviceCodeFlow, +}; diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 2985e19dafe..8e5e9ee6fa0 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -148,6 +148,7 @@ const { } = inferenceConfig; const onboardProviders = require("./onboard/providers"); +const hermesProviderAuth = require("./hermes-provider-auth"); const CUSTOM_BUILD_CONTEXT_WARN_BYTES = 100_000_000; const CUSTOM_BUILD_CONTEXT_IGNORES = new Set([ @@ -373,6 +374,12 @@ const GATEWAY_BOOTSTRAP_SECRET_NAMES = [ "openshell-ssh-handshake", ]; const BACK_TO_SELECTION = "__NEMOCLAW_BACK_TO_SELECTION__"; +type HermesAuthMethod = "oauth" | "api_key"; +const HERMES_AUTH_METHOD_OAUTH: HermesAuthMethod = "oauth"; +const HERMES_AUTH_METHOD_API_KEY: HermesAuthMethod = "api_key"; +const HERMES_NOUS_API_KEY_CREDENTIAL_ENV = + hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV || "NOUS_API_KEY"; +const HERMES_NOUS_API_KEY_HELP_URL = "https://portal.nousresearch.com/manage-subscription"; /** * Probe whether the gateway Docker container is actually running. @@ -1497,6 +1504,130 @@ function exitOnboardFromPrompt(): never { process.exit(1); } +function normalizeHermesAuthMethod(value: string | null | undefined): HermesAuthMethod | null { + const normalized = String(value || "") + .trim() + .toLowerCase() + .replace(/[\s-]+/g, "_"); + if (!normalized) return null; + if (normalized === "oauth" || normalized === "nous_oauth" || normalized === "nous_portal_oauth") { + return HERMES_AUTH_METHOD_OAUTH; + } + if ( + normalized === "api" || + normalized === "key" || + normalized === "api_key" || + normalized === "apikey" || + normalized === "nous_api_key" + ) { + return HERMES_AUTH_METHOD_API_KEY; + } + return null; +} + +function hermesAuthMethodLabel(method: HermesAuthMethod | null | undefined): string { + return method === HERMES_AUTH_METHOD_API_KEY ? "Nous API Key" : "Nous Portal OAuth"; +} + +function getRequestedHermesAuthMethod(): HermesAuthMethod | null { + const raw = + process.env.NEMOCLAW_HERMES_AUTH_METHOD || + process.env.NEMOCLAW_HERMES_AUTH || + process.env.NEMOCLAW_NOUS_AUTH_METHOD || + ""; + const method = normalizeHermesAuthMethod(raw); + if (!raw || method) return method; + console.error(` Unsupported Hermes Provider auth method: ${raw}`); + console.error(" Valid values: oauth, nous-portal-oauth, api-key, nous-api-key"); + process.exit(1); +} + +async function promptHermesAuthMethod(): Promise { + const methods: Array<{ key: HermesAuthMethod; label: string }> = [ + { key: HERMES_AUTH_METHOD_OAUTH, label: "Nous Portal OAuth (authenticate via browser)" }, + { + key: HERMES_AUTH_METHOD_API_KEY, + label: "Nous API Key (paste a key from the provider dashboard)", + }, + ]; + const requested = getRequestedHermesAuthMethod(); + if (isNonInteractive()) { + const method = requested || HERMES_AUTH_METHOD_OAUTH; + note(` [non-interactive] Hermes auth: ${hermesAuthMethodLabel(method)}`); + return method; + } + + console.log(""); + console.log(" Hermes Provider authentication:"); + methods.forEach((method, index) => { + console.log(` ${index + 1}) ${method.label}`); + }); + console.log(""); + + const defaultIdx = (requested ? methods.findIndex((method) => method.key === requested) : 0) + 1; + const choice = await prompt(` Choose [${defaultIdx}]: `); + const navigation = getNavigationChoice(choice); + if (navigation === "back") return BACK_TO_SELECTION; + if (navigation === "exit") exitOnboardFromPrompt(); + const idx = parseInt(choice || String(defaultIdx), 10) - 1; + return methods[idx]?.key || methods[defaultIdx - 1]?.key || HERMES_AUTH_METHOD_OAUTH; +} + +function resolveHermesNousApiKey(): string | null { + return ( + normalizeCredentialValue(process.env[HERMES_NOUS_API_KEY_CREDENTIAL_ENV]) || + normalizeCredentialValue(process.env.NEMOCLAW_PROVIDER_KEY) || + null + ); +} + +function stageNousApiKeyProviderEnv(): void { + const key = resolveHermesNousApiKey(); + if (key) { + process.env[HERMES_NOUS_API_KEY_CREDENTIAL_ENV] = key; + } +} + +async function ensureHermesNousApiKeyEnv(): Promise { + const existing = resolveHermesNousApiKey(); + if (existing) { + process.env[HERMES_NOUS_API_KEY_CREDENTIAL_ENV] = existing; + return existing; + } + console.log(""); + console.log(" Hermes Provider Nous API Key"); + console.log(` Create or copy a key from ${HERMES_NOUS_API_KEY_HELP_URL}`); + const key = normalizeCredentialValue( + await prompt(" Nous API Key: ", { + secret: true, + }), + ); + const validationError = validateNvidiaApiKeyValue(key, HERMES_NOUS_API_KEY_CREDENTIAL_ENV); + if (validationError) { + console.error(validationError); + process.exit(1); + } + process.env[HERMES_NOUS_API_KEY_CREDENTIAL_ENV] = key; + return key; +} + +async function selectOnboardAgent({ + agentFlag = null, + session = null, +}: { + agentFlag?: string | null; + session?: { agent?: string | null } | null; + resume?: boolean; + canPrompt?: boolean; +} = {}): Promise { + const agent = agentOnboard.resolveAgent({ agentFlag, session }); + if (isNonInteractive()) { + const displayName = agent?.displayName || agentDefs.loadAgent("openclaw").displayName; + note(` [non-interactive] Agent: ${displayName}`); + } + return agent; +} + const { getTransportRecoveryMessage, getProbeRecovery } = validationRecovery; // Validation functions — delegated to src/lib/validation.ts @@ -3009,6 +3140,7 @@ async function validateCustomAnthropicSelection( const { promptManualModelId, promptCloudModel, promptRemoteModel, promptInputModel } = modelPrompts; const { validateAnthropicModel, validateOpenAiLikeModel } = providerModels; +const nousModels: typeof import("./inference/nous-models") = require("./inference/nous-models"); // Build context helpers — delegated to src/lib/build-context.ts const { shouldIncludeBuildContextPath, copyBuildContextDir, printSandboxCreateRecoveryHints } = @@ -4050,7 +4182,13 @@ async function preflight( const requiredPorts = [ { port: GATEWAY_PORT, label: "OpenShell gateway", envVar: "NEMOCLAW_GATEWAY_PORT" }, ...(dashboardPortToCheck !== null - ? [{ port: dashboardPortToCheck, label: `${cliDisplayName()} dashboard`, envVar: "NEMOCLAW_DASHBOARD_PORT" }] + ? [ + { + port: dashboardPortToCheck, + label: `${cliDisplayName()} dashboard`, + envVar: "NEMOCLAW_DASHBOARD_PORT", + }, + ] : []), ]; for (const { port, label, envVar } of requiredPorts) { @@ -4695,6 +4833,7 @@ type OnboardConfigSummary = { provider: string | null; model: string | null; credentialEnv?: string | null; + hermesAuthMethod?: HermesAuthMethod | string | null; webSearchConfig?: WebSearchConfig | null; enabledChannels?: string[] | null; sandboxName: string; @@ -4737,6 +4876,7 @@ function formatOnboardConfigSummary({ provider, model, credentialEnv = null, + hermesAuthMethod = null, webSearchConfig = null, enabledChannels = null, sandboxName, @@ -4749,9 +4889,20 @@ function formatOnboardConfigSummary({ : "none"; const webSearch = webSearchConfig && webSearchConfig.fetchEnabled === true ? "enabled" : "disabled"; - const apiKeyLine = credentialEnv - ? ` API key: ${credentialEnv} (staged for OpenShell gateway registration)` - : ` API key: (not required for ${provider ?? "this provider"})`; + const effectiveHermesAuthMethod = + normalizeHermesAuthMethod(hermesAuthMethod) || + (provider === hermesProviderAuth.HERMES_PROVIDER_NAME && + credentialEnv === HERMES_NOUS_API_KEY_CREDENTIAL_ENV + ? HERMES_AUTH_METHOD_API_KEY + : HERMES_AUTH_METHOD_OAUTH); + const apiKeyLine = + provider === hermesProviderAuth.HERMES_PROVIDER_NAME + ? effectiveHermesAuthMethod === HERMES_AUTH_METHOD_API_KEY + ? " Nous API key: host-managed; sandbox receives inference placeholder only" + : " Nous OAuth: host-managed; sandbox receives inference placeholder only" + : credentialEnv + ? ` API key: ${credentialEnv} (staged for OpenShell gateway registration)` + : ` API key: (not required for ${provider ?? "this provider"})`; const noteLines = (Array.isArray(notes) ? notes : []) .filter((n) => typeof n === "string" && n.length > 0) .map((n) => ` Note: ${n}`); @@ -6069,11 +6220,13 @@ async function selectAndValidateOllamaModel( async function setupNim( gpu: ReturnType, sandboxName: string | null = null, + agent: AgentDefinition | null = null, ): Promise<{ model: string | null; provider: string; endpointUrl: string | null; credentialEnv: string | null; + hermesAuthMethod: HermesAuthMethod | null; preferredInferenceApi: string | null; nimContainer: string | null; }> { @@ -6084,6 +6237,7 @@ async function setupNim( let nimContainer: string | null = null; let endpointUrl: string | null = REMOTE_PROVIDER_CONFIG.build.endpointUrl; let credentialEnv: string | null = REMOTE_PROVIDER_CONFIG.build.credentialEnv; + let hermesAuthMethod: HermesAuthMethod | null = null; let preferredInferenceApi: string | null = null; // Detect local inference options. Bound curl with --connect-timeout/--max-time @@ -6174,6 +6328,7 @@ async function setupNim( const requestedModel = isNonInteractive() ? getNonInteractiveModel(requestedProvider || "build") : null; + const hermesProviderAvailable = agent?.name === "hermes"; const options: Array<{ key: string; label: string }> = []; options.push({ key: "build", label: "NVIDIA Endpoints" }); options.push({ key: "openai", label: "OpenAI" }); @@ -6269,6 +6424,9 @@ async function setupNim( if (blueprintRouterCfg && blueprintRouterCfg.router?.enabled === true) { options.push({ key: "routed", label: "Model Router (experimental)" }); } + if (hermesProviderAvailable) { + options.push({ key: "hermesProvider", label: "Hermes Provider" }); + } function checkOllamaPortsOrWarn(): boolean { const portValidation = validateOllamaPortConfiguration(); @@ -6370,6 +6528,13 @@ async function setupNim( selected = options.find((o) => o.key === "install-ollama"); } if (!selected) { + if (providerKey === "hermesProvider" && !hermesProviderAvailable) { + console.error(" Hermes Provider is only available when onboarding Hermes Agent."); + console.error( + " Re-run with `nemohermes onboard` or `nemoclaw onboard --agent hermes`.", + ); + process.exit(1); + } console.error( ` Requested provider '${providerKey}' is not available in this environment.`, ); @@ -6478,6 +6643,61 @@ async function setupNim( } } + if (selected.key === "hermesProvider") { + const selectedHermesAuthMethod = await promptHermesAuthMethod(); + if (selectedHermesAuthMethod === BACK_TO_SELECTION) { + console.log(" Returning to provider selection."); + console.log(""); + continue selectionLoop; + } + hermesAuthMethod = selectedHermesAuthMethod; + if (hermesAuthMethod === HERMES_AUTH_METHOD_API_KEY) { + credentialEnv = HERMES_NOUS_API_KEY_CREDENTIAL_ENV; + stageNousApiKeyProviderEnv(); + if (isNonInteractive()) { + if (!resolveHermesNousApiKey()) { + console.error( + ` ${HERMES_NOUS_API_KEY_CREDENTIAL_ENV} (or NEMOCLAW_PROVIDER_KEY) is required for Hermes Provider Nous API Key in non-interactive mode.`, + ); + process.exit(1); + } + } else { + await ensureHermesNousApiKeyEnv(); + } + } else { + credentialEnv = remoteConfig.credentialEnv; + } + + const defaultModel = + requestedModel || + (recoveredFromSandbox && recoveredModel) || + remoteConfig.defaultModel; + if (isNonInteractive()) { + model = defaultModel; + } else { + const hermesProviderModels = await nousModels.getHermesProviderModelOptions(); + model = await promptRemoteModel( + remoteConfig.label, + selected.key, + defaultModel, + null, + { + otherShowsFullList: true, + remoteModelOptions: { [selected.key]: hermesProviderModels }, + topLevelModelLimit: 10, + }, + ); + } + if (model === BACK_TO_SELECTION) { + console.log(" Returning to provider selection."); + console.log(""); + continue selectionLoop; + } + preferredInferenceApi = "openai-completions"; + console.log(` Using ${remoteConfig.label} with model: ${model}`); + break; + } + // Hydrate from credential env vars set earlier in this process // before checking env, so rebuild and other non-interactive callers // can resolve keys stored during the original interactive onboard. @@ -7212,7 +7432,15 @@ async function setupNim( } } - return { model, provider, endpointUrl, credentialEnv, preferredInferenceApi, nimContainer }; + return { + model, + provider, + endpointUrl, + credentialEnv, + hermesAuthMethod, + preferredInferenceApi, + nimContainer, + }; } // ── Step 4: Inference provider ─────────────────────────────────── @@ -7223,10 +7451,70 @@ async function setupInference( provider: string, endpointUrl: string | null = null, credentialEnv: string | null = null, + hermesAuthMethod: HermesAuthMethod | string | null = null, ): Promise<{ ok: true; retry?: undefined } | { retry: "selection" }> { step(4, 8, "Setting up inference provider"); runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true }); + if (provider === hermesProviderAuth.HERMES_PROVIDER_NAME) { + const targetSandbox = requireValue(sandboxName, "Hermes Provider requires a sandbox name"); + const resolvedHermesAuthMethod = + normalizeHermesAuthMethod(hermesAuthMethod) || + (credentialEnv === HERMES_NOUS_API_KEY_CREDENTIAL_ENV + ? HERMES_AUTH_METHOD_API_KEY + : HERMES_AUTH_METHOD_OAUTH); + try { + const state = + resolvedHermesAuthMethod === HERMES_AUTH_METHOD_API_KEY + ? await hermesProviderAuth.ensureHermesProviderApiKeyCredentials(targetSandbox, { + apiKey: resolveHermesNousApiKey(), + runOpenshell, + baseUrl: endpointUrl || undefined, + }) + : await hermesProviderAuth.ensureHermesProviderOAuthCredentials(targetSandbox, { + allowInteractiveLogin: !isNonInteractive(), + runOpenshell, + baseUrl: endpointUrl || undefined, + }); + if (!state) { + const authLabel = hermesAuthMethodLabel(resolvedHermesAuthMethod); + console.error(` ✗ Hermes Provider ${authLabel} is not available on the host.`); + console.error( + " Re-run `nemoclaw onboard --agent hermes` interactively to configure credentials.", + ); + process.exit(1); + } + } catch (err) { + console.error( + ` ✗ Failed to prepare Hermes Provider credentials: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + if (isNonInteractive()) process.exit(1); + return { retry: "selection" }; + } + + const applyResult = runOpenshell( + ["inference", "set", "--no-verify", "--provider", provider, "--model", model], + { ignoreError: true }, + ); + if (applyResult.status !== 0) { + const message = + compactText(redact(`${applyResult.stderr || ""} ${applyResult.stdout || ""}`)) || + `Failed to configure inference provider '${provider}'.`; + console.error(` ${message}`); + if (isNonInteractive()) process.exit(applyResult.status || 1); + return { retry: "selection" }; + } + + verifyInferenceRoute(provider, model); + if (sandboxName) { + registry.updateSandbox(sandboxName, { model, provider }); + } + console.log(` ✓ Inference route set: ${provider} / ${model}`); + return { ok: true }; + } + if ( provider === "nvidia-prod" || provider === "nvidia-nim" || @@ -7838,7 +8126,7 @@ async function setupMessagingChannels(): Promise { const userId = (await prompt(` ${ch.userIdLabel}: `)).trim(); if (userId) { process.env[ch.userIdEnvKey] = userId; - console.log(` ✓ ${ch.name} user ID saved`); + console.log(` ✓ ${ch.name} allowed IDs saved`); } else { const skippedReason = ch.allowIdsMode === "guild" @@ -8836,7 +9124,8 @@ function findForwardEntry( port: string, ): { sandboxName: string; status: string } | null { if (!forwardListOutput) return null; - for (const line of forwardListOutput.split("\n")) { + for (const rawLine of forwardListOutput.split("\n")) { + const line = rawLine.replace(ANSI_RE, ""); if (/^\s*SANDBOX\s/i.test(line)) continue; const parts = line.trim().split(/\s+/); if (parts.length < 3 || parts[2] !== port) continue; @@ -8855,7 +9144,8 @@ function isLiveForwardStatus(status: string): boolean { function getRunningForwardPorts(forwardListOutput: string | null | undefined): string[] { const ports = new Set(); if (!forwardListOutput) return []; - for (const line of forwardListOutput.split("\n")) { + for (const rawLine of forwardListOutput.split("\n")) { + const line = rawLine.replace(ANSI_RE, ""); if (/^\s*SANDBOX\s/i.test(line)) continue; const parts = line.trim().split(/\s+/); if (parts.length < 5 || !/^\d+$/.test(parts[2])) continue; @@ -8885,7 +9175,8 @@ function stopAllDashboardForwards(): void { function getOccupiedPorts(forwardListOutput: string | null): Map { const occupied = new Map(); if (!forwardListOutput) return occupied; - for (const line of forwardListOutput.split("\n")) { + for (const rawLine of forwardListOutput.split("\n")) { + const line = rawLine.replace(ANSI_RE, ""); if (/^\s*SANDBOX\s/i.test(line)) continue; const parts = line.trim().split(/\s+/); // parts: [sandbox, bind, port, pid, status...] @@ -9377,15 +9668,8 @@ function printDashboard( console.log(""); } -function toOptionalString(value: string | null | undefined): string | undefined { - return value ?? undefined; -} - // Preserve the nullable contract end-to-end: `null` means "clear this // field on the persisted session", `undefined` means "leave unchanged". -// Collapsing `null`→`undefined` (as toOptionalString does) silently drops -// explicit clears such as the credentialEnv reset during a remote→local -// provider switch — the exact bug in GH #2625. function toNullableString(value: string | null | undefined): string | null | undefined { if (value === undefined) return undefined; if (value === null) return null; @@ -9399,6 +9683,7 @@ function toSessionUpdates( model?: string | null; endpointUrl?: string | null; credentialEnv?: string | null; + hermesAuthMethod?: HermesAuthMethod | string | null; preferredInferenceApi?: string | null; nimContainer?: string | null; webSearchConfig?: WebSearchConfig | null; @@ -9416,6 +9701,8 @@ function toSessionUpdates( normalized.endpointUrl = toNullableString(updates.endpointUrl); if (updates.credentialEnv !== undefined) normalized.credentialEnv = toNullableString(updates.credentialEnv); + if (updates.hermesAuthMethod !== undefined) + normalized.hermesAuthMethod = normalizeHermesAuthMethod(updates.hermesAuthMethod); if (updates.preferredInferenceApi !== undefined) { normalized.preferredInferenceApi = toNullableString(updates.preferredInferenceApi); } @@ -9774,14 +10061,17 @@ async function onboard(opts: OnboardOptions = {}): Promise { } }); - const agent = agentOnboard.resolveAgent({ agentFlag: opts.agent, session }); + const agent = await selectOnboardAgent({ + agentFlag: opts.agent, + session, + resume, + canPrompt: !cannotPrompt, + }); setOnboardBrandingAgent(agent?.name || "openclaw"); - if (agent) { - onboardSession.updateSession((s: Session) => { - s.agent = agent.name; - return s; - }); - } + onboardSession.updateSession((s: Session) => { + s.agent = agent?.name ?? null; + return s; + }); console.log(""); console.log(` ${cliDisplayName()} Onboarding`); @@ -9953,6 +10243,12 @@ async function onboard(opts: OnboardOptions = {}): Promise { let provider = session?.provider || null; let endpointUrl = session?.endpointUrl || null; let credentialEnv = session?.credentialEnv || null; + let hermesAuthMethod: HermesAuthMethod | null = + normalizeHermesAuthMethod(session?.hermesAuthMethod) || + (provider === hermesProviderAuth.HERMES_PROVIDER_NAME && + session?.credentialEnv === HERMES_NOUS_API_KEY_CREDENTIAL_ENV + ? HERMES_AUTH_METHOD_API_KEY + : null); let preferredInferenceApi = session?.preferredInferenceApi || null; let nimContainer = session?.nimContainer || null; let webSearchConfig = session?.webSearchConfig || null; @@ -9974,11 +10270,12 @@ async function onboard(opts: OnboardOptions = {}): Promise { // otherwise leave a phantom that `nemoclaw list` resurrects until // manually destroyed. startRecordedStep("provider_selection"); - const selection = await setupNim(gpu, sandboxName); + const selection = await setupNim(gpu, sandboxName, agent); model = selection.model; provider = selection.provider; endpointUrl = selection.endpointUrl; credentialEnv = selection.credentialEnv; + hermesAuthMethod = selection.hermesAuthMethod; preferredInferenceApi = selection.preferredInferenceApi; nimContainer = selection.nimContainer; onboardSession.markStepComplete( @@ -9988,6 +10285,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { model, endpointUrl, credentialEnv, + hermesAuthMethod, preferredInferenceApi, nimContainer, }), @@ -10002,6 +10300,26 @@ async function onboard(opts: OnboardOptions = {}): Promise { const resumeInference = !forceProviderSelection && resume && isInferenceRouteReady(provider, model); if (resumeInference) { + if (provider === hermesProviderAuth.HERMES_PROVIDER_NAME) { + startRecordedStep("inference", { provider, model }); + const inferenceResult = await setupInference( + sandboxName, + model, + provider, + endpointUrl, + credentialEnv, + hermesAuthMethod, + ); + if (inferenceResult?.retry === "selection") { + forceProviderSelection = true; + continue; + } + onboardSession.markStepComplete( + "inference", + toSessionUpdates({ provider, model, hermesAuthMethod, nimContainer }), + ); + break; + } if (isRoutedInferenceProvider(provider)) { try { await reconcileModelRouter(); @@ -10018,7 +10336,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { } onboardSession.markStepComplete( "inference", - toSessionUpdates({ provider, model, nimContainer }), + toSessionUpdates({ provider, model, hermesAuthMethod, nimContainer }), ); break; } @@ -10040,6 +10358,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { provider, model, credentialEnv, + hermesAuthMethod, webSearchConfig, enabledChannels: selectedMessagingChannels.length > 0 ? selectedMessagingChannels : null, sandboxName, @@ -10065,6 +10384,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { provider, endpointUrl, credentialEnv, + hermesAuthMethod, ); delete process.env.NVIDIA_API_KEY; if (inferenceResult?.retry === "selection") { @@ -10076,7 +10396,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { } onboardSession.markStepComplete( "inference", - toSessionUpdates({ provider, model, nimContainer }), + toSessionUpdates({ provider, model, hermesAuthMethod, nimContainer }), ); break; } @@ -10269,14 +10589,14 @@ async function onboard(opts: OnboardOptions = {}): Promise { skippedStepMessage("openclaw", sandboxName); onboardSession.markStepComplete( "openclaw", - toSessionUpdates({ sandboxName, provider, model }), + toSessionUpdates({ sandboxName, provider, model, hermesAuthMethod }), ); } else { startRecordedStep("openclaw", { sandboxName, provider, model }); await setupOpenclaw(sandboxName, model, provider); onboardSession.markStepComplete( "openclaw", - toSessionUpdates({ sandboxName, provider, model }), + toSessionUpdates({ sandboxName, provider, model, hermesAuthMethod }), ); } onboardSession.markStepSkipped("agent_setup"); @@ -10360,7 +10680,9 @@ async function onboard(opts: OnboardOptions = {}): Promise { ensureAgentDashboardForward(sandboxName, agent); } - onboardSession.completeSession(toSessionUpdates({ sandboxName, provider, model })); + onboardSession.completeSession( + toSessionUpdates({ sandboxName, provider, model, hermesAuthMethod }), + ); completed = true; // Onboarding finished successfully. Delete the legacy plaintext // credentials.json only when every staged *value* was actually pushed @@ -10499,6 +10821,7 @@ module.exports = { setupInference, setupMessagingChannels, MESSAGING_CHANNELS, + selectOnboardAgent, setupNim, providerNameToOptionKey, readRecordedProvider, @@ -10525,6 +10848,7 @@ module.exports = { hasChatCompletionsToolCall, hasChatCompletionsToolCallLeak, upsertProvider, + normalizeHermesAuthMethod, hashCredential, detectMessagingCredentialRotation, getDefaultSandboxNameForAgent, diff --git a/src/lib/onboard/providers.ts b/src/lib/onboard/providers.ts index 51891397bd4..bbab712b2c5 100644 --- a/src/lib/onboard/providers.ts +++ b/src/lib/onboard/providers.ts @@ -7,6 +7,7 @@ const { redact } = require("../runner"); const { DEFAULT_CLOUD_MODEL, + DEFAULT_HERMES_PROVIDER_MODEL, OLLAMA_LOCAL_CREDENTIAL_ENV, VLLM_LOCAL_CREDENTIAL_ENV, } = require("../inference/config"); @@ -19,6 +20,7 @@ const BUILD_ENDPOINT_URL = "https://integrate.api.nvidia.com/v1"; const OPENAI_ENDPOINT_URL = "https://api.openai.com/v1"; const ANTHROPIC_ENDPOINT_URL = "https://api.anthropic.com"; const GEMINI_ENDPOINT_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"; +const HERMES_INFERENCE_ENDPOINT_URL = "https://inference-api.nousresearch.com/v1"; const REMOTE_PROVIDER_CONFIG = { build: { @@ -74,6 +76,17 @@ const REMOTE_PROVIDER_CONFIG = { defaultModel: "gemini-2.5-flash", skipVerify: true, }, + hermesProvider: { + label: "Hermes Provider", + providerName: "hermes-provider", + providerType: "openai", + credentialEnv: "OPENAI_API_KEY", + endpointUrl: HERMES_INFERENCE_ENDPOINT_URL, + helpUrl: "https://portal.nousresearch.com/manage-subscription", + modelMode: "curated", + defaultModel: DEFAULT_HERMES_PROVIDER_MODEL, + skipVerify: true, + }, custom: { label: "Other OpenAI-compatible endpoint", providerName: "compatible-endpoint", @@ -161,6 +174,11 @@ function getNonInteractiveProvider() { nim: "nim-local", vllm: "vllm", anthropiccompatible: "anthropicCompatible", + hermes: "hermesProvider", + "hermes-provider": "hermesProvider", + hermesprovider: "hermesProvider", + nous: "hermesProvider", + "nous-portal": "hermesProvider", }; const normalized = aliases[providerKey] || providerKey; const validProviders = new Set([ @@ -169,6 +187,7 @@ function getNonInteractiveProvider() { "anthropic", "anthropicCompatible", "gemini", + "hermesProvider", "ollama", "custom", "nim-local", @@ -182,7 +201,7 @@ function getNonInteractiveProvider() { if (!validProviders.has(normalized)) { console.error(` Unsupported NEMOCLAW_PROVIDER: ${providerKey}`); console.error( - " Valid values: build, openai, anthropic, anthropicCompatible, gemini, ollama, custom, nim-local, vllm, routed, install-vllm, install-ollama, install-windows-ollama, start-windows-ollama", + " Valid values: build, openai, anthropic, anthropicCompatible, gemini, hermes-provider, ollama, custom, nim-local, vllm, routed, install-vllm, install-ollama, install-windows-ollama, start-windows-ollama", ); process.exit(1); } @@ -332,6 +351,7 @@ function getSandboxInferenceConfig( inferenceApi = "anthropic-messages"; break; case "gemini-api": + case "hermes-provider": providerKey = "inference"; primaryModelRef = `inference/${model}`; inferenceCompat = { diff --git a/src/lib/sandbox-channels.test.ts b/src/lib/sandbox-channels.test.ts index 9b62c31ef73..2d9536ab89a 100644 --- a/src/lib/sandbox-channels.test.ts +++ b/src/lib/sandbox-channels.test.ts @@ -28,6 +28,15 @@ describe("sandbox-channels KNOWN_CHANNELS", () => { expect(getChannelDef("slack")?.appTokenEnvKey).toBe("SLACK_APP_TOKEN"); }); + it("asks for Slack human member IDs as a comma-separated allowlist", () => { + const slack = getChannelDef("slack"); + expect(slack?.userIdEnvKey).toBe("SLACK_ALLOWED_USERS"); + expect(slack?.userIdLabel).toBe("Slack Member IDs (comma-separated allowlist)"); + expect(slack?.userIdHelp).toContain("comma-separated member IDs"); + expect(slack?.userIdHelp).toContain("not the app or bot user ID"); + expect(slack?.allowIdsMode).toBe("dm"); + }); + it("normalises case and whitespace when resolving a channel name", () => { expect(getChannelDef(" Telegram ")).toBe(KNOWN_CHANNELS.telegram); expect(getChannelDef("DISCORD")).toBe(KNOWN_CHANNELS.discord); diff --git a/src/lib/sandbox-channels.ts b/src/lib/sandbox-channels.ts index 15f65a4e207..1be69ec2772 100644 --- a/src/lib/sandbox-channels.ts +++ b/src/lib/sandbox-channels.ts @@ -70,6 +70,11 @@ export const KNOWN_CHANNELS: Record = { appTokenLabel: "Slack App Token (Socket Mode)", appTokenFormat: /^xapp-[A-Za-z0-9_-]+$/, appTokenFormatHint: "Slack app tokens start with 'xapp-' (e.g. xapp-1-A0000-12345-abcdef).", + userIdEnvKey: "SLACK_ALLOWED_USERS", + userIdHelp: + "In Slack, open each allowed human user's profile -> More -> Copy member ID. Enter one or more comma-separated member IDs, not the app or bot user ID. Member IDs look like U01ABC2DEF3.", + userIdLabel: "Slack Member IDs (comma-separated allowlist)", + allowIdsMode: "dm", }, }; diff --git a/src/lib/security/redact.ts b/src/lib/security/redact.ts index 4fc0c771675..5cc2e7b445e 100644 --- a/src/lib/security/redact.ts +++ b/src/lib/security/redact.ts @@ -107,7 +107,7 @@ export function redactSensitiveText(value: unknown): string | null { if (typeof value !== "string") return null; let result = value .replace( - /(NVIDIA_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|GEMINI_API_KEY|COMPATIBLE_API_KEY|COMPATIBLE_ANTHROPIC_API_KEY|BRAVE_API_KEY|SLACK_BOT_TOKEN|SLACK_APP_TOKEN|DISCORD_BOT_TOKEN|TELEGRAM_BOT_TOKEN)=\S+/gi, + /(NVIDIA_API_KEY|NOUS_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|GEMINI_API_KEY|COMPATIBLE_API_KEY|COMPATIBLE_ANTHROPIC_API_KEY|BRAVE_API_KEY|SLACK_BOT_TOKEN|SLACK_APP_TOKEN|DISCORD_BOT_TOKEN|TELEGRAM_BOT_TOKEN)=\S+/gi, "$1=", ) .replace(/Bearer\s+\S+/gi, "Bearer "); diff --git a/src/lib/state/onboard-session.test.ts b/src/lib/state/onboard-session.test.ts index af7d286c027..0448764bab8 100644 --- a/src/lib/state/onboard-session.test.ts +++ b/src/lib/state/onboard-session.test.ts @@ -211,6 +211,28 @@ describe("onboard session", () => { expect(loaded.provider).toBe("openai"); }); + it("only persists known Hermes auth methods", () => { + session.saveSession(session.createSession()); + session.markStepComplete("provider_selection", { + provider: "hermes-provider", + hermesAuthMethod: "oauth", + }); + let loaded = requireLoadedSession(session.loadSession()); + expect(loaded.hermesAuthMethod).toBe("oauth"); + + session.markStepComplete("provider_selection", { + hermesAuthMethod: "not-a-real-method" as never, + }); + loaded = requireLoadedSession(session.loadSession()); + expect(loaded.hermesAuthMethod).toBe("oauth"); + + session.markStepComplete("provider_selection", { + hermesAuthMethod: null, + }); + loaded = requireLoadedSession(session.loadSession()); + expect(loaded.hermesAuthMethod).toBeNull(); + }); + it("accepts null as an explicit clear for every nullable string field", () => { // All six nullable fields that travel through filterSafeUpdates must // support the null-clear contract. If any regresses to the old diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index 7b26af30111..e35286008da 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -28,6 +28,7 @@ export const LOCK_FILE = path.join(SESSION_DIR, "onboard.lock"); type SessionJsonValue = JsonValue; type UnknownRecord = JsonObject; type StepStatus = "pending" | "in_progress" | "complete" | "failed" | "skipped"; +export type HermesAuthMethod = "oauth" | "api_key"; const STEP_STATES: readonly StepStatus[] = [ "pending", @@ -75,6 +76,7 @@ export interface Session { model: string | null; endpointUrl: string | null; credentialEnv: string | null; + hermesAuthMethod: HermesAuthMethod | null; preferredInferenceApi: string | null; nimContainer: string | null; routerPid: number | null; @@ -129,6 +131,7 @@ export interface SessionUpdates { model?: string | null; endpointUrl?: string | null; credentialEnv?: string | null; + hermesAuthMethod?: HermesAuthMethod | null; preferredInferenceApi?: string | null; nimContainer?: string | null; routerPid?: number; @@ -156,6 +159,7 @@ export interface DebugSessionSummary { model: string | null; endpointUrl: string | null; credentialEnv: string | null; + hermesAuthMethod: HermesAuthMethod | null; preferredInferenceApi: string | null; nimContainer: string | null; policyPresets: string[] | null; @@ -201,6 +205,10 @@ function readString(value: SessionJsonValue | undefined): string | null { return typeof value === "string" ? value : null; } +function readHermesAuthMethod(value: SessionJsonValue | undefined): HermesAuthMethod | null { + return value === "oauth" || value === "api_key" ? value : null; +} + function readPositiveInteger(value: SessionJsonValue | undefined): number | null { return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null; } @@ -311,6 +319,7 @@ export function createSession(overrides: Partial = {}): Session { model: overrides.model ?? null, endpointUrl: overrides.endpointUrl ?? null, credentialEnv: overrides.credentialEnv ?? null, + hermesAuthMethod: overrides.hermesAuthMethod ?? null, preferredInferenceApi: overrides.preferredInferenceApi ?? null, nimContainer: overrides.nimContainer ?? null, routerPid: readPositiveInteger(overrides.routerPid), @@ -350,6 +359,7 @@ export function normalizeSession(data: Session | SessionJsonValue | undefined): model: readString(data.model), endpointUrl: typeof data.endpointUrl === "string" ? redactUrl(data.endpointUrl) : null, credentialEnv: readString(data.credentialEnv), + hermesAuthMethod: readHermesAuthMethod(data.hermesAuthMethod), preferredInferenceApi: readString(data.preferredInferenceApi), nimContainer: readString(data.nimContainer), routerPid: readPositiveInteger(data.routerPid), @@ -748,6 +758,11 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial { assignNullableString(safe, "model", updates.model); assignNullableString(safe, "endpointUrl", updates.endpointUrl, redactUrl); assignNullableString(safe, "credentialEnv", updates.credentialEnv); + if (updates.hermesAuthMethod === "oauth" || updates.hermesAuthMethod === "api_key") { + safe.hermesAuthMethod = updates.hermesAuthMethod; + } else if (updates.hermesAuthMethod === null) { + safe.hermesAuthMethod = null; + } assignNullableString(safe, "preferredInferenceApi", updates.preferredInferenceApi); assignNullableString(safe, "nimContainer", updates.nimContainer); if (typeof updates.routerPid === "number" && Number.isInteger(updates.routerPid) && updates.routerPid > 0) { @@ -892,6 +907,7 @@ export function summarizeForDebug( model: session.model, endpointUrl: redactUrl(session.endpointUrl), credentialEnv: session.credentialEnv, + hermesAuthMethod: session.hermesAuthMethod, preferredInferenceApi: session.preferredInferenceApi, nimContainer: session.nimContainer, policyPresets: session.policyPresets, diff --git a/src/lib/state/sandbox-session.test.ts b/src/lib/state/sandbox-session.test.ts index a44d412f325..99bb18d4353 100644 --- a/src/lib/state/sandbox-session.test.ts +++ b/src/lib/state/sandbox-session.test.ts @@ -40,6 +40,20 @@ my-sandbox 127.0.0.1 18789 12345 running`; }); }); + it("strips ANSI colors from forward list output", () => { + const output = `SANDBOX BIND PORT PID STATUS +hermes 127.0.0.1 8642 50394 \u001b[32mrunning\u001b[39m`; + expect(parseForwardList(output)).toEqual([ + { + sandboxName: "hermes", + bind: "127.0.0.1", + port: "8642", + pid: 50394, + status: "running", + }, + ]); + }); + it("parses multiple forward entries", () => { const output = `SANDBOX BIND PORT PID STATUS sandbox-1 127.0.0.1 18789 100 running diff --git a/src/lib/state/sandbox-session.ts b/src/lib/state/sandbox-session.ts index df1de801ca3..ae7f0cecb9c 100644 --- a/src/lib/state/sandbox-session.ts +++ b/src/lib/state/sandbox-session.ts @@ -55,6 +55,10 @@ export interface ForwardEntry { // Pure classifiers — parse CLI output, no I/O // --------------------------------------------------------------------------- +function stripAnsi(value: string): string { + return value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ""); +} + /** * Parse `openshell forward list` output into structured forward entries. * @@ -68,7 +72,7 @@ export function parseForwardList(output: string | null | undefined): ForwardEntr if (!output || typeof output !== "string") return []; const entries: ForwardEntry[] = []; - const lines = output + const lines = stripAnsi(output) .split("\n") .map((l) => l.trim()) .filter(Boolean); diff --git a/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts index ee85885b3bb..7704eb0fec5 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -167,6 +167,7 @@ describe("agents/hermes/generate-config.ts", () => { NEMOCLAW_MESSAGING_CHANNELS_B64: encodeJson(["telegram", "slack"]), NEMOCLAW_MESSAGING_ALLOWED_IDS_B64: encodeJson({ telegram: ["123456789"], + slack: ["U0123456789", "U09ABCDEFGH"], }), NEMOCLAW_TELEGRAM_CONFIG_B64: encodeJson({ requireMention: true }), }); @@ -184,6 +185,7 @@ describe("agents/hermes/generate-config.ts", () => { ); expect(envFile).not.toContain("SLACK_BOT_TOKEN=openshell:resolve:env:SLACK_BOT_TOKEN\n"); expect(envFile).not.toContain("SLACK_APP_TOKEN=openshell:resolve:env:SLACK_APP_TOKEN\n"); + expect(envFile).toContain("SLACK_ALLOWED_USERS=U0123456789,U09ABCDEFGH\n"); }); it("omits Telegram behavior config when requireMention is not boolean", () => { diff --git a/test/hermes-provider-foundation.test.ts b/test/hermes-provider-foundation.test.ts new file mode 100644 index 00000000000..2de8b1f9e0f --- /dev/null +++ b/test/hermes-provider-foundation.test.ts @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +function buildHermeticEnv(tmpDir: string, extra: Record = {}): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env, HOME: tmpDir, ...extra }; + for (const key of Object.keys(env)) { + if (key.startsWith("DISCORD_") || key.startsWith("TELEGRAM_")) { + delete env[key]; + } + } + return env; +} + +describe("Hermes Provider onboarding selection", () => { + it("keeps bare interactive onboard on the OpenClaw default", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-agent-default-"), + ); + const scriptPath = path.join(tmpDir, "agent-default-check.js"); + const onboardPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "onboard.js"), + ); + + const script = String.raw` +const { selectOnboardAgent } = require(${onboardPath}); + +(async () => { + const agent = await selectOnboardAgent({ canPrompt: true }); + console.log(JSON.stringify({ agent: agent && agent.name })); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: buildHermeticEnv(tmpDir), + }); + + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout.trim())).toEqual({ agent: null }); + }); + + it("rejects Hermes Provider when Hermes Agent was not selected", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-hermes-provider-hidden-"), + ); + const scriptPath = path.join(tmpDir, "hermes-provider-hidden-check.js"); + const onboardPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "onboard.js"), + ); + const runnerPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "runner.js"), + ); + + const script = String.raw` +const runner = require(${runnerPath}); +runner.runCapture = () => ""; +const { setupNim } = require(${onboardPath}); + +(async () => { + await setupNim(null, "my-assistant", null); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: buildHermeticEnv(tmpDir, { + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_PROVIDER: "hermes-provider", + }), + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + "Hermes Provider is only available when onboarding Hermes Agent", + ); + expect(result.stderr).toContain( + "Re-run with `nemohermes onboard` or `nemoclaw onboard --agent hermes`.", + ); + }); + + it("selects the API-key Hermes Provider path for Hermes Agent", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-hermes-provider-api-"), + ); + const scriptPath = path.join(tmpDir, "hermes-provider-api-check.js"); + const onboardPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "onboard.js"), + ); + const runnerPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "runner.js"), + ); + + const script = String.raw` +const runner = require(${runnerPath}); +runner.runCapture = () => ""; +const { setupNim } = require(${onboardPath}); + +(async () => { + const result = await setupNim(null, "my-assistant", { name: "hermes" }); + console.log(JSON.stringify(result)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: buildHermeticEnv(tmpDir, { + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_PROVIDER: "hermes-provider", + NEMOCLAW_HERMES_AUTH_METHOD: "nous-api-key", + NOUS_API_KEY: "nous-key-1", + }), + }); + + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout.trim().split("\n").at(-1) || "{}"); + expect(payload.provider).toBe("hermes-provider"); + expect(payload.credentialEnv).toBe("NOUS_API_KEY"); + expect(payload.hermesAuthMethod).toBe("api_key"); + }); +}); diff --git a/test/rebuild-credential-preflight.test.ts b/test/rebuild-credential-preflight.test.ts index 43578780d18..b2a71747b07 100644 --- a/test/rebuild-credential-preflight.test.ts +++ b/test/rebuild-credential-preflight.test.ts @@ -49,9 +49,11 @@ function createFixture(opts: { /** If set, the onboard-session.json provider_selection step status */ providerSelectionStatus?: string; agent?: string | null; + hermesAuthMethod?: string | null; messagingChannels?: string[] | null; providerCredentialHashes?: Record; dockerBuildExitCode?: number; + providerRegistered?: boolean; }) { const { sandboxName = "my-assistant", @@ -60,9 +62,11 @@ function createFixture(opts: { savedCredential, providerSelectionStatus = "complete", agent = null, + hermesAuthMethod = null, messagingChannels = null, providerCredentialHashes, dockerBuildExitCode = 0, + providerRegistered = true, } = opts; const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-2273-")); tmpFixtures.push(tmpDir); @@ -110,6 +114,7 @@ function createFixture(opts: { model: "meta/llama-3.3-70b-instruct", endpointUrl: null, credentialEnv, + hermesAuthMethod, preferredInferenceApi: null, nimContainer: null, webSearchConfig: null, @@ -207,6 +212,7 @@ if (a[0]==="gateway" && a[1]==="info") { process.stdout.write("nemoclaw\\n if (a[0]==="gateway" && a[1]==="select") { process.exit(0); } if (a[0]==="inference" && a[1]==="get") { process.stdout.write('{"provider":"${provider}","model":"meta/llama-3.3-70b-instruct"}\\n'); process.exit(0); } if (a[0]==="inference" && a[1]==="set") { process.exit(0); } +if (a[0]==="provider" && a[1]==="get") { process.exit(${providerRegistered ? 0 : 1}); } if (a[0]==="provider") { process.exit(0); } if (a[0]==="forward") { process.exit(0); } process.exit(0); @@ -484,6 +490,71 @@ describe("Issue #2273: atomic rebuild", () => { expect(registryHasSandbox(f)).toBe(true); }, ); + + it( + "uses the registered Hermes Provider in OpenShell instead of requiring OPENAI_API_KEY", + { timeout: 60_000 }, + () => { + const f = createFixture({ + agent: "hermes", + provider: "hermes-provider", + credentialEnv: "OPENAI_API_KEY", + hermesAuthMethod: "oauth", + }); + + const result = runRebuild(f); + const output = (result.stderr || "") + (result.stdout || ""); + + expect(output).not.toContain("Missing credential: OPENAI_API_KEY"); + expect(output).not.toContain("provider credential not found"); + expect(output).toContain("Backing up sandbox state"); + }, + ); + + it( + "registers an exported Hermes API key in OpenShell when the provider is missing", + { timeout: 60_000 }, + () => { + const f = createFixture({ + agent: "hermes", + provider: "hermes-provider", + credentialEnv: "NOUS_API_KEY", + hermesAuthMethod: "api_key", + providerRegistered: false, + }); + + const result = runRebuild(f, { NOUS_API_KEY: "nous-key-from-env" }); + const output = (result.stderr || "") + (result.stdout || ""); + + expect(output).not.toContain("Missing credential: NOUS_API_KEY"); + expect(output).not.toContain("provider credential not found"); + expect(output).toContain("Backing up sandbox state"); + }, + ); + + it( + "aborts Hermes OAuth rebuild before backup when the OpenShell provider is missing", + { timeout: 60_000 }, + () => { + const f = createFixture({ + agent: "hermes", + provider: "hermes-provider", + credentialEnv: "OPENAI_API_KEY", + hermesAuthMethod: "oauth", + providerRegistered: false, + }); + + const result = runRebuild(f); + const output = (result.stderr || "") + (result.stdout || ""); + + expect(result.status).not.toBe(0); + expect(output).toContain("Hermes Provider is not registered in OpenShell"); + expect(output).toContain("credentials must be stored in OpenShell"); + expect(output).not.toContain("Missing credential: OPENAI_API_KEY"); + expect(output).not.toContain("Backing up sandbox state"); + expect(registryHasSandbox(f)).toBe(true); + }, + ); }); describe("Layer 3: recovery on recreate failure", () => { From f17d8442cbedec03a4731462ceed973a016783d9 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 10 May 2026 06:32:12 -0700 Subject: [PATCH 2/6] docs: list Hermes provider in installers Signed-off-by: Aaron Erickson --- install.sh | 1 + scripts/install.sh | 1 + 2 files changed, 2 insertions(+) diff --git a/install.sh b/install.sh index 4edcdab614f..1d439124a85 100755 --- a/install.sh +++ b/install.sh @@ -120,6 +120,7 @@ bootstrap_usage() { printf " NEMOCLAW_SANDBOX_NAME Sandbox name to create/use\n" printf " NEMOCLAW_PROVIDER build | openai | anthropic | anthropicCompatible\n" printf " | gemini | ollama | custom | nim-local | vllm | routed\n" + printf " | hermes-provider\n" printf " (aliases: cloud -> build, nim -> nim-local)\n" printf " NEMOCLAW_POLICY_MODE suggested | custom | skip\n" printf "\n" diff --git a/scripts/install.sh b/scripts/install.sh index 7be5ee2aeb6..808e2113894 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -555,6 +555,7 @@ usage() { printf " NEMOCLAW_INSTALL_TAG Git ref to install (default: latest release)\n" printf " NEMOCLAW_PROVIDER build | openai | anthropic | anthropicCompatible\n" printf " | gemini | ollama | custom | nim-local | vllm | routed\n" + printf " | hermes-provider\n" printf " (aliases: cloud -> build, nim -> nim-local)\n" printf " NEMOCLAW_MODEL Inference model to configure\n" printf " NEMOCLAW_POLICY_MODE suggested | custom | skip\n" From 7e6fddfe30f07656e43e5e82d384c10fb32904cd Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 10 May 2026 06:44:39 -0700 Subject: [PATCH 3/6] fix(hermes): document auth env and raw key boundary Signed-off-by: Aaron Erickson --- docs/reference/commands.md | 3 +++ src/lib/onboard.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index ccc622f805f..809199bbdd8 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -1020,6 +1020,9 @@ Set them before running `nemoclaw onboard`. | Variable | Format | Effect | |----------|--------|--------| | `NEMOCLAW_PROVIDER` | provider key (e.g. `nvidia`, `openai`, `anthropic`, `ollama`, `vllm`, `compatible`) | Selects the inference provider in non-interactive onboarding. Must match one of the keys the wizard would prompt for. | +| `NEMOCLAW_HERMES_AUTH_METHOD` | `oauth` | Selects Hermes Provider authentication in non-interactive onboarding. Valid values: `oauth`, `nous-portal-oauth`, `api-key`, `nous-api-key`. | +| `NEMOCLAW_HERMES_AUTH` | same as `NEMOCLAW_HERMES_AUTH_METHOD` | Back-compatible alias for Hermes Provider authentication selection. | +| `NEMOCLAW_NOUS_AUTH_METHOD` | same as `NEMOCLAW_HERMES_AUTH_METHOD` | Nous-specific alias for Hermes Provider authentication selection. | | `NEMOCLAW_ENDPOINT_URL` | URL | Custom OpenAI-compatible endpoint URL. Used together with `NEMOCLAW_PROVIDER=compatible`. | | `NEMOCLAW_PREFERRED_API` | `completions` (currently the only honored value) | Forces the validation probe to use the `/v1/chat/completions` API path instead of the newer `/v1/responses` API. | | `NEMOCLAW_INFERENCE_INPUTS` | comma-separated list of `text` and/or `image` | Declares model input modalities for vision-capable models. Validated strictly; unknown tokens are ignored. | diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 8e5e9ee6fa0..71ae68ff7b3 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -1575,6 +1575,7 @@ async function promptHermesAuthMethod(): Promise Date: Sun, 10 May 2026 06:47:54 -0700 Subject: [PATCH 4/6] fix(hermes): address provider onboarding review findings Signed-off-by: Aaron Erickson --- src/lib/inference/model-prompts.test.ts | 32 ++++++++++- src/lib/inference/model-prompts.ts | 14 +++-- src/lib/oauth-device-code.test.ts | 13 ++++- src/lib/oauth-device-code.ts | 73 ++++++++++++++++++------- src/lib/onboard.ts | 10 +++- test/hermes-provider-foundation.test.ts | 24 +++++++- 6 files changed, 134 insertions(+), 32 deletions(-) diff --git a/src/lib/inference/model-prompts.test.ts b/src/lib/inference/model-prompts.test.ts index 45b2664f6de..fb7b9fbfb11 100644 --- a/src/lib/inference/model-prompts.test.ts +++ b/src/lib/inference/model-prompts.test.ts @@ -135,11 +135,41 @@ describe("model prompt helpers", () => { expect(writeLine).toHaveBeenCalledWith(" 11) model-11"); }); + it("keeps a hidden remote default when the user presses enter", async () => { + const modelOptions = Array.from({ length: 12 }, (_, index) => `model-${index + 1}`); + const promptFn = promptSequence([""]); + const writeLine = vi.fn(); + const result = await promptRemoteModel("Hermes Provider", "hermesProvider", "model-12", null, { + promptFn, + writeLine, + remoteModelOptions: { hermesProvider: modelOptions }, + topLevelModelLimit: 3, + otherShowsFullList: false, + }); + + expect(result).toBe("model-12"); + expect(promptFn).toHaveBeenCalledWith(" Choose model [12]: "); + expect(writeLine).toHaveBeenCalledWith(" 12) model-12 (current)"); + }); + + it("keeps a hidden remote default when the user types its index", async () => { + const modelOptions = Array.from({ length: 12 }, (_, index) => `model-${index + 1}`); + const result = await promptRemoteModel("Hermes Provider", "hermesProvider", "model-12", null, { + promptFn: promptSequence(["12"]), + writeLine: vi.fn(), + remoteModelOptions: { hermesProvider: modelOptions }, + topLevelModelLimit: 3, + otherShowsFullList: true, + }); + + expect(result).toBe("model-12"); + }); + it("limits top-level remote catalogs before manual-entry fallback", async () => { const modelOptions = Array.from({ length: 12 }, (_, index) => `model-${index + 1}`); const writeLine = vi.fn(); const result = await promptRemoteModel("Hermes Provider", "hermesProvider", "model-12", null, { - promptFn: promptSequence(["", "custom-model"]), + promptFn: promptSequence(["4", "custom-model"]), writeLine, remoteModelOptions: { hermesProvider: modelOptions }, topLevelModelLimit: 3, diff --git a/src/lib/inference/model-prompts.ts b/src/lib/inference/model-prompts.ts index 94fe19e9114..98e981062d2 100644 --- a/src/lib/inference/model-prompts.ts +++ b/src/lib/inference/model-prompts.ts @@ -183,7 +183,7 @@ export async function promptRemoteModel( ): Promise { const deps = resolvePromptOptions(options); const modelOptions = deps.remoteModelOptions[providerKey] || []; - const defaultIndex = Math.max(0, modelOptions.indexOf(defaultModel)); + const defaultIndex = modelOptions.indexOf(defaultModel); const topLevelLimit = options.topLevelModelLimit && options.topLevelModelLimit > 0 ? Math.min(options.topLevelModelLimit, modelOptions.length) @@ -192,9 +192,9 @@ export async function promptRemoteModel( options.otherShowsFullList === true && topLevelLimit < modelOptions.length; const visibleOptions = modelOptions.slice(0, topLevelLimit); const defaultChoice = - defaultIndex >= visibleOptions.length - ? visibleOptions.length + 1 - : Math.min(defaultIndex, Math.max(visibleOptions.length - 1, 0)) + 1; + defaultIndex >= 0 + ? defaultIndex + 1 + : Math.min(Math.max(visibleOptions.length, 1), visibleOptions.length + 1); deps.writeLine(""); deps.writeLine(` ${label} models:`); @@ -202,6 +202,9 @@ export async function promptRemoteModel( deps.writeLine(` ${index + 1}) ${option}`); }); deps.writeLine(` ${visibleOptions.length + 1}) Other...`); + if (defaultIndex >= visibleOptions.length) { + deps.writeLine(` ${defaultIndex + 1}) ${defaultModel} (current)`); + } deps.writeLine(""); const choice = await deps.promptFn(` Choose model [${defaultChoice}]: `); @@ -213,6 +216,9 @@ export async function promptRemoteModel( deps.exitFn(); } const index = parseInt(choice || String(defaultChoice), 10) - 1; + if (defaultIndex >= 0 && index === defaultIndex) { + return defaultModel; + } if (Number.isFinite(index) && index >= 0 && index < visibleOptions.length) { return visibleOptions[index]; } diff --git a/src/lib/oauth-device-code.test.ts b/src/lib/oauth-device-code.test.ts index ba02830dd8c..7b6ebd354d3 100644 --- a/src/lib/oauth-device-code.test.ts +++ b/src/lib/oauth-device-code.test.ts @@ -43,12 +43,13 @@ describe("pollForToken", () => { describe("refreshAccessTokenWithRefreshToken", () => { it("uses the host-side refresh-token grant form body", async () => { - const calls: Array<{ url: string; body: string }> = []; + const calls: Array<{ url: string; body: string; signal: AbortSignal | null }> = []; const token = await refreshAccessTokenWithRefreshToken("refresh-1", { fetch: (async (url, init) => { calls.push({ url: String(url), body: String(init?.body ?? ""), + signal: init?.signal instanceof AbortSignal ? init.signal : null, }); return new Response( JSON.stringify({ @@ -76,6 +77,7 @@ describe("refreshAccessTokenWithRefreshToken", () => { expect(new URLSearchParams(calls[0]?.body).get("client_id")).toBe( "hermes-cli", ); + expect(calls[0]?.signal).toBeInstanceOf(AbortSignal); }); it("surfaces refresh-token grant errors", async () => { @@ -100,7 +102,12 @@ describe("refreshAccessTokenWithRefreshToken", () => { describe("mintAgentKeyWithAccessToken", () => { it("mints a short-lived agent key with Authorization bearer auth", async () => { - const calls: Array<{ url: string; auth: string | null; body: string }> = []; + const calls: Array<{ + url: string; + auth: string | null; + body: string; + signal: AbortSignal | null; + }> = []; const key = await mintAgentKeyWithAccessToken("access-1", { minTtlSeconds: 120, fetch: (async (url, init) => { @@ -109,6 +116,7 @@ describe("mintAgentKeyWithAccessToken", () => { url: String(url), auth: headers.get("authorization"), body: String(init?.body ?? ""), + signal: init?.signal instanceof AbortSignal ? init.signal : null, }); return new Response( JSON.stringify({ @@ -129,5 +137,6 @@ describe("mintAgentKeyWithAccessToken", () => { expect(JSON.parse(calls[0]?.body ?? "{}")).toEqual({ min_ttl_seconds: 120, }); + expect(calls[0]?.signal).toBeInstanceOf(AbortSignal); }); }); diff --git a/src/lib/oauth-device-code.ts b/src/lib/oauth-device-code.ts index 50e84412f4d..3c133b76748 100644 --- a/src/lib/oauth-device-code.ts +++ b/src/lib/oauth-device-code.ts @@ -4,10 +4,11 @@ /** * OAuth 2.0 Device Authorization Grant helpers for Hermes Provider onboarding. * - * NemoClaw keeps Nous Portal OAuth on the host. Onboarding stores the - * refresh-token state under ~/.nemoclaw and uses it to mint short-lived - * agent keys for the OpenShell inference provider. The sandbox receives the - * normal OpenShell inference placeholder, never raw Nous OAuth tokens. + * Hermes OAuth/API-key material must not be durably persisted to host-side + * NemoClaw storage such as ~/.nemoclaw. Onboarding uses ephemeral OAuth tokens + * to mint short-lived agent keys for OpenShell provider registration. The + * sandbox receives only the normal OpenShell inference placeholder, never raw + * Hermes/Nous OAuth tokens or API keys. */ import { spawn } from "node:child_process"; @@ -122,19 +123,39 @@ function clampInterval(value: unknown): number { ); } +function createRequestTimeout(timeoutSeconds: number | undefined): { + signal: AbortSignal; + clear: () => void; +} { + const controller = new AbortController(); + const seconds = Math.max(1, Math.round(timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS)); + const timer = setTimeout(() => controller.abort(), seconds * 1000); + return { + signal: controller.signal, + clear: () => clearTimeout(timer), + }; +} + async function postForm( url: string, body: Record, fetchImpl: typeof fetch, + timeoutSeconds?: number, ): Promise { - return fetchImpl(url, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams(body).toString(), - }); + const timeout = createRequestTimeout(timeoutSeconds); + try { + return await fetchImpl(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(body).toString(), + signal: timeout.signal, + }); + } finally { + timeout.clear(); + } } export async function requestDeviceCode( @@ -149,6 +170,7 @@ export async function requestDeviceCode( `${portalBaseUrl}/api/oauth/device/code`, { client_id: clientId, scope }, fetchImpl, + opts.timeoutSeconds, ); if (resp.status !== 200) { @@ -205,6 +227,7 @@ export async function pollForToken( client_id: clientId, }, fetchImpl, + opts.timeoutSeconds, ); if (resp.status === 200) { @@ -262,6 +285,7 @@ export async function refreshAccessTokenWithRefreshToken( client_id: clientId, }, fetchImpl, + opts.timeoutSeconds, ); if (resp.status !== 200) { @@ -296,15 +320,22 @@ export async function mintAgentKeyWithAccessToken( const portalBaseUrl = opts.portalBaseUrl ?? DEFAULT_PORTAL_BASE_URL; const minTtlSeconds = Math.max(60, Math.round(opts.minTtlSeconds ?? 1800)); - const resp = await fetchImpl(`${portalBaseUrl}/api/oauth/agent-key`, { - method: "POST", - headers: { - Accept: "application/json", - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ min_ttl_seconds: minTtlSeconds }), - }); + const timeout = createRequestTimeout(opts.timeoutSeconds); + let resp: Response; + try { + resp = await fetchImpl(`${portalBaseUrl}/api/oauth/agent-key`, { + method: "POST", + headers: { + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ min_ttl_seconds: minTtlSeconds }), + signal: timeout.signal, + }); + } finally { + timeout.clear(); + } if (resp.status !== 200) { let errorPayload: { error?: string; error_description?: string } = {}; diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 71ae68ff7b3..bf6ecbf7c24 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -1552,7 +1552,11 @@ async function promptHermesAuthMethod(): Promise = {}): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { ...process.env, HOME: tmpDir, ...extra }; + const env: NodeJS.ProcessEnv = { ...process.env, HOME: tmpDir }; for (const key of Object.keys(env)) { - if (key.startsWith("DISCORD_") || key.startsWith("TELEGRAM_")) { + if ( + key.startsWith("NEMOCLAW_") || + key.startsWith("DISCORD_") || + key.startsWith("TELEGRAM_") || + key.startsWith("AWS_") || + key.startsWith("GCP_") || + key.startsWith("GOOGLE_") || + key.startsWith("GCLOUD_") || + key.startsWith("AZURE_") || + key.endsWith("_CREDENTIALS") || + key.endsWith("_API_KEY") || + key.includes("SECRET") || + key.includes("TOKEN") + ) { delete env[key]; } } - return env; + return { ...env, ...extra }; } describe("Hermes Provider onboarding selection", () => { @@ -45,6 +60,7 @@ const { selectOnboardAgent } = require(${onboardPath}); cwd: repoRoot, encoding: "utf-8", env: buildHermeticEnv(tmpDir), + timeout: CHILD_TIMEOUT_MS, }); expect(result.status).toBe(0); @@ -85,6 +101,7 @@ const { setupNim } = require(${onboardPath}); NEMOCLAW_NON_INTERACTIVE: "1", NEMOCLAW_PROVIDER: "hermes-provider", }), + timeout: CHILD_TIMEOUT_MS, }); expect(result.status).not.toBe(0); @@ -133,6 +150,7 @@ const { setupNim } = require(${onboardPath}); NEMOCLAW_HERMES_AUTH_METHOD: "nous-api-key", NOUS_API_KEY: "nous-key-1", }), + timeout: CHILD_TIMEOUT_MS, }); expect(result.status).toBe(0); From 0f232a14fca7a34f6e544ab6760083ca75c74a5d Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 10 May 2026 07:17:02 -0700 Subject: [PATCH 5/6] fix(hermes): reuse registered provider defaults Signed-off-by: Aaron Erickson --- agents/hermes/manifest.yaml | 2 + src/lib/agent/defs.test.ts | 1 + src/lib/agent/defs.ts | 23 +++++++ src/lib/inference/model-prompts.test.ts | 21 +++++++ src/lib/inference/model-prompts.ts | 15 +++-- src/lib/onboard.ts | 72 +++++++++++++--------- test/onboard.test.ts | 79 +++++++++++++++++++++++++ 7 files changed, 179 insertions(+), 34 deletions(-) diff --git a/agents/hermes/manifest.yaml b/agents/hermes/manifest.yaml index 2c4ec48fa06..d594ec06222 100644 --- a/agents/hermes/manifest.yaml +++ b/agents/hermes/manifest.yaml @@ -106,6 +106,8 @@ inference: base_url_config_key: "model.base_url" model_config_key: "model.default" proxy_support: implicit # via httpx (OpenAI SDK dep) + provider_options: + - hermesProvider # ── Phone-home hosts ─────────────────────────────────────────── # Agent-specific egress endpoints needed for updates, auth, etc. diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index 98ff8d24b71..5ca61ee4282 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -63,6 +63,7 @@ describe("agent definitions", () => { envFile: ".env", format: "yaml", }); + expect(hermes.inferenceProviderOptions).toEqual(["hermesProvider"]); expect(hermes.healthProbe.url).toBe("http://localhost:8642/health"); expect(hermes.messagingPlatforms).toEqual(["telegram", "discord", "slack"]); }); diff --git a/src/lib/agent/defs.ts b/src/lib/agent/defs.ts index c794f2dd6ff..cee239c7257 100644 --- a/src/lib/agent/defs.ts +++ b/src/lib/agent/defs.ts @@ -47,6 +47,11 @@ export interface AgentDashboard { path: string; } +export interface AgentInference { + provider_type?: string; + provider_options?: string[]; +} + export interface AgentLegacyPaths { dockerfileBase: string | null; dockerfile: string | null; @@ -68,6 +73,7 @@ export interface AgentDefinition { forward_ports?: number[]; health_probe?: AgentHealthProbe; config?: ManifestRecord; + inference?: AgentInference; state_dirs?: string[]; state_files?: AgentStateFile[]; messaging_platforms?: { supported?: string[] }; @@ -79,6 +85,7 @@ export interface AgentDefinition { readonly forwardPort: number; readonly dashboard: AgentDashboard; readonly configPaths: AgentConfigPaths; + readonly inferenceProviderOptions?: string[]; readonly stateDirs: string[]; readonly stateFiles: AgentStateFile[]; readonly versionCommand: string; @@ -252,6 +259,16 @@ function readMessagingPlatforms(record: ManifestRecord): { supported?: string[] return supported ? { supported } : {}; } +function readInference(record: ManifestRecord): AgentInference | undefined { + const inference = readObject(record, "inference"); + if (!inference) return undefined; + + return { + provider_type: readString(inference, "provider_type"), + provider_options: readStringArray(inference, "provider_options"), + }; +} + function loadManifestRecord(manifestPath: string): ManifestRecord { const parsed = yaml.load(fs.readFileSync(manifestPath, "utf8")); if (!isManifestRecord(parsed)) { @@ -298,6 +315,7 @@ export function loadAgent(name: string): AgentDefinition { const forwardPorts = readPortArray(raw, "forward_ports"); const healthProbe = readHealthProbe(raw); const config = readObject(raw, "config"); + const inference = readInference(raw); const stateDirs = readStringArray(raw, "state_dirs"); const stateFiles = readStateFiles(raw); const phoneHomeHosts = readStringArray(raw, "phone_home_hosts"); @@ -318,6 +336,7 @@ export function loadAgent(name: string): AgentDefinition { forward_ports: forwardPorts, health_probe: healthProbe, config, + inference, state_dirs: stateDirs, state_files: stateFiles, messaging_platforms: messagingPlatforms, @@ -366,6 +385,10 @@ export function loadAgent(name: string): AgentDefinition { }; }, + get inferenceProviderOptions(): string[] { + return inference?.provider_options ?? []; + }, + get stateDirs(): string[] { return stateDirs ?? []; }, diff --git a/src/lib/inference/model-prompts.test.ts b/src/lib/inference/model-prompts.test.ts index fb7b9fbfb11..71724049806 100644 --- a/src/lib/inference/model-prompts.test.ts +++ b/src/lib/inference/model-prompts.test.ts @@ -165,6 +165,27 @@ describe("model prompt helpers", () => { expect(result).toBe("model-12"); }); + it("keeps a safe current remote default that is not in the curated list", async () => { + const writeLine = vi.fn(); + const promptFn = promptSequence([""]); + const result = await promptRemoteModel( + "OpenAI", + "openai", + "custom/provider-model", + null, + { + promptFn, + writeLine, + remoteModelOptions: { openai: ["model-1", "model-2", "model-3"] }, + }, + ); + + expect(result).toBe("custom/provider-model"); + expect(promptFn).toHaveBeenCalledWith(" Choose model [5]: "); + expect(writeLine).toHaveBeenCalledWith(" 4) Other..."); + expect(writeLine).toHaveBeenCalledWith(" 5) custom/provider-model (current)"); + }); + it("limits top-level remote catalogs before manual-entry fallback", async () => { const modelOptions = Array.from({ length: 12 }, (_, index) => `model-${index + 1}`); const writeLine = vi.fn(); diff --git a/src/lib/inference/model-prompts.ts b/src/lib/inference/model-prompts.ts index 98e981062d2..a91c2fcb3af 100644 --- a/src/lib/inference/model-prompts.ts +++ b/src/lib/inference/model-prompts.ts @@ -191,10 +191,15 @@ export async function promptRemoteModel( const shouldOfferFullList = options.otherShowsFullList === true && topLevelLimit < modelOptions.length; const visibleOptions = modelOptions.slice(0, topLevelLimit); - const defaultChoice = + const currentDefaultChoice = defaultIndex >= 0 ? defaultIndex + 1 - : Math.min(Math.max(visibleOptions.length, 1), visibleOptions.length + 1); + : defaultModel && isSafeModelId(defaultModel) + ? visibleOptions.length + 2 + : null; + const defaultChoice = + currentDefaultChoice ?? + Math.min(Math.max(visibleOptions.length, 1), visibleOptions.length + 1); deps.writeLine(""); deps.writeLine(` ${label} models:`); @@ -202,8 +207,8 @@ export async function promptRemoteModel( deps.writeLine(` ${index + 1}) ${option}`); }); deps.writeLine(` ${visibleOptions.length + 1}) Other...`); - if (defaultIndex >= visibleOptions.length) { - deps.writeLine(` ${defaultIndex + 1}) ${defaultModel} (current)`); + if (currentDefaultChoice !== null && currentDefaultChoice > visibleOptions.length + 1) { + deps.writeLine(` ${currentDefaultChoice}) ${defaultModel} (current)`); } deps.writeLine(""); @@ -216,7 +221,7 @@ export async function promptRemoteModel( deps.exitFn(); } const index = parseInt(choice || String(defaultChoice), 10) - 1; - if (defaultIndex >= 0 && index === defaultIndex) { + if (currentDefaultChoice !== null && index === currentDefaultChoice - 1) { return defaultModel; } if (Number.isFinite(index) && index >= 0 && index < visibleOptions.length) { diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index bf6ecbf7c24..4df8a2afdd0 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -4730,6 +4730,15 @@ function getEffectiveSandboxAgent(agent: AgentDefinition | null | undefined): Ag return agent || agentDefs.loadAgent("openclaw"); } +function getAgentInferenceProviderOptions(agent: AgentDefinition | null | undefined): string[] { + const effectiveAgent = agent?.name + ? agentDefs.loadAgent(agent.name) + : getEffectiveSandboxAgent(agent); + return Array.isArray(effectiveAgent.inferenceProviderOptions) + ? effectiveAgent.inferenceProviderOptions + : []; +} + function getSandboxAgentRegistryFields( agent: AgentDefinition | null | undefined, agentVersionKnown = true, @@ -6333,7 +6342,8 @@ async function setupNim( const requestedModel = isNonInteractive() ? getNonInteractiveModel(requestedProvider || "build") : null; - const hermesProviderAvailable = agent?.name === "hermes"; + const agentProviderOptions = getAgentInferenceProviderOptions(agent); + const hermesProviderAvailable = agentProviderOptions.includes("hermesProvider"); const options: Array<{ key: string; label: string }> = []; options.push({ key: "build", label: "NVIDIA Endpoints" }); options.push({ key: "openai", label: "OpenAI" }); @@ -6429,8 +6439,10 @@ async function setupNim( if (blueprintRouterCfg && blueprintRouterCfg.router?.enabled === true) { options.push({ key: "routed", label: "Model Router (experimental)" }); } - if (hermesProviderAvailable) { - options.push({ key: "hermesProvider", label: "Hermes Provider" }); + for (const providerKey of agentProviderOptions) { + const remoteConfig = REMOTE_PROVIDER_CONFIG[providerKey]; + if (!remoteConfig || options.some((option) => option.key === providerKey)) continue; + options.push({ key: providerKey, label: remoteConfig.label }); } function checkOllamaPortsOrWarn(): boolean { @@ -7472,35 +7484,37 @@ async function setupInference( (credentialEnv === HERMES_NOUS_API_KEY_CREDENTIAL_ENV ? HERMES_AUTH_METHOD_API_KEY : HERMES_AUTH_METHOD_OAUTH); - try { - const state = - resolvedHermesAuthMethod === HERMES_AUTH_METHOD_API_KEY - ? await hermesProviderAuth.ensureHermesProviderApiKeyCredentials(targetSandbox, { - apiKey: resolveHermesNousApiKey(), - runOpenshell, - baseUrl: endpointUrl || undefined, - }) - : await hermesProviderAuth.ensureHermesProviderOAuthCredentials(targetSandbox, { - allowInteractiveLogin: !isNonInteractive(), - runOpenshell, - baseUrl: endpointUrl || undefined, - }); - if (!state) { - const authLabel = hermesAuthMethodLabel(resolvedHermesAuthMethod); - console.error(` ✗ Hermes Provider ${authLabel} is not available on the host.`); + if (!hermesProviderAuth.isHermesProviderRegistered(runOpenshell)) { + try { + const state = + resolvedHermesAuthMethod === HERMES_AUTH_METHOD_API_KEY + ? await hermesProviderAuth.ensureHermesProviderApiKeyCredentials(targetSandbox, { + apiKey: resolveHermesNousApiKey(), + runOpenshell, + baseUrl: endpointUrl || undefined, + }) + : await hermesProviderAuth.ensureHermesProviderOAuthCredentials(targetSandbox, { + allowInteractiveLogin: !isNonInteractive(), + runOpenshell, + baseUrl: endpointUrl || undefined, + }); + if (!state) { + const authLabel = hermesAuthMethodLabel(resolvedHermesAuthMethod); + console.error(` ✗ Hermes Provider ${authLabel} is not available on the host.`); + console.error( + " Re-run `nemoclaw onboard --agent hermes` interactively to configure credentials.", + ); + process.exit(1); + } + } catch (err) { console.error( - " Re-run `nemoclaw onboard --agent hermes` interactively to configure credentials.", + ` ✗ Failed to prepare Hermes Provider credentials: ${ + err instanceof Error ? err.message : String(err) + }`, ); - process.exit(1); + if (isNonInteractive()) process.exit(1); + return { retry: "selection" }; } - } catch (err) { - console.error( - ` ✗ Failed to prepare Hermes Provider credentials: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - if (isNonInteractive()) process.exit(1); - return { retry: "selection" }; } const applyResult = runOpenshell( diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 6042f3828ab..8f39f468220 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -2747,6 +2747,85 @@ const { setupInference } = require(${onboardPath}); assert.equal(payload.nvidiaApiKey, "nvapi-secret-value"); }); + it("reuses a registered Hermes Provider without re-collecting host credentials", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-hermes-reuse-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "setup-hermes-reuse-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "state", "registry.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = String.raw` +const runner = require(${runnerPath}); +const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); +const registry = require(${registryPath}); + +const commands = []; +runner.run = (command, opts = {}) => { + const normalized = _n(command); + commands.push({ command: normalized, env: opts.env || null }); + if (normalized.includes("provider get hermes-provider")) { + return { status: 0, stdout: "Provider: hermes-provider", stderr: "" }; + } + return { status: 0, stdout: "", stderr: "" }; +}; +runner.runCapture = (command) => { + if (_n(command).includes("inference") && _n(command).includes("get")) { + return [ + "Gateway inference:", + "", + " Route: inference.local", + " Provider: hermes-provider", + " Model: moonshotai/kimi-k2.6", + " Version: 1", + ].join("\\n"); + } + return ""; +}; +registry.updateSandbox = () => true; + +delete process.env.NOUS_API_KEY; +delete process.env.OPENAI_API_KEY; +process.env.NEMOCLAW_NON_INTERACTIVE = "1"; + +const { setupInference } = require(${onboardPath}); + +(async () => { + await setupInference("test-box", "moonshotai/kimi-k2.6", "hermes-provider", "https://inference-api.nousresearch.com/v1", "OPENAI_API_KEY", "oauth"); + console.log(JSON.stringify(commands)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + expect(result.status).toBe(0); + const commands = parseStdoutJson(result.stdout); + assert.equal(commands.length, 3); + assert.match(commands[0].command, /gateway select nemoclaw/); + assert.match(commands[1].command, /provider get hermes-provider/); + assert.match(commands[2].command, /inference set --no-verify --provider hermes-provider/); + assert.ok(!commands.some((entry) => /provider (create|update)/.test(entry.command))); + assert.ok(!commands.some((entry) => entry.env?.NOUS_API_KEY || entry.env?.OPENAI_API_KEY)); + }); + it("configures Model Router as a host provider while sandboxes keep inference.local", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-router-inference-")); From 4a073c96e55ccbec52321f29ee16ada8d2ddb58c Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 10 May 2026 07:32:37 -0700 Subject: [PATCH 6/6] fix(hermes): address provider review feedback Signed-off-by: Aaron Erickson --- src/lib/agent/base-image.test.ts | 1 + src/lib/agent/defs.test.ts | 32 ++++++++++ src/lib/agent/defs.ts | 25 +++++++- src/lib/agent/onboard.test.ts | 1 + src/lib/agent/runtime.test.ts | 1 + src/lib/oauth-device-code.ts | 20 +++--- src/lib/onboard.ts | 20 +++++- test/onboard.test.ts | 104 ++++++++++++++++++++++++++++++- 8 files changed, 187 insertions(+), 17 deletions(-) diff --git a/src/lib/agent/base-image.test.ts b/src/lib/agent/base-image.test.ts index 987c46887be..f239e9e9f83 100644 --- a/src/lib/agent/base-image.test.ts +++ b/src/lib/agent/base-image.test.ts @@ -24,6 +24,7 @@ function makeAgent(overrides: Partial = {}): AgentDefinition { envFile: ".env", format: "yaml", }, + inferenceProviderOptions: [], stateDirs: [], stateFiles: [], versionCommand: "hermes --version", diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index 5ca61ee4282..6aa2f441c38 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -118,4 +118,36 @@ describe("agent definitions", () => { expect(() => loadAgent(agentName)).toThrow(/health_probe\.port/); }); + + it("rejects invalid inference provider options in manifests", () => { + const agentName = `invalid-inference-options-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "display_name: Broken Inference", + "inference:", + " provider_options:", + " - hermesProvider", + " - 42", + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow(/inference\.provider_options/); + }); + + it("rejects invalid inference provider type in manifests", () => { + const agentName = `invalid-inference-provider-type-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "display_name: Broken Inference Type", + "inference:", + " provider_type: 42", + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow(/inference\.provider_type/); + }); }); diff --git a/src/lib/agent/defs.ts b/src/lib/agent/defs.ts index cee239c7257..20f176221d8 100644 --- a/src/lib/agent/defs.ts +++ b/src/lib/agent/defs.ts @@ -85,7 +85,7 @@ export interface AgentDefinition { readonly forwardPort: number; readonly dashboard: AgentDashboard; readonly configPaths: AgentConfigPaths; - readonly inferenceProviderOptions?: string[]; + readonly inferenceProviderOptions: string[]; readonly stateDirs: string[]; readonly stateFiles: AgentStateFile[]; readonly versionCommand: string; @@ -263,9 +263,28 @@ function readInference(record: ManifestRecord): AgentInference | undefined { const inference = readObject(record, "inference"); if (!inference) return undefined; + const providerType = inference.provider_type; + if (providerType !== undefined && typeof providerType !== "string") { + throw new Error("Agent manifest field 'inference.provider_type' must be a string"); + } + + const providerOptions = inference.provider_options; + let providerOptionList: string[] | undefined; + if (providerOptions !== undefined) { + if ( + !Array.isArray(providerOptions) || + providerOptions.some((entry) => typeof entry !== "string") + ) { + throw new Error( + "Agent manifest field 'inference.provider_options' must be an array of strings", + ); + } + providerOptionList = providerOptions as string[]; + } + return { - provider_type: readString(inference, "provider_type"), - provider_options: readStringArray(inference, "provider_options"), + provider_type: providerType, + provider_options: providerOptionList, }; } diff --git a/src/lib/agent/onboard.test.ts b/src/lib/agent/onboard.test.ts index 68ad674f299..1380b7549fc 100644 --- a/src/lib/agent/onboard.test.ts +++ b/src/lib/agent/onboard.test.ts @@ -21,6 +21,7 @@ function makeAgent(overrides: Partial = {}): AgentDefinition { envFile: null, format: "yaml", }, + inferenceProviderOptions: [], stateDirs: [], stateFiles: [], versionCommand: "agent --version", diff --git a/src/lib/agent/runtime.test.ts b/src/lib/agent/runtime.test.ts index c9bc88ecd9b..5e1b628c3f9 100644 --- a/src/lib/agent/runtime.test.ts +++ b/src/lib/agent/runtime.test.ts @@ -25,6 +25,7 @@ function makeAgent(overrides: Partial = {}): AgentDefinition { envFile: null, format: "yaml", }, + inferenceProviderOptions: [], stateDirs: [], stateFiles: [], versionCommand: "test-agent --version", diff --git a/src/lib/oauth-device-code.ts b/src/lib/oauth-device-code.ts index 3c133b76748..af0dad416f3 100644 --- a/src/lib/oauth-device-code.ts +++ b/src/lib/oauth-device-code.ts @@ -22,6 +22,7 @@ export const DEFAULT_SCOPE = "inference:mint_agent_key"; const POLL_INTERVAL_MIN_SECONDS = 1; const POLL_INTERVAL_MAX_SECONDS = 30; const DEFAULT_TIMEOUT_SECONDS = 15 * 60; +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; export interface DeviceCodeResponse { device_code: string; @@ -56,6 +57,7 @@ export interface DeviceCodeFlowOptions { clientId?: string; scope?: string; timeoutSeconds?: number; + requestTimeoutMs?: number; noBrowser?: boolean; now?: () => number; sleep?: (ms: number) => Promise; @@ -123,13 +125,13 @@ function clampInterval(value: unknown): number { ); } -function createRequestTimeout(timeoutSeconds: number | undefined): { +function createRequestTimeout(timeoutMs: number | undefined): { signal: AbortSignal; clear: () => void; } { const controller = new AbortController(); - const seconds = Math.max(1, Math.round(timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS)); - const timer = setTimeout(() => controller.abort(), seconds * 1000); + const ms = Math.max(1, Math.round(timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS)); + const timer = setTimeout(() => controller.abort(), ms); return { signal: controller.signal, clear: () => clearTimeout(timer), @@ -140,9 +142,9 @@ async function postForm( url: string, body: Record, fetchImpl: typeof fetch, - timeoutSeconds?: number, + requestTimeoutMs?: number, ): Promise { - const timeout = createRequestTimeout(timeoutSeconds); + const timeout = createRequestTimeout(requestTimeoutMs); try { return await fetchImpl(url, { method: "POST", @@ -170,7 +172,7 @@ export async function requestDeviceCode( `${portalBaseUrl}/api/oauth/device/code`, { client_id: clientId, scope }, fetchImpl, - opts.timeoutSeconds, + opts.requestTimeoutMs, ); if (resp.status !== 200) { @@ -227,7 +229,7 @@ export async function pollForToken( client_id: clientId, }, fetchImpl, - opts.timeoutSeconds, + opts.requestTimeoutMs, ); if (resp.status === 200) { @@ -285,7 +287,7 @@ export async function refreshAccessTokenWithRefreshToken( client_id: clientId, }, fetchImpl, - opts.timeoutSeconds, + opts.requestTimeoutMs, ); if (resp.status !== 200) { @@ -320,7 +322,7 @@ export async function mintAgentKeyWithAccessToken( const portalBaseUrl = opts.portalBaseUrl ?? DEFAULT_PORTAL_BASE_URL; const minTtlSeconds = Math.max(60, Math.round(opts.minTtlSeconds ?? 1800)); - const timeout = createRequestTimeout(opts.timeoutSeconds); + const timeout = createRequestTimeout(opts.requestTimeoutMs); let resp: Response; try { resp = await fetchImpl(`${portalBaseUrl}/api/oauth/agent-key`, { diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 4df8a2afdd0..c61a7e1f3fb 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -6466,6 +6466,7 @@ async function setupNim( // recorded model from the same recovery decision. let recoveredFromSandbox = false; let recoveredModel: string | null = null; + hermesAuthMethod = null; if (isNonInteractive()) { let providerKey = requestedProvider; @@ -6696,7 +6697,15 @@ async function setupNim( if (isNonInteractive()) { model = defaultModel; } else { - const hermesProviderModels = await nousModels.getHermesProviderModelOptions(); + let hermesProviderModels: string[] = []; + try { + hermesProviderModels = await nousModels.getHermesProviderModelOptions(); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + console.warn( + ` Warning: failed to load Nous model recommendations; falling back to the current/default model (${detail}).`, + ); + } model = await promptRemoteModel( remoteConfig.label, selected.key, @@ -7484,7 +7493,14 @@ async function setupInference( (credentialEnv === HERMES_NOUS_API_KEY_CREDENTIAL_ENV ? HERMES_AUTH_METHOD_API_KEY : HERMES_AUTH_METHOD_OAUTH); - if (!hermesProviderAuth.isHermesProviderRegistered(runOpenshell)) { + const providerRegistered = hermesProviderAuth.isHermesProviderRegistered(runOpenshell); + const hasFreshNousApiKey = + resolvedHermesAuthMethod === HERMES_AUTH_METHOD_API_KEY && !!resolveHermesNousApiKey(); + const shouldPrepareHermesCredentials = + !providerRegistered || + hasFreshNousApiKey || + (resolvedHermesAuthMethod === HERMES_AUTH_METHOD_OAUTH && !isNonInteractive()); + if (shouldPrepareHermesCredentials) { try { const state = resolvedHermesAuthMethod === HERMES_AUTH_METHOD_API_KEY diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 8f39f468220..f4c32b95f2c 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -2676,7 +2676,9 @@ startGateway(null).catch(() => {}); const scriptPath = path.join(tmpDir, "setup-inference-check.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); - const registryPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "state", "registry.js")); + const registryPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "state", "registry.js"), + ); fs.mkdirSync(fakeBin, { recursive: true }); fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { @@ -2790,8 +2792,8 @@ runner.runCapture = (command) => { }; registry.updateSandbox = () => true; -delete process.env.NOUS_API_KEY; -delete process.env.OPENAI_API_KEY; +process.env.NOUS_API_KEY = "nous-host-secret"; +process.env.OPENAI_API_KEY = "openai-host-secret"; process.env.NEMOCLAW_NON_INTERACTIVE = "1"; const { setupInference } = require(${onboardPath}); @@ -2824,6 +2826,102 @@ const { setupInference } = require(${onboardPath}); assert.match(commands[2].command, /inference set --no-verify --provider hermes-provider/); assert.ok(!commands.some((entry) => /provider (create|update)/.test(entry.command))); assert.ok(!commands.some((entry) => entry.env?.NOUS_API_KEY || entry.env?.OPENAI_API_KEY)); + assert.ok( + !commands.some((entry) => /nous-host-secret|openai-host-secret/.test(entry.command)), + "host credential values must not appear in argv", + ); + }); + + it("reconciles a registered Hermes Provider when a fresh shell Nous key is selected", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-hermes-update-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "setup-hermes-update-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "state", "registry.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = String.raw` +const runner = require(${runnerPath}); +const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); +const registry = require(${registryPath}); + +const commands = []; +runner.run = (command, opts = {}) => { + const normalized = _n(command); + commands.push({ command: normalized, env: opts.env || null }); + if (normalized.includes("provider get hermes-provider")) { + return { status: 0, stdout: "Provider: hermes-provider", stderr: "" }; + } + return { status: 0, stdout: "", stderr: "" }; +}; +runner.runCapture = (command) => { + if (_n(command).includes("inference") && _n(command).includes("get")) { + return [ + "Gateway inference:", + "", + " Route: inference.local", + " Provider: hermes-provider", + " Model: moonshotai/kimi-k2.6", + " Version: 1", + ].join("\\n"); + } + return ""; +}; +registry.updateSandbox = () => true; + +process.env.NOUS_API_KEY = "nous-host-secret"; +delete process.env.OPENAI_API_KEY; +process.env.NEMOCLAW_NON_INTERACTIVE = "1"; + +const { setupInference } = require(${onboardPath}); + +(async () => { + await setupInference( + "test-box", + "moonshotai/kimi-k2.6", + "hermes-provider", + "https://inference-api.nousresearch.com/v1", + "NOUS_API_KEY", + "api_key", + ); + console.log(JSON.stringify(commands)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + expect(result.status).toBe(0); + const commands = parseStdoutJson(result.stdout); + const update = commands.find((entry) => /provider update hermes-provider/.test(entry.command)); + assert.ok(update); + assert.match(update.command, /--credential NOUS_API_KEY/); + assert.equal(update.env?.NOUS_API_KEY, "nous-host-secret"); + assert.ok( + !commands.some((entry) => /nous-host-secret/.test(entry.command)), + "shell credential value must not appear in argv", + ); + assert.match( + commands.at(-1)?.command || "", + /inference set --no-verify --provider hermes-provider/, + ); }); it("configures Model Router as a host provider while sandboxes keep inference.local", () => {