diff --git a/agents/hermes/config/messaging-config.ts b/agents/hermes/config/messaging-config.ts index 869ec0466b9..9bbc06669da 100644 --- a/agents/hermes/config/messaging-config.ts +++ b/agents/hermes/config/messaging-config.ts @@ -30,6 +30,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 715684c4978..9665fd73403 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -13,6 +13,16 @@ 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; + loadHermesOAuthState: (sandboxName: string) => { + auth_method?: unknown; + api_key?: unknown; + access_token?: unknown; + refresh_token?: unknown; + } | null; +}; const { LOCAL_INFERENCE_PROVIDERS, REMOTE_PROVIDER_CONFIG } = require("../../onboard/providers") as { LOCAL_INFERENCE_PROVIDERS: string[]; REMOTE_PROVIDER_CONFIG: Record; @@ -62,6 +72,74 @@ 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( + sandboxName: string, + session: Session | null, + credentialEnv: string | null, + log: (msg: string) => void, +): boolean { + const state = hermesProviderAuth.loadHermesOAuthState(sandboxName); + const authMethod = + normalizeHermesRebuildAuthMethod(session?.hermesAuthMethod) || + normalizeHermesRebuildAuthMethod(state?.auth_method) || + (credentialEnv === hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV ? "api_key" : "oauth"); + + if (authMethod === "api_key") { + const hostStateKey = nonEmptyString(state?.api_key) || nonEmptyString(state?.access_token); + const envKey = hydrateCredentialEnv(hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV); + log( + `Hermes Provider rebuild preflight: api_key state=${hostStateKey ? "present" : "missing"} env=${envKey ? "present" : "missing"}`, + ); + if (hostStateKey || envKey) return true; + } else { + const refreshToken = nonEmptyString(state?.refresh_token); + log( + `Hermes Provider rebuild preflight: oauth refresh_token=${refreshToken ? "present" : "missing"}`, + ); + if (refreshToken) return true; + } + + console.error(""); + console.error(` ${_RD}Rebuild preflight failed:${R} Hermes Provider credentials not found.`); + console.error(" Hermes Provider uses host-side Nous auth state, not OPENAI_API_KEY."); + if (authMethod === "api_key") { + console.error( + ` Re-run ${CLI_NAME} onboard to store a Nous API key, or export ${hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV} before rebuilding.`, + ); + } else { + console.error(` Re-run ${CLI_NAME} onboard to refresh Nous Portal OAuth for this sandbox.`); + } + console.error(""); + console.error(" Sandbox is untouched — no data was lost."); + return false; +} + /** * Rebuild a live sandbox while preserving registered agent state and policies. * @@ -161,8 +239,9 @@ 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 || 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 @@ -178,6 +257,7 @@ export async function rebuildSandbox( } else { rebuildCredentialEnv = session?.credentialEnv || null; } + const rebuildProvider = sessionMatchesTarget ? session?.provider || sb.provider : sb.provider; // Legacy migration: pre-fix local-inference sandboxes (GH #2519) recorded // credentialEnv="OPENAI_API_KEY" in onboard-session.json even though the // sandbox does not actually need a host OpenAI key (ollama-local uses an @@ -197,6 +277,24 @@ export async function rebuildSandbox( ); rebuildCredentialEnv = null; } + if (rebuildProvider === hermesProviderAuth.HERMES_PROVIDER_NAME) { + if ( + !preflightHermesProviderCredentials( + sandboxName, + sessionMatchesTarget ? session : null, + rebuildCredentialEnv, + log, + ) + ) { + bail("Missing Hermes Provider credentials"); + return; + } + // Hermes Provider credentials are host-managed in ~/.nemoclaw/hermes-oauth + // and re-registered by the provider setup path during recreate. Do not + // fall through to the generic env-var preflight, which would incorrectly + // demand OPENAI_API_KEY for OAuth or NOUS_API_KEY despite reusable state. + 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/credentials/store.ts b/src/lib/credentials/store.ts index 4048a4757d0..96a79d3951a 100644 --- a/src/lib/credentials/store.ts +++ b/src/lib/credentials/store.ts @@ -28,6 +28,7 @@ type CredentialInput = string | null | undefined; // sync without a second hand-maintained copy. export const KNOWN_CREDENTIAL_ENV_KEYS: readonly string[] = [ "NVIDIA_API_KEY", + "NOUS_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", diff --git a/src/lib/hermes-provider-auth.test.ts b/src/lib/hermes-provider-auth.test.ts new file mode 100644 index 00000000000..f643cd01961 --- /dev/null +++ b/src/lib/hermes-provider-auth.test.ts @@ -0,0 +1,170 @@ +// 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", +); +const DIST_CREDS = path.join( + import.meta.dirname, + "..", + "..", + "dist", + "lib", + "credentials.js", +); + +function clearDistModule(modulePath: string): void { + try { + delete require.cache[require.resolve(modulePath)]; + } catch { + // not loaded + } +} + +function loadAuthForHome(home: string): Record { + process.env.HOME = home; + clearDistModule(DIST_AUTH); + clearDistModule(DIST_CREDS); + return require(DIST_AUTH); +} + +afterEach(() => { + clearDistModule(DIST_AUTH); + clearDistModule(DIST_CREDS); +}); + +describe("Hermes provider host auth", () => { + it("persists API-key inference state with private permissions and registers OpenShell provider", async () => { + const originalHome = process.env.HOME; + const tmp = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-hermes-api-key-"), + ); + try { + const auth = loadAuthForHome(tmp); + 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"); + const statePath = auth.getHermesOAuthStatePath("my-assistant"); + expect(fs.statSync(path.dirname(statePath)).mode & 0o777).toBe(0o700); + expect(fs.statSync(statePath).mode & 0o777).toBe(0o600); + expect(JSON.parse(fs.readFileSync(statePath, "utf8")).api_key).toBe( + "nous-key-1", + ); + 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); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("refreshes OAuth state and mints an inference agent key", async () => { + const originalHome = process.env.HOME; + const tmp = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-hermes-oauth-"), + ); + try { + const auth = loadAuthForHome(tmp); + auth.persistHermesOAuthState("my-assistant", { + auth_method: "oauth", + access_token: "old-access", + refresh_token: "refresh-1", + expires_at: "2000-01-01T00:00:00.000Z", + }); + const calls: Array<{ url: string; auth: string | null; body: string }> = + []; + const state = await auth.ensureHermesProviderOAuthCredentials( + "my-assistant", + { + allowInteractiveLogin: false, + fetch: (async (url, init) => { + const headers = new Headers(init?.headers); + calls.push({ + url: String(url), + auth: headers.get("authorization"), + body: String(init?.body ?? ""), + }); + 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, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }) as typeof fetch, + runOpenshell: ( + args: string[], + opts: { env?: Record } = {}, + ) => { + if (args[0] === "provider" && args[1] === "get") { + return { status: 1, stdout: "", stderr: "" }; + } + expect(opts.env?.OPENAI_API_KEY).toBe("agent-key-1"); + return { status: 0, stdout: "", stderr: "" }; + }, + }, + ); + + expect(state.refresh_token).toBe("refresh-2"); + expect(state.agent_key).toBe("agent-key-1"); + expect(calls[0]?.body).toContain("refresh_token=refresh-1"); + expect(calls[1]?.auth).toBe("Bearer access-2"); + } 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..ae751e97776 --- /dev/null +++ b/src/lib/hermes-provider-auth.ts @@ -0,0 +1,310 @@ +// @ts-nocheck +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Host-side Hermes Provider inference credentials. + * + * This is the provider-foundation slice only. It owns host persistence for + * Nous Portal OAuth/API-key inference and OpenShell provider registration. + * The managed-tool broker and messaging bridge lifecycle live in the next + * Hermes wrapper runtime branch. + */ + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); + +const { getCredsDir } = require("./credentials/store"); +const oauth = require("./oauth-device-code"); +const onboardProviders = require("./onboard/providers"); +const { validateName } = require("./runner"); + +const HERMES_PROVIDER_NAME = "hermes-provider"; +const HERMES_INFERENCE_CREDENTIAL_ENV = "OPENAI_API_KEY"; +const HERMES_NOUS_API_KEY_CREDENTIAL_ENV = "NOUS_API_KEY"; +const HERMES_OAUTH_DIR = path.join(getCredsDir(), "hermes-oauth"); +const ACCESS_REFRESH_SKEW_MS = 120_000; +const AGENT_KEY_MIN_TTL_SECONDS = 1800; + +function ensurePrivateStateDir(dir) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.chmodSync(dir, 0o700); +} + +function ensureHermesOAuthDir() { + ensurePrivateStateDir(HERMES_OAUTH_DIR); +} + +function getHermesOAuthStatePath(sandboxName) { + const safeName = validateName(sandboxName, "sandbox name"); + ensureHermesOAuthDir(); + return path.join(HERMES_OAUTH_DIR, `${safeName}.json`); +} + +function atomicWriteJson(file, value) { + ensurePrivateStateDir(path.dirname(file)); + const tmp = path.join( + path.dirname(file), + `.${path.basename(file)}.${process.pid}.${Date.now()}.${crypto + .randomBytes(4) + .toString("hex")}.tmp`, + ); + fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }); + fs.chmodSync(tmp, 0o600); + fs.renameSync(tmp, file); + fs.chmodSync(file, 0o600); +} + +function loadHermesOAuthState(sandboxName) { + try { + const parsed = JSON.parse( + fs.readFileSync(getHermesOAuthStatePath(sandboxName), "utf8"), + ); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +function persistHermesOAuthState(sandboxName, state) { + atomicWriteJson(getHermesOAuthStatePath(sandboxName), { + version: 1, + sandbox: sandboxName, + ...state, + updated_at: new Date().toISOString(), + }); +} + +function tokenExpiresSoon(expiresAt, skewMs = ACCESS_REFRESH_SKEW_MS) { + if (!expiresAt || typeof expiresAt !== "string") return true; + const timestamp = Date.parse(expiresAt); + if (!Number.isFinite(timestamp)) return true; + return timestamp - Date.now() < skewMs; +} + +function withTokenMetadata(existing, tokenResp) { + const now = new Date(); + const expiresIn = + typeof tokenResp.expires_in === "number" && + Number.isFinite(tokenResp.expires_in) + ? tokenResp.expires_in + : 900; + return { + ...(existing || {}), + auth_method: "oauth", + api_key: undefined, + access_token: tokenResp.access_token, + refresh_token: tokenResp.refresh_token, + token_type: tokenResp.token_type || "Bearer", + scope: tokenResp.scope || existing?.scope || oauth.DEFAULT_SCOPE, + expires_in: expiresIn, + expires_at: new Date(now.getTime() + expiresIn * 1000).toISOString(), + obtained_at: now.toISOString(), + client_id: oauth.DEFAULT_CLIENT_ID, + portal_base_url: oauth.DEFAULT_PORTAL_BASE_URL, + inference_base_url: oauth.DEFAULT_INFERENCE_BASE_URL, + }; +} + +function withApiKeyMetadata(existing, apiKey, sandboxName) { + const now = new Date(); + return { + ...(existing || {}), + version: 1, + sandbox: sandboxName, + auth_method: "api_key", + api_key: apiKey, + access_token: undefined, + refresh_token: undefined, + token_type: "Bearer", + portal_base_url: oauth.DEFAULT_PORTAL_BASE_URL, + inference_base_url: oauth.DEFAULT_INFERENCE_BASE_URL, + obtained_at: now.toISOString(), + updated_at: now.toISOString(), + }; +} + +async function ensureHermesOAuthState( + sandboxName, + { allowInteractiveLogin = true, log = console.error, fetch = undefined } = {}, +) { + let state = loadHermesOAuthState(sandboxName); + if ( + state?.auth_method === "oauth" && + state?.refresh_token && + !tokenExpiresSoon(state.expires_at) + ) { + return state; + } + + if (state?.auth_method === "oauth" && state?.refresh_token) { + try { + const refreshed = await oauth.refreshAccessTokenWithRefreshToken( + state.refresh_token, + { + fetch, + }, + ); + state = withTokenMetadata(state, refreshed); + persistHermesOAuthState(sandboxName, state); + return state; + } catch (err) { + if (!allowInteractiveLogin) { + throw err; + } + const message = err instanceof Error ? err.message : String(err); + log(` ⚠ Hermes Provider OAuth refresh failed: ${message}`); + log(" Falling back to browser authorization."); + } + } + + if (!allowInteractiveLogin) { + return null; + } + + const tokens = await oauth.runDeviceCodeFlow({ fetch, log }); + state = withTokenMetadata(state, tokens); + persistHermesOAuthState(sandboxName, state); + return state; +} + +async function ensureHermesAgentKey( + sandboxName, + state, + { fetch = undefined } = {}, +) { + if ( + state?.agent_key && + !tokenExpiresSoon( + state.agent_key_expires_at, + AGENT_KEY_MIN_TTL_SECONDS * 1000, + ) + ) { + return state; + } + + const minted = await oauth.mintAgentKeyWithAccessToken(state.access_token, { + fetch, + minTtlSeconds: AGENT_KEY_MIN_TTL_SECONDS, + }); + const now = new Date(); + const expiresIn = + typeof minted.expires_in === "number" && Number.isFinite(minted.expires_in) + ? minted.expires_in + : AGENT_KEY_MIN_TTL_SECONDS; + const next = { + ...state, + agent_key: minted.api_key, + agent_key_id: minted.key_id || null, + agent_key_expires_at: + minted.expires_at || + new Date(now.getTime() + expiresIn * 1000).toISOString(), + agent_key_expires_in: expiresIn, + agent_key_reused: Boolean(minted.reused), + agent_key_obtained_at: now.toISOString(), + inference_base_url: minted.inference_base_url || state.inference_base_url, + }; + persistHermesOAuthState(sandboxName, next); + return next; +} + +function upsertProvider(name, type, credentialEnv, baseUrl, env, runOpenshell) { + const result = onboardProviders.upsertProvider( + name, + type, + credentialEnv, + baseUrl, + env, + runOpenshell, + ); + if (!result.ok) { + throw new Error(result.message || `failed to upsert provider '${name}'`); + } +} + +function registerHermesInferenceProvider( + apiKey, + runOpenshell, + credentialEnv = HERMES_INFERENCE_CREDENTIAL_ENV, + baseUrl = oauth.DEFAULT_INFERENCE_BASE_URL, +) { + upsertProvider( + HERMES_PROVIDER_NAME, + "openai", + credentialEnv, + baseUrl, + { [credentialEnv]: apiKey }, + runOpenshell, + ); +} + +async function ensureHermesProviderOAuthCredentials( + sandboxName, + { + allowInteractiveLogin = true, + runOpenshell = null, + log = console.error, + fetch = undefined, + } = {}, +) { + let state = await ensureHermesOAuthState(sandboxName, { + allowInteractiveLogin, + log, + fetch, + }); + if (!state) return null; + state = await ensureHermesAgentKey(sandboxName, state, { fetch }); + if (runOpenshell) { + registerHermesInferenceProvider(state.agent_key, runOpenshell); + } + return state; +} + +async function ensureHermesProviderApiKeyCredentials( + sandboxName, + { apiKey = null, runOpenshell = null } = {}, +) { + const existing = loadHermesOAuthState(sandboxName); + const existingApiKey = + existing?.auth_method === "api_key" || existing?.api_key + ? existing.api_key || existing.access_token + : null; + const normalizedApiKey = String(apiKey || existingApiKey || "").trim(); + if (!normalizedApiKey) return null; + + let state = existing; + if ( + !state || + state.auth_method !== "api_key" || + state.api_key !== normalizedApiKey + ) { + state = withApiKeyMetadata(existing, normalizedApiKey, sandboxName); + persistHermesOAuthState(sandboxName, state); + } + + if (runOpenshell) { + registerHermesInferenceProvider( + normalizedApiKey, + runOpenshell, + HERMES_NOUS_API_KEY_CREDENTIAL_ENV, + ); + } + return state; +} + +module.exports = { + HERMES_PROVIDER_NAME, + HERMES_INFERENCE_CREDENTIAL_ENV, + HERMES_NOUS_API_KEY_CREDENTIAL_ENV, + HERMES_OAUTH_DIR, + AGENT_KEY_MIN_TTL_SECONDS, + getHermesOAuthStatePath, + loadHermesOAuthState, + persistHermesOAuthState, + ensureHermesOAuthState, + ensureHermesAgentKey, + ensureHermesProviderOAuthCredentials, + ensureHermesProviderApiKeyCredentials, + registerHermesInferenceProvider, +}; 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..55d51da721b 100644 --- a/src/lib/inference/model-prompts.test.ts +++ b/src/lib/inference/model-prompts.test.ts @@ -118,6 +118,23 @@ 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("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..1bf36ded979 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 = shouldOfferFullList + ? modelOptions.slice(0, topLevelLimit) + : modelOptions; + const defaultChoice = + shouldOfferFullList && 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 (shouldOfferFullList && index === visibleOptions.length) { + return promptFullRemoteModelList(label, modelOptions, defaultModel, 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..fa14fab911d --- /dev/null +++ b/src/lib/oauth-device-code.test.ts @@ -0,0 +1,100 @@ +// 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, + refreshAccessTokenWithRefreshToken, +} from "../../dist/lib/oauth-device-code"; + +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..d05dd97e716 --- /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.refresh_token) { + throw new OAuthError( + "token_response_missing_refresh_token", + "portal returned no refresh_token; cannot persist host-side auth state", + ); + } + 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 a27513407a6..b2693dde73f 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,99 @@ 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 stageNousApiKeyProviderFallback(): void { + const providerKey = (process.env.NEMOCLAW_PROVIDER_KEY || "").trim(); + if (providerKey && !getCredential(HERMES_NOUS_API_KEY_CREDENTIAL_ENV)) { + saveCredential(HERMES_NOUS_API_KEY_CREDENTIAL_ENV, providerKey); + } +} + +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 @@ -3006,6 +3106,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 } = @@ -4047,7 +4148,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) { @@ -4691,6 +4798,7 @@ type OnboardConfigSummary = { provider: string | null; model: string | null; credentialEnv?: string | null; + hermesAuthMethod?: HermesAuthMethod | string | null; webSearchConfig?: WebSearchConfig | null; enabledChannels?: string[] | null; sandboxName: string; @@ -4733,6 +4841,7 @@ function formatOnboardConfigSummary({ provider, model, credentialEnv = null, + hermesAuthMethod = null, webSearchConfig = null, enabledChannels = null, sandboxName, @@ -4745,9 +4854,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}`); @@ -6060,11 +6180,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; }> { @@ -6075,6 +6197,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 @@ -6165,6 +6288,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" }); @@ -6260,6 +6384,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(); @@ -6361,6 +6488,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.`, ); @@ -6469,6 +6603,65 @@ 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; + stageNousApiKeyProviderFallback(); + if (isNonInteractive()) { + if (!resolveProviderCredential(HERMES_NOUS_API_KEY_CREDENTIAL_ENV)) { + 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 ensureNamedCredential( + HERMES_NOUS_API_KEY_CREDENTIAL_ENV, + "Hermes Provider Nous API Key", + HERMES_NOUS_API_KEY_HELP_URL, + ); + } + } 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. @@ -7203,7 +7396,15 @@ async function setupNim( } } - return { model, provider, endpointUrl, credentialEnv, preferredInferenceApi, nimContainer }; + return { + model, + provider, + endpointUrl, + credentialEnv, + hermesAuthMethod, + preferredInferenceApi, + nimContainer, + }; } // ── Step 4: Inference provider ─────────────────────────────────── @@ -7214,10 +7415,68 @@ 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: hydrateCredentialEnv(credentialEnv || HERMES_NOUS_API_KEY_CREDENTIAL_ENV), + runOpenshell, + }) + : await hermesProviderAuth.ensureHermesProviderOAuthCredentials(targetSandbox, { + allowInteractiveLogin: !isNonInteractive(), + runOpenshell, + }); + 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" || @@ -7829,7 +8088,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" @@ -8796,7 +9055,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; @@ -8815,7 +9075,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; @@ -8845,7 +9106,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...] @@ -9348,6 +9610,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; @@ -9365,6 +9628,8 @@ function toSessionUpdates( normalized.endpointUrl = toOptionalString(updates.endpointUrl); if (updates.credentialEnv !== undefined) normalized.credentialEnv = toOptionalString(updates.credentialEnv); + if (updates.hermesAuthMethod !== undefined) + normalized.hermesAuthMethod = toOptionalString(updates.hermesAuthMethod); if (updates.preferredInferenceApi !== undefined) { normalized.preferredInferenceApi = toOptionalString(updates.preferredInferenceApi); } @@ -9723,14 +9988,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`); @@ -9902,6 +10170,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; @@ -9923,11 +10197,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( @@ -9937,6 +10212,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { model, endpointUrl, credentialEnv, + hermesAuthMethod, preferredInferenceApi, nimContainer, }), @@ -9951,6 +10227,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(); @@ -9967,7 +10263,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { } onboardSession.markStepComplete( "inference", - toSessionUpdates({ provider, model, nimContainer }), + toSessionUpdates({ provider, model, hermesAuthMethod, nimContainer }), ); break; } @@ -9989,6 +10285,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { provider, model, credentialEnv, + hermesAuthMethod, webSearchConfig, enabledChannels: selectedMessagingChannels.length > 0 ? selectedMessagingChannels : null, sandboxName, @@ -10014,6 +10311,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { provider, endpointUrl, credentialEnv, + hermesAuthMethod, ); delete process.env.NVIDIA_API_KEY; if (inferenceResult?.retry === "selection") { @@ -10025,7 +10323,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { } onboardSession.markStepComplete( "inference", - toSessionUpdates({ provider, model, nimContainer }), + toSessionUpdates({ provider, model, hermesAuthMethod, nimContainer }), ); break; } @@ -10217,14 +10515,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"); @@ -10295,7 +10593,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 @@ -10434,6 +10734,7 @@ module.exports = { setupInference, setupMessagingChannels, MESSAGING_CHANNELS, + selectOnboardAgent, setupNim, providerNameToOptionKey, readRecordedProvider, @@ -10457,6 +10758,7 @@ module.exports = { summarizeProbeFailure, hasResponsesToolCall, 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.ts b/src/lib/state/onboard-session.ts index bed04ea39f1..c737fc7bb09 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -75,6 +75,7 @@ export interface Session { model: string | null; endpointUrl: string | null; credentialEnv: string | null; + hermesAuthMethod: string | null; preferredInferenceApi: string | null; nimContainer: string | null; routerPid: number | null; @@ -126,6 +127,7 @@ export interface SessionUpdates { model?: string; endpointUrl?: string; credentialEnv?: string; + hermesAuthMethod?: string; preferredInferenceApi?: string; nimContainer?: string; routerPid?: number; @@ -153,6 +155,7 @@ export interface DebugSessionSummary { model: string | null; endpointUrl: string | null; credentialEnv: string | null; + hermesAuthMethod: string | null; preferredInferenceApi: string | null; nimContainer: string | null; policyPresets: string[] | null; @@ -308,6 +311,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), @@ -347,6 +351,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: readString(data.hermesAuthMethod), preferredInferenceApi: readString(data.preferredInferenceApi), nimContainer: readString(data.nimContainer), routerPid: readPositiveInteger(data.routerPid), @@ -708,6 +713,8 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial { if (typeof updates.model === "string") safe.model = updates.model; if (typeof updates.endpointUrl === "string") safe.endpointUrl = redactUrl(updates.endpointUrl); if (typeof updates.credentialEnv === "string") safe.credentialEnv = updates.credentialEnv; + if (typeof updates.hermesAuthMethod === "string") + safe.hermesAuthMethod = updates.hermesAuthMethod; if (typeof updates.preferredInferenceApi === "string") safe.preferredInferenceApi = updates.preferredInferenceApi; if (typeof updates.nimContainer === "string") safe.nimContainer = updates.nimContainer; @@ -853,6 +860,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/credentials.test.ts b/test/credentials.test.ts index d0d5768448a..385de7a6b78 100644 --- a/test/credentials.test.ts +++ b/test/credentials.test.ts @@ -76,6 +76,12 @@ describe("messaging legacy bridge credentials", () => { }); }); +describe("Hermes Provider credentials", () => { + it("allows staging Nous API keys for Hermes inference", () => { + expect(KNOWN_CREDENTIAL_ENV_KEYS).toContain("NOUS_API_KEY"); + }); +}); + describe("host-side credential staging", () => { it("stages values in process.env and never writes to disk", async () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-creds-")); diff --git a/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts index f22e070779c..fc61deed49b 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -164,6 +164,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 }), }); @@ -175,6 +176,7 @@ describe("agents/hermes/generate-config.ts", () => { expect(envFile).toContain("TELEGRAM_ALLOWED_USERS=123456789\n"); expect(envFile).toContain("SLACK_BOT_TOKEN=openshell:resolve:env:SLACK_BOT_TOKEN\n"); expect(envFile).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..d07365cdb14 --- /dev/null +++ b/test/hermes-provider-foundation.test.ts @@ -0,0 +1,138 @@ +// 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"; + +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: { ...process.env, HOME: 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: { + ...process.env, + HOME: 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: { + ...process.env, + HOME: 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..067c6d39019 100644 --- a/test/rebuild-credential-preflight.test.ts +++ b/test/rebuild-credential-preflight.test.ts @@ -49,6 +49,7 @@ 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; @@ -60,6 +61,7 @@ function createFixture(opts: { savedCredential, providerSelectionStatus = "complete", agent = null, + hermesAuthMethod = null, messagingChannels = null, providerCredentialHashes, dockerBuildExitCode = 0, @@ -110,6 +112,7 @@ function createFixture(opts: { model: "meta/llama-3.3-70b-instruct", endpointUrl: null, credentialEnv, + hermesAuthMethod, preferredInferenceApi: null, nimContainer: null, webSearchConfig: null, @@ -266,6 +269,28 @@ process.exit(0); return { tmpDir, nemoclawDir, sandboxName, fakeRoot }; } +function writeHermesProviderState( + fixture: ReturnType, + state: Record, +) { + const dir = path.join(fixture.nemoclawDir, "hermes-oauth"); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.writeFileSync( + path.join(dir, `${fixture.sandboxName}.json`), + JSON.stringify( + { + version: 1, + sandbox: fixture.sandboxName, + updated_at: "2026-01-01T00:00:00.000Z", + ...state, + }, + null, + 2, + ), + { mode: 0o600 }, + ); +} + function runRebuild( fixture: ReturnType, extraEnv: Record = {}, @@ -484,6 +509,79 @@ describe("Issue #2273: atomic rebuild", () => { expect(registryHasSandbox(f)).toBe(true); }, ); + + it( + "uses Hermes OAuth host state instead of requiring OPENAI_API_KEY", + { timeout: 60_000 }, + () => { + const f = createFixture({ + agent: "hermes", + provider: "hermes-provider", + credentialEnv: "OPENAI_API_KEY", + hermesAuthMethod: "oauth", + }); + writeHermesProviderState(f, { + auth_method: "oauth", + access_token: "access-1", + refresh_token: "refresh-1", + expires_at: "2026-01-01T00:15:00.000Z", + }); + + 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( + "uses Hermes API-key host state instead of requiring NOUS_API_KEY", + { timeout: 60_000 }, + () => { + const f = createFixture({ + agent: "hermes", + provider: "hermes-provider", + credentialEnv: "NOUS_API_KEY", + hermesAuthMethod: "api_key", + }); + writeHermesProviderState(f, { + auth_method: "api_key", + api_key: "nous-key-from-host-state", + }); + + const result = runRebuild(f); + 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 host auth state is missing", + { 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(result.status).not.toBe(0); + expect(output).toContain("Hermes Provider credentials not found"); + expect(output).toContain("not OPENAI_API_KEY"); + 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", () => {