Skip to content
Draft
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;
}

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
Loading