Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions agents/hermes/config/messaging-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
100 changes: 99 additions & 1 deletion src/lib/actions/sandbox/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { providerName: string; credentialEnv: string | null }>;
Expand Down Expand Up @@ -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;
Comment on lines +108 to +125

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Suggested change
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).

}

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.
*
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/lib/credentials/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
170 changes: 170 additions & 0 deletions src/lib/hermes-provider-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> {
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<string, string> }> = [];
const state = await auth.ensureHermesProviderApiKeyCredentials(
"my-assistant",
{
apiKey: "nous-key-1",
runOpenshell: (
args: string[],
opts: { env?: Record<string, string> } = {},
) => {
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<string, string> } = {},
) => {
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 });
}
});
});
Loading