feat(hermes): add provider onboarding foundation#3237
Conversation
Signed-off-by: Shannon Sands <shannon.sands.1979@gmail.com>
Signed-off-by: Shannon Sands <shannon.sands.1979@gmail.com>
Signed-off-by: Shannon Sands <shannon.sands.1979@gmail.com>
Signed-off-by: Shannon Sands <shannon.sands.1979@gmail.com>
📝 WalkthroughWalkthroughThis PR introduces Hermes Provider support with OAuth 2.0 device-code authentication flow, host-side credential persistence, and full integration into the onboarding and rebuild workflows. It also adds Slack user allowlist support for messaging channels and extends model recommendation fetching from remote catalogs. ChangesHermes Provider OAuth & Device-Code Integration
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/lib/actions/sandbox/rebuild.ts (1)
242-260:⚠️ Potential issue | 🟠 Major | ⚡ Quick winTreat a missing
session.sandboxNameas unknown, not matched.
!session?.sandboxNamelets an orphaned or partially-written session supplyproviderandcredentialEnvfor this rebuild. A stale session from a different sandbox can then run the wrong preflight and block a valid rebuild before any destructive work starts.Suggested fix
- const sessionMatchesTarget = !session?.sandboxName || session.sandboxName === sandboxName; + const sessionMatchesTarget = session?.sandboxName === sandboxName; let rebuildCredentialEnv: string | null = null; - if (!sessionMatchesTarget) { + if (!sessionMatchesTarget) { // Session belongs to a different sandbox — its credentialEnv may be // wrong (e.g. hermes session while rebuilding openclaw). Resolve the // target sandbox provider from the registry instead so destructive // operations still get a credential preflight for the sandbox being rebuilt. rebuildCredentialEnv = getRebuildCredentialEnvFromRegistry(sb.provider); - log( - `Preflight warning: session belongs to '${session.sandboxName}', not '${sandboxName}' — using registry credential env ${rebuildCredentialEnv || "(none)"}`, - ); - console.log( - ` ${D}Note: onboard session belongs to '${session.sandboxName}', not '${sandboxName}'. ` + - `Using the '${sandboxName}' registry entry for credential preflight.${R}`, - ); + if (session?.sandboxName) { + log( + `Preflight warning: session belongs to '${session.sandboxName}', not '${sandboxName}' — using registry credential env ${rebuildCredentialEnv || "(none)"}`, + ); + console.log( + ` ${D}Note: onboard session belongs to '${session.sandboxName}', not '${sandboxName}'. ` + + `Using the '${sandboxName}' registry entry for credential preflight.${R}`, + ); + } } else { rebuildCredentialEnv = session?.credentialEnv || null; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/lib/actions/sandbox/rebuild.ts` around lines 242 - 260, The logic in sessionMatchesTarget currently treats a missing session.sandboxName as a match, letting orphaned sessions supply provider/credentialEnv; change sessionMatchesTarget to only be true when session.sandboxName is present and equals sandboxName (i.e. session?.sandboxName === sandboxName). This will ensure getRebuildCredentialEnvFromRegistry(sb.provider) is used when sandboxName is unknown and that rebuildCredentialEnv and rebuildProvider (referenced by rebuildCredentialEnv, sessionMatchesTarget, rebuildProvider, getRebuildCredentialEnvFromRegistry) come from the registry unless the session explicitly belongs to the target sandbox.src/lib/onboard.ts (1)
4151-4164:⚠️ Potential issue | 🟠 Major | ⚡ Quick winCustom dashboard ports can be falsely rejected during healthy-gateway reuse
Line 4163 still checks
port === DASHBOARD_PORT, so when--control-ui-portis set, preflight can fail even if that custom port is already owned by the healthy runtime.💡 Proposed fix
for (const { port, label, envVar } of requiredPorts) { let portCheck = await checkPortAvailable(port); if (!portCheck.ok) { - if ((port === GATEWAY_PORT || port === DASHBOARD_PORT) && gatewayReuseState === "healthy") { + const isRequestedDashboardPort = + dashboardPortToCheck !== null && port === dashboardPortToCheck; + if ((port === GATEWAY_PORT || isRequestedDashboardPort) && gatewayReuseState === "healthy") { console.log( ` ✓ Port ${port} already owned by healthy ${cliDisplayName()} runtime (${label})`, ); continue; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/lib/onboard.ts` around lines 4151 - 4164, The preflight check erroneously compares the numeric port to DASHBOARD_PORT, rejecting custom dashboard ports; update the conditional inside the requiredPorts loop (where checkPortAvailable is used) to detect the dashboard entry by its envVar instead of only DASHBOARD_PORT—e.g. replace the `(port === GATEWAY_PORT || port === DASHBOARD_PORT)` check with `(port === GATEWAY_PORT || envVar === "NEMOCLAW_DASHBOARD_PORT")` (or include both port === DASHBOARD_PORT || envVar === "NEMOCLAW_DASHBOARD_PORT") so custom `--control-ui-port` values (the port variable) are treated as the dashboard and can be reused when `gatewayReuseState === "healthy"`.
🧹 Nitpick comments (3)
test/hermes-provider-foundation.test.ts (1)
34-38: ⚡ Quick winMake spawned onboarding tests hermetic by scrubbing inherited messaging env.
These spawns currently inherit all of
process.env, so local/CIDISCORD_*orTELEGRAM_*values can leak into onboarding paths and destabilize assertions.Based on learnings: “For hermetic messaging-channel tests… delete/remove unrelated messaging env vars such as DISCORD_* and TELEGRAM_*.”Proposed hardening
+function buildHermeticEnv(tmpDir: string, extra: Record<string, string> = {}) { + const env: NodeJS.ProcessEnv = { ...process.env, HOME: tmpDir, ...extra }; + for (const key of Object.keys(env)) { + if (key.startsWith("DISCORD_") || key.startsWith("TELEGRAM_")) { + delete env[key]; + } + } + return env; +} ... - const result = spawnSync(process.execPath, [scriptPath], { + const result = spawnSync(process.execPath, [scriptPath], { cwd: repoRoot, encoding: "utf-8", - env: { ...process.env, HOME: tmpDir }, + env: buildHermeticEnv(tmpDir), });Also applies to: 74-80, 122-130
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/hermes-provider-foundation.test.ts` around lines 34 - 38, The spawnSync invocations in hermes-provider-foundation.test.ts currently pass a full copy of process.env (e.g. the spawnSync call that produces result) which can leak DISCORD_* or TELEGRAM_* values; create a sanitizedEnv by shallow-copying process.env, delete any keys that start with "DISCORD_" or "TELEGRAM_" (e.g. using Object.keys(safeEnv).forEach(k => { if (/^(DISCORD_|TELEGRAM_)/.test(k)) delete safeEnv[k]; })), then pass env: { ...safeEnv, HOME: tmpDir } to spawnSync; apply the same change to the other spawnSync sites in this test file so all onboarding tests are hermetic.src/lib/state/onboard-session.ts (1)
130-130: ⚡ Quick winConstrain
hermesAuthMethodto known values before persisting.Right now any string is accepted/read back. Restricting this to
"oauth"or"api_key"prevents invalid session state from silently propagating.Proposed tightening
+type HermesAuthMethod = "oauth" | "api_key"; ... - hermesAuthMethod: string | null; + hermesAuthMethod: HermesAuthMethod | null; ... - hermesAuthMethod?: string; + hermesAuthMethod?: HermesAuthMethod | null; ... +function readHermesAuthMethod(value: SessionJsonValue | undefined): HermesAuthMethod | null { + return value === "oauth" || value === "api_key" ? value : null; +} ... - hermesAuthMethod: readString(data.hermesAuthMethod), + hermesAuthMethod: readHermesAuthMethod(data.hermesAuthMethod), ... - if (typeof updates.hermesAuthMethod === "string") - safe.hermesAuthMethod = updates.hermesAuthMethod; + if (updates.hermesAuthMethod === "oauth" || updates.hermesAuthMethod === "api_key") { + safe.hermesAuthMethod = updates.hermesAuthMethod; + } else if (updates.hermesAuthMethod === null) { + safe.hermesAuthMethod = null; + }Also applies to: 314-315, 354-355, 716-717, 863-863
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/lib/state/onboard-session.ts` at line 130, Constrain the hermesAuthMethod field to a strict union and validate it before persisting/reading: change the hermesAuthMethod declaration on the OnboardSession shape to the union type "oauth" | "api_key" (or undefined), add a small type-guard/validation where the session is written (validateHermesAuthMethod(value): value is "oauth" | "api_key") and use it to reject or coerce any other string before saving, and likewise validate/normalize when loading the session (return undefined or a safe default for invalid values). Update all places that set or read hermesAuthMethod to use the new union type and guard (references: hermesAuthMethod, OnboardSession, and the read/write locations noted in the comment).src/lib/onboard.ts (1)
1583-1598: ⚡ Quick win
selectOnboardAgentacceptsresumeandcanPromptbut neither is used or forwardedLine 9996 passes
resumeandcanPrompttoselectOnboardAgent, but line 1592 only forwards{ agentFlag, session }toagentOnboard.resolveAgent. SinceresolveAgentonly accepts those two parameters, removeresumeandcanPromptfrom theselectOnboardAgentoptions to keep the contract accurate and avoid confusion.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/lib/onboard.ts` around lines 1583 - 1598, The selectOnboardAgent function signature and option type should be simplified to remove unused resume and canPrompt parameters: update the selectOnboardAgent declaration and its parameter type to only accept agentFlag and session, and ensure the call to agentOnboard.resolveAgent({ agentFlag, session }) remains unchanged; adjust any TypeScript types or JSDoc for selectOnboardAgent to reflect the new two-parameter contract so callers (e.g., the place passing resume/canPrompt) no longer expect those options.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/lib/actions/sandbox/rebuild.ts`:
- Around line 108-125: The auth-method detection can incorrectly default to
"oauth" when session?.hermesAuthMethod and state?.auth_method are missing even
though an exported NOUS_API_KEY exists; update the logic around
normalizeHermesRebuildAuthMethod(...) and
hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV so that the presence of
hydrateCredentialEnv(hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV)
forces the chosen authMethod to "api_key". Concretely, compute envKey =
hydrateCredentialEnv(...), then set authMethod = "api_key" if envKey is truthy
(even if normalizeHermesRebuildAuthMethod returned null/undefined), and keep the
existing branches that check api_key vs oauth (functions/variables to touch:
normalizeHermesRebuildAuthMethod,
hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV, hydrateCredentialEnv,
authMethod, credentialEnv, session?.hermesAuthMethod, state?.auth_method).
In `@src/lib/hermes-provider-auth.ts`:
- Around line 226-240: The registration currently ignores the negotiated base
URL and falls back to oauth.DEFAULT_INFERENCE_BASE_URL when persisting provider
info; update registerHermesInferenceProvider to pass the chosen baseUrl through
to the helper that persists API-key credentials, and modify
ensureHermesProviderApiKeyCredentials to accept a baseUrl parameter and use it
when calling upsertProvider (so HERMES_PROVIDER_NAME and the credential entry
are stored with the negotiated baseUrl rather than DEFAULT_INFERENCE_BASE_URL);
ensure all call sites of ensureHermesProviderApiKeyCredentials (including where
registerHermesInferenceProvider is invoked) are updated to forward the baseUrl.
- Line 1: Remove the top-level "// `@ts-nocheck`" and add explicit TypeScript
types across this CLI auth module: annotate all function signatures, parameter
and return types in exported functions, and type the imported symbols (e.g., the
oauth import, onboardProviders, and any credential/state objects) to match the
shapes used by the typed oauth-device-code.ts implementation; ensure the module
is type-checked under tsconfig.cli.json, add or reference appropriate
interfaces/types for device-code responses, token objects, and provider configs,
and update any untyped usages to satisfy the compiler.
In `@src/lib/inference/model-prompts.ts`:
- Around line 187-225: The topLevelLimit is ignored unless otherShowsFullList is
true because visibleOptions falls back to modelOptions; change visibleOptions to
always be modelOptions.slice(0, topLevelLimit) (use topLevelLimit computed
above) so the menu shows the top-N + "Other..." option regardless of
otherShowsFullList, adjust defaultChoice to treat the "Other..." entry
(visibleOptions.length + 1) as the fallback when defaultIndex is outside
visibleOptions regardless of shouldOfferFullList, and keep the branch that
handles index === visibleOptions.length but choose between
promptFullRemoteModelList and promptManualModelId based on shouldOfferFullList;
update references: topLevelLimit, shouldOfferFullList, visibleOptions,
defaultChoice, promptFullRemoteModelList, promptManualModelId, modelOptions,
options.otherShowsFullList.
In `@src/lib/oauth-device-code.ts`:
- Around line 210-218: The success path that parses TokenResponse only checks
for refresh_token; update the resp.status === 200 handling (the block that casts
to TokenResponse) to also validate that payload.access_token is present and
non-empty and throw an OAuthError (similar to the existing
"token_response_missing_refresh_token") when it's missing; ensure you reference
TokenResponse and reuse OAuthError for a clear error code/message so we never
return/persist a payload missing access_token.
---
Outside diff comments:
In `@src/lib/actions/sandbox/rebuild.ts`:
- Around line 242-260: The logic in sessionMatchesTarget currently treats a
missing session.sandboxName as a match, letting orphaned sessions supply
provider/credentialEnv; change sessionMatchesTarget to only be true when
session.sandboxName is present and equals sandboxName (i.e. session?.sandboxName
=== sandboxName). This will ensure
getRebuildCredentialEnvFromRegistry(sb.provider) is used when sandboxName is
unknown and that rebuildCredentialEnv and rebuildProvider (referenced by
rebuildCredentialEnv, sessionMatchesTarget, rebuildProvider,
getRebuildCredentialEnvFromRegistry) come from the registry unless the session
explicitly belongs to the target sandbox.
In `@src/lib/onboard.ts`:
- Around line 4151-4164: The preflight check erroneously compares the numeric
port to DASHBOARD_PORT, rejecting custom dashboard ports; update the conditional
inside the requiredPorts loop (where checkPortAvailable is used) to detect the
dashboard entry by its envVar instead of only DASHBOARD_PORT—e.g. replace the
`(port === GATEWAY_PORT || port === DASHBOARD_PORT)` check with `(port ===
GATEWAY_PORT || envVar === "NEMOCLAW_DASHBOARD_PORT")` (or include both port ===
DASHBOARD_PORT || envVar === "NEMOCLAW_DASHBOARD_PORT") so custom
`--control-ui-port` values (the port variable) are treated as the dashboard and
can be reused when `gatewayReuseState === "healthy"`.
---
Nitpick comments:
In `@src/lib/onboard.ts`:
- Around line 1583-1598: The selectOnboardAgent function signature and option
type should be simplified to remove unused resume and canPrompt parameters:
update the selectOnboardAgent declaration and its parameter type to only accept
agentFlag and session, and ensure the call to agentOnboard.resolveAgent({
agentFlag, session }) remains unchanged; adjust any TypeScript types or JSDoc
for selectOnboardAgent to reflect the new two-parameter contract so callers
(e.g., the place passing resume/canPrompt) no longer expect those options.
In `@src/lib/state/onboard-session.ts`:
- Line 130: Constrain the hermesAuthMethod field to a strict union and validate
it before persisting/reading: change the hermesAuthMethod declaration on the
OnboardSession shape to the union type "oauth" | "api_key" (or undefined), add a
small type-guard/validation where the session is written
(validateHermesAuthMethod(value): value is "oauth" | "api_key") and use it to
reject or coerce any other string before saving, and likewise validate/normalize
when loading the session (return undefined or a safe default for invalid
values). Update all places that set or read hermesAuthMethod to use the new
union type and guard (references: hermesAuthMethod, OnboardSession, and the
read/write locations noted in the comment).
In `@test/hermes-provider-foundation.test.ts`:
- Around line 34-38: The spawnSync invocations in
hermes-provider-foundation.test.ts currently pass a full copy of process.env
(e.g. the spawnSync call that produces result) which can leak DISCORD_* or
TELEGRAM_* values; create a sanitizedEnv by shallow-copying process.env, delete
any keys that start with "DISCORD_" or "TELEGRAM_" (e.g. using
Object.keys(safeEnv).forEach(k => { if (/^(DISCORD_|TELEGRAM_)/.test(k)) delete
safeEnv[k]; })), then pass env: { ...safeEnv, HOME: tmpDir } to spawnSync; apply
the same change to the other spawnSync sites in this test file so all onboarding
tests are hermetic.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: 5baaf36e-d898-4cb3-bb3a-92d88d5a8495
📒 Files selected for processing (26)
agents/hermes/config/messaging-config.tssrc/lib/actions/sandbox/rebuild.tssrc/lib/credentials/store.tssrc/lib/hermes-provider-auth.test.tssrc/lib/hermes-provider-auth.tssrc/lib/inference/config.test.tssrc/lib/inference/config.tssrc/lib/inference/model-prompts.test.tssrc/lib/inference/model-prompts.tssrc/lib/inference/nous-models.test.tssrc/lib/inference/nous-models.tssrc/lib/messaging-channel-config.test.tssrc/lib/oauth-device-code.test.tssrc/lib/oauth-device-code.tssrc/lib/onboard.tssrc/lib/onboard/providers.tssrc/lib/sandbox-channels.test.tssrc/lib/sandbox-channels.tssrc/lib/security/redact.tssrc/lib/state/onboard-session.tssrc/lib/state/sandbox-session.test.tssrc/lib/state/sandbox-session.tstest/credentials.test.tstest/generate-hermes-config.test.tstest/hermes-provider-foundation.test.tstest/rebuild-credential-preflight.test.ts
| 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; |
There was a problem hiding this comment.
Don't infer OAuth when NOUS_API_KEY is the only recovery material.
When session?.hermesAuthMethod and state?.auth_method are missing, this falls back to OAuth unless credentialEnv equals the Hermes API-key env. For Hermes rebuilds that value comes from the registry path and is OPENAI_API_KEY, so an exported NOUS_API_KEY is ignored and preflight fails even though the error text tells users to use it.
Suggested fix
function preflightHermesProviderCredentials(
sandboxName: string,
session: Session | null,
credentialEnv: string | null,
log: (msg: string) => void,
): boolean {
const state = hermesProviderAuth.loadHermesOAuthState(sandboxName);
+ const envKey = hydrateCredentialEnv(hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV);
const authMethod =
normalizeHermesRebuildAuthMethod(session?.hermesAuthMethod) ||
normalizeHermesRebuildAuthMethod(state?.auth_method) ||
+ (envKey ? "api_key" : null) ||
(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"}`,
);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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; | |
| const envKey = hydrateCredentialEnv(hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV); | |
| const authMethod = | |
| normalizeHermesRebuildAuthMethod(session?.hermesAuthMethod) || | |
| normalizeHermesRebuildAuthMethod(state?.auth_method) || | |
| (envKey ? "api_key" : null) || | |
| (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); | |
| 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; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/actions/sandbox/rebuild.ts` around lines 108 - 125, The auth-method
detection can incorrectly default to "oauth" when session?.hermesAuthMethod and
state?.auth_method are missing even though an exported NOUS_API_KEY exists;
update the logic around normalizeHermesRebuildAuthMethod(...) and
hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV so that the presence of
hydrateCredentialEnv(hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV)
forces the chosen authMethod to "api_key". Concretely, compute envKey =
hydrateCredentialEnv(...), then set authMethod = "api_key" if envKey is truthy
(even if normalizeHermesRebuildAuthMethod returned null/undefined), and keep the
existing branches that check api_key vs oauth (functions/variables to touch:
normalizeHermesRebuildAuthMethod,
hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV, hydrateCredentialEnv,
authMethod, credentialEnv, session?.hermesAuthMethod, state?.auth_method).
| @@ -0,0 +1,310 @@ | |||
| // @ts-nocheck | |||
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, confirm the file exists and check line 1
head -20 src/lib/hermes-provider-auth.tsRepository: NVIDIA/NemoClaw
Length of output: 804
🏁 Script executed:
# Check if tsconfig.cli.json exists and review its configuration
cat tsconfig.cli.jsonRepository: NVIDIA/NemoClaw
Length of output: 693
🏁 Script executed:
# Look for any type errors that might require the `@ts-nocheck` pragma
# We'll need to see how the project is set up for typechecking
ls -la | grep -E "tsconfig|package.json"Repository: NVIDIA/NemoClaw
Length of output: 264
🏁 Script executed:
wc -l src/lib/hermes-provider-auth.tsRepository: NVIDIA/NemoClaw
Length of output: 95
🏁 Script executed:
cat -n src/lib/hermes-provider-auth.tsRepository: NVIDIA/NemoClaw
Length of output: 10925
🏁 Script executed:
# Find other .ts files in src/lib and check if they use `@ts-nocheck` or are properly typed
find src/lib -name "*.ts" -type f | head -10Repository: NVIDIA/NemoClaw
Length of output: 394
🏁 Script executed:
# Check a few representative CLI files to see their typing patterns
rg -l "function|async function" src/lib/*.ts | head -5Repository: NVIDIA/NemoClaw
Length of output: 204
🏁 Script executed:
# Look for other `@ts-nocheck` pragmas in the codebase to understand the pattern
rg "@ts-nocheck" --type tsRepository: NVIDIA/NemoClaw
Length of output: 2430
🏁 Script executed:
# Check if oauth-device-code and other imported modules have type definitions
head -30 src/lib/oauth-device-code.ts 2>/dev/null || echo "File not found or error"Repository: NVIDIA/NemoClaw
Length of output: 1184
🏁 Script executed:
# Check the git history to see if this file was recently added
git log --oneline -n 20 src/lib/hermes-provider-auth.ts 2>/dev/null | head -5Repository: NVIDIA/NemoClaw
Length of output: 126
🏁 Script executed:
# Verify the coding guideline is correctly stated
grep -A 2 "Type-check CLI code" coding_guidelines.md 2>/dev/null || rg "Type-check CLI" --type mdRepository: NVIDIA/NemoClaw
Length of output: 281
Remove @ts-nocheck and add TypeScript type annotations to this CLI auth module.
This file bypasses the required tsconfig.cli.json typecheck (per coding guidelines: "Type-check CLI code using tsconfig.cli.json"). Untyped function parameters and module interactions in this security-sensitive credential path can introduce state-shape and type errors unnoticed.
Removing the pragma alone is insufficient; the file needs type annotations on all function signatures, parameter types, and return types (e.g., the oauth and onboardProviders imports return untyped values that should be explicitly shaped). The imported oauth-device-code.ts module demonstrates the codebase already has properly typed infrastructure to work from.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/hermes-provider-auth.ts` at line 1, Remove the top-level "//
`@ts-nocheck`" and add explicit TypeScript types across this CLI auth module:
annotate all function signatures, parameter and return types in exported
functions, and type the imported symbols (e.g., the oauth import,
onboardProviders, and any credential/state objects) to match the shapes used by
the typed oauth-device-code.ts implementation; ensure the module is type-checked
under tsconfig.cli.json, add or reference appropriate interfaces/types for
device-code responses, token objects, and provider configs, and update any
untyped usages to satisfy the compiler.
| 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, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Register the provider with the negotiated inference base URL.
Both call sites let registerHermesInferenceProvider() fall back to DEFAULT_INFERENCE_BASE_URL, so the endpoint saved in state is never used. That silently repoints sandbox or provider-specific Hermes setups back at production during registration.
Suggested fix
if (runOpenshell) {
- registerHermesInferenceProvider(state.agent_key, runOpenshell);
+ registerHermesInferenceProvider(
+ state.agent_key,
+ runOpenshell,
+ HERMES_INFERENCE_CREDENTIAL_ENV,
+ state.inference_base_url || oauth.DEFAULT_INFERENCE_BASE_URL,
+ );
}Apply the same idea to the API-key path by threading the intended base URL into ensureHermesProviderApiKeyCredentials().
Also applies to: 257-260, 286-292
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/hermes-provider-auth.ts` around lines 226 - 240, The registration
currently ignores the negotiated base URL and falls back to
oauth.DEFAULT_INFERENCE_BASE_URL when persisting provider info; update
registerHermesInferenceProvider to pass the chosen baseUrl through to the helper
that persists API-key credentials, and modify
ensureHermesProviderApiKeyCredentials to accept a baseUrl parameter and use it
when calling upsertProvider (so HERMES_PROVIDER_NAME and the credential entry
are stored with the negotiated baseUrl rather than DEFAULT_INFERENCE_BASE_URL);
ensure all call sites of ensureHermesProviderApiKeyCredentials (including where
registerHermesInferenceProvider is invoked) are updated to forward the baseUrl.
| 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); |
There was a problem hiding this comment.
topLevelModelLimit currently has no effect unless full-list mode is enabled.
visibleOptions falls back to the full model list whenever otherShowsFullList is false, so callers cannot get the documented “top N + Other...” menu with direct manual entry. This also makes hidden defaults resolve to the wrong default choice.
Suggested fix
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 hasHiddenOptions = topLevelLimit < modelOptions.length;
+ const shouldOfferFullList = options.otherShowsFullList === true && hasHiddenOptions;
+ const visibleOptions = hasHiddenOptions ? modelOptions.slice(0, topLevelLimit) : modelOptions;
const defaultChoice =
- shouldOfferFullList && defaultIndex >= visibleOptions.length
+ hasHiddenOptions && defaultIndex >= visibleOptions.length
? visibleOptions.length + 1
: Math.min(defaultIndex, Math.max(visibleOptions.length - 1, 0)) + 1;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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); | |
| const topLevelLimit = | |
| options.topLevelModelLimit && options.topLevelModelLimit > 0 | |
| ? Math.min(options.topLevelModelLimit, modelOptions.length) | |
| : modelOptions.length; | |
| const hasHiddenOptions = topLevelLimit < modelOptions.length; | |
| const shouldOfferFullList = options.otherShowsFullList === true && hasHiddenOptions; | |
| const visibleOptions = hasHiddenOptions | |
| ? modelOptions.slice(0, topLevelLimit) | |
| : modelOptions; | |
| const defaultChoice = | |
| hasHiddenOptions && 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); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/inference/model-prompts.ts` around lines 187 - 225, The topLevelLimit
is ignored unless otherShowsFullList is true because visibleOptions falls back
to modelOptions; change visibleOptions to always be modelOptions.slice(0,
topLevelLimit) (use topLevelLimit computed above) so the menu shows the top-N +
"Other..." option regardless of otherShowsFullList, adjust defaultChoice to
treat the "Other..." entry (visibleOptions.length + 1) as the fallback when
defaultIndex is outside visibleOptions regardless of shouldOfferFullList, and
keep the branch that handles index === visibleOptions.length but choose between
promptFullRemoteModelList and promptManualModelId based on shouldOfferFullList;
update references: topLevelLimit, shouldOfferFullList, visibleOptions,
defaultChoice, promptFullRemoteModelList, promptManualModelId, modelOptions,
options.otherShowsFullList.
| 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; |
There was a problem hiding this comment.
Validate access_token on the initial token exchange.
This success path only rejects a 200 response when refresh_token is missing. If the portal returns a partial body, we persist unusable OAuth state here and the later agent-key mint ends up sending Bearer undefined, which obscures the real failure.
Suggested fix
if (resp.status === 200) {
const payload = (await resp.json()) as TokenResponse;
- if (!payload.refresh_token) {
+ if (!payload.access_token || !payload.refresh_token) {
throw new OAuthError(
- "token_response_missing_refresh_token",
- "portal returned no refresh_token; cannot persist host-side auth state",
+ "token_response_missing_tokens",
+ "portal returned no access_token or refresh_token; cannot persist host-side auth state",
);
}
return payload;
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/oauth-device-code.ts` around lines 210 - 218, The success path that
parses TokenResponse only checks for refresh_token; update the resp.status ===
200 handling (the block that casts to TokenResponse) to also validate that
payload.access_token is present and non-empty and throw an OAuthError (similar
to the existing "token_response_missing_refresh_token") when it's missing;
ensure you reference TokenResponse and reuse OAuthError for a clear error
code/message so we never return/persist a payload missing access_token.
|
✨ Thanks for submitting this PR that adds the Hermes Provider onboarding foundation. This change involves adding a new provider, modifying the onboarding process, and updating the OpenShell registration. |
## Summary Replays the Hermes Provider onboarding foundation from partner PR #3237 in a branch based on current `main`, with the credential boundary adjusted to match NemoClaw/OpenShell security invariants. This keeps bare `nemoclaw onboard` on the existing OpenClaw default while allowing `nemohermes onboard` / `nemoclaw onboard --agent hermes` to select Hermes Provider, choose Nous Portal OAuth or Nous API Key, pick from Nous-recommended models with a full/custom fallback, and register Hermes inference through OpenShell. ## Security / Architecture - NemoClaw orchestrates Hermes/Nous auth, but does not durably persist Hermes OAuth/API-key material in host-side NemoClaw files. - Durable provider credentials are handed to OpenShell provider registration and resolved from OpenShell primitives. - OAuth is an in-memory authorization/minting step; the minted inference credential is registered into OpenShell, and refresh/access tokens are not written under `~/.nemoclaw`. - API-key onboarding uses `NOUS_API_KEY` / `NEMOCLAW_PROVIDER_KEY` from the current process and registers it into OpenShell; `NOUS_API_KEY` is intentionally not added to NemoClaw's legacy host credential store allowlist. - Rebuild preflight checks the target Hermes Provider registration in OpenShell and can register an exported `NOUS_API_KEY` before destructive rebuild work. - Session state stores only non-secret Hermes metadata such as `hermesAuthMethod`; invalid auth methods are rejected/normalized. ## Credit Based on the Hermes Provider foundation work from @shannonsands / NousResearch in #3237. The replay commit includes Shannon as co-author and retains Shannon's signed-off-by from the original contribution. ## Verification - `npm run build:cli` - `npm run typecheck:cli` - `git diff --cached --check` - `npx vitest run src/lib/hermes-provider-auth.test.ts src/lib/oauth-device-code.test.ts src/lib/inference/config.test.ts src/lib/inference/model-prompts.test.ts src/lib/inference/nous-models.test.ts src/lib/state/onboard-session.test.ts src/lib/messaging-channel-config.test.ts src/lib/sandbox-channels.test.ts src/lib/state/sandbox-session.test.ts test/hermes-provider-foundation.test.ts test/rebuild-credential-preflight.test.ts test/generate-hermes-config.test.ts` - `npx vitest run src/lib/oauth-device-code.test.ts` --- Signed-off-by: Shannon Sands <shannon.sands.1979@gmail.com> Signed-off-by: Aaron Erickson <aerickson@nvidia.com> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Hermes Provider added as an inference option with API-key and OAuth onboarding, curated model defaults, remote recommended-model fetching, and installer support for "hermes-provider" * Slack allowlist support via SLACK_ALLOWED_USERS env * **Bug Fixes** * Strip terminal color codes from status output * Redact NOUS_API_KEY in logs * **Tests** * Expanded coverage for Hermes auth, OAuth device flow, model selection, rebuild credential preflight, and onboarding flows * **Docs** * Documented Hermes auth env vars and aliases [](https://app.coderabbit.ai/change-stack/NVIDIA/NemoClaw/pull/3320) <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
|
Hi Shannon, thank you again for this PR and for the NousResearch partnership here. I wanted to leave the supersession trail explicitly on this thread so the attribution and review context are easy to follow. We merged #3320 as the maintained replay of this Hermes Provider onboarding foundation. Functionally, #3320 carries forward the core work from this PR: Hermes Provider selection for The main deltas in #3320 are around aligning the credential boundary with the current NemoClaw/OpenShell invariants and current Really appreciate the contribution and the partnership from you and the NousResearch team. This PR gave us the foundation for the landed implementation; #3320 is the version we merged only so we could carry it across current main and tighten the credential ownership boundary for the release. |
|
Closing this as superseded by the merged shared implementation in #3320, with thanks again to Shannon and the NousResearch team for the foundation and collaboration here. Please do let us know if there are further changes you would like based on the shared implementation now that #3320 has landed. We would be very happy to keep iterating with you from that merged base, whether as follow-up PRs, issues, or review notes on the parts of the provider flow that you think we should refine. |
Summary
Adds the cleaned Hermes Provider onboarding foundation directly against
main. This slice keeps managed-tool broker and Discord bridge work out of scope, supports host-managed Nous OAuth/API-key inference setup, and preserves barenemoclaw onboardas the OpenClaw default whilenemohermes onboard/nemoclaw onboard --agent hermesselects Hermes.Related Issue
Linear: NS-322, NS-324.
Changes
hermes-providerthrough OpenShell as OpenAI-compatible host-managed inference againsthttps://inference-api.nousresearch.com/v1, while sandbox config useshttps://inference.local/v1.OPENAI_API_KEY/ already-storedNOUS_API_KEYenv vars.Type of Change
Verification
npx prek run --all-filespassesnpm testpassesmake docsbuilds without warnings (doc changes only)Focused checks run locally:
npm run build:clipassesnpm run typecheck:clipassesgit diff --checkpassesnpx vitest run src/lib/inference/config.test.ts src/lib/inference/model-prompts.test.ts src/lib/inference/nous-models.test.ts test/hermes-provider-foundation.test.ts test/rebuild-credential-preflight.test.tspasses (48 tests)node -esmoke viadist/lib/inference/nous-modelsreturns the live Nous Portal top-10 recommendation orderEarlier focused PR set before the dynamic-model update:
npx vitest run test/rebuild-credential-preflight.test.ts src/lib/sandbox-channels.test.ts src/lib/messaging-channel-config.test.ts test/generate-hermes-config.test.ts test/hermes-provider-foundation.test.ts test/hermes-plugin-handlers.test.ts test/sandbox-init.test.ts test/recover-port-forward.test.ts src/lib/inference/config.test.ts src/lib/inference/model-prompts.test.ts src/lib/agent/runtime.test.ts src/lib/state/sandbox-session.test.ts src/lib/hermes-provider-auth.test.ts src/lib/oauth-device-code.test.ts test/process-recovery.test.ts src/lib/cli/oclif-pattern-discovery.test.ts src/lib/cli/oclif-metadata.test.ts src/lib/cli/public-oclif-help.test.ts test/shellquote-sandbox.test.ts test/snapshot-gateway-guard.test.ts test/uninstall.test.tspassed (189 tests)Broad hook status:
null, and shell helper timeouts. The branch was pushed with hook bypass for this draft PR after the focused checks passed.Signed-off-by: Shannon Sands shannon.sands.1979@gmail.com
Summary by CodeRabbit
Release Notes
New Features
Bug Fixes