Skip to content
Merged
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 @@ -41,6 +41,9 @@ export function buildMessagingEnvLines(
if (allowedIds.telegram?.length) {
envLines.push(`TELEGRAM_ALLOWED_USERS=${allowedIds.telegram.map(String).join(",")}`);
}
if (allowedIds.slack?.length) {
envLines.push(`SLACK_ALLOWED_USERS=${allowedIds.slack.map(String).join(",")}`);
}

return envLines;
}
Expand Down
2 changes: 2 additions & 0 deletions agents/hermes/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ inference:
base_url_config_key: "model.base_url"
model_config_key: "model.default"
proxy_support: implicit # via httpx (OpenAI SDK dep)
provider_options:
- hermesProvider

# ── Phone-home hosts ───────────────────────────────────────────
# Agent-specific egress endpoints needed for updates, auth, etc.
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -1035,6 +1035,9 @@ Set them before running `nemoclaw onboard`.
| Variable | Format | Effect |
|----------|--------|--------|
| `NEMOCLAW_PROVIDER` | provider key (e.g. `nvidia`, `openai`, `anthropic`, `ollama`, `vllm`, `compatible`) | Selects the inference provider in non-interactive onboarding. Must match one of the keys the wizard would prompt for. |
| `NEMOCLAW_HERMES_AUTH_METHOD` | `oauth` | Selects Hermes Provider authentication in non-interactive onboarding. Valid values: `oauth`, `nous-portal-oauth`, `api-key`, `nous-api-key`. |
| `NEMOCLAW_HERMES_AUTH` | same as `NEMOCLAW_HERMES_AUTH_METHOD` | Back-compatible alias for Hermes Provider authentication selection. |
| `NEMOCLAW_NOUS_AUTH_METHOD` | same as `NEMOCLAW_HERMES_AUTH_METHOD` | Nous-specific alias for Hermes Provider authentication selection. |
| `NEMOCLAW_ENDPOINT_URL` | URL | Custom OpenAI-compatible endpoint URL. Used together with `NEMOCLAW_PROVIDER=compatible`. |
| `NEMOCLAW_PREFERRED_API` | `completions` (currently the only honored value) | Forces the validation probe to use the `/v1/chat/completions` API path instead of the newer `/v1/responses` API. |
| `NEMOCLAW_INFERENCE_INPUTS` | comma-separated list of `text` and/or `image` | Declares model input modalities for vision-capable models. Validated strictly; unknown tokens are ignored. |
Expand Down
1 change: 1 addition & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ bootstrap_usage() {
printf " NEMOCLAW_SANDBOX_NAME Sandbox name to create/use\n"
printf " NEMOCLAW_PROVIDER build | openai | anthropic | anthropicCompatible\n"
printf " | gemini | ollama | custom | nim-local | vllm | routed\n"
printf " | hermes-provider\n"
printf " (aliases: cloud -> build, nim -> nim-local)\n"
printf " NEMOCLAW_POLICY_MODE suggested | custom | skip\n"
printf "\n"
Expand Down
1 change: 1 addition & 0 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,7 @@ usage() {
printf " NEMOCLAW_INSTALL_TAG Git ref to install (default: latest release)\n"
printf " NEMOCLAW_PROVIDER build | openai | anthropic | anthropicCompatible\n"
printf " | gemini | ollama | custom | nim-local | vllm | routed\n"
printf " | hermes-provider\n"
printf " (aliases: cloud -> build, nim -> nim-local)\n"
printf " NEMOCLAW_MODEL Inference model to configure\n"
printf " NEMOCLAW_POLICY_MODE suggested | custom | skip\n"
Expand Down
1 change: 1 addition & 0 deletions src/lib/actions/inference-set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function baseSession(overrides: Partial<Session> = {}): Session {
model: "moonshotai/kimi-k2.6",
endpointUrl: "https://inference.local/v1",
credentialEnv: "OPENAI_API_KEY",
hermesAuthMethod: null,
preferredInferenceApi: null,
nimContainer: null,
routerPid: null,
Expand Down
125 changes: 117 additions & 8 deletions src/lib/actions/sandbox/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ import {
const { hydrateCredentialEnv } = require("../../onboard") as {
hydrateCredentialEnv: (name: string) => string | null;
};
const hermesProviderAuth = require("../../hermes-provider-auth") as {
HERMES_PROVIDER_NAME: string;
HERMES_NOUS_API_KEY_CREDENTIAL_ENV: string;
isHermesProviderRegistered: (runOpenshellFn: typeof runOpenshell) => boolean;
registerHermesInferenceProvider: (
apiKey: string,
runOpenshellFn: typeof runOpenshell,
credentialEnv?: string,
baseUrl?: string,
) => void;
};
const { LOCAL_INFERENCE_PROVIDERS, REMOTE_PROVIDER_CONFIG } = require("../../onboard/providers") as {
LOCAL_INFERENCE_PROVIDERS: string[];
REMOTE_PROVIDER_CONFIG: Record<string, { providerName: string; credentialEnv: string | null }>;
Expand Down Expand Up @@ -61,6 +72,84 @@ function getRebuildCredentialEnvFromRegistry(provider: string | null | undefined
return remoteConfig?.credentialEnv || null;
}

function normalizeHermesRebuildAuthMethod(value: unknown): "oauth" | "api_key" | null {
const normalized = String(value || "")
.trim()
.toLowerCase()
.replace(/[\s-]+/g, "_");
if (!normalized) return null;
if (normalized === "oauth" || normalized === "nous_oauth" || normalized === "nous_portal_oauth") {
return "oauth";
}
if (
normalized === "api" ||
normalized === "key" ||
normalized === "api_key" ||
normalized === "apikey" ||
normalized === "nous_api_key"
) {
return "api_key";
}
return null;
}

function nonEmptyString(value: unknown): string | null {
const normalized = String(value || "").trim();
return normalized || null;
}

function preflightHermesProviderCredentials(
session: Session | null,
credentialEnv: string | null,
log: (msg: string) => void,
): boolean {
const authMethod =
normalizeHermesRebuildAuthMethod(session?.hermesAuthMethod) ||
(credentialEnv === hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV ? "api_key" : null);

if (hermesProviderAuth.isHermesProviderRegistered(runOpenshell)) {
log("Hermes Provider rebuild preflight: provider is registered in OpenShell");
return true;
}

if (authMethod === "api_key") {
const envKey =
nonEmptyString(process.env[hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV]) ||
nonEmptyString(process.env.NEMOCLAW_PROVIDER_KEY);
log(
`Hermes Provider rebuild preflight: OpenShell provider missing; ${hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV} env=${envKey ? "present" : "missing"}`,
);
if (envKey) {
try {
hermesProviderAuth.registerHermesInferenceProvider(
envKey,
runOpenshell,
hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV,
);
return true;
} catch (err) {
log(
`Hermes Provider rebuild preflight: failed to register OpenShell provider: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}

console.error("");
console.error(` ${_RD}Rebuild preflight failed:${R} Hermes Provider is not registered in OpenShell.`);
console.error(" Hermes Provider credentials must be stored in OpenShell, not host-side files.");
if (authMethod === "api_key") {
console.error(
` Export ${hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV} and rerun rebuild, or re-run ${CLI_NAME} onboard to register it.`,
);
} else {
console.error(` Re-run ${CLI_NAME} onboard interactively to authorize Hermes Provider and register it with OpenShell.`);
}
console.error("");
console.error(" Sandbox is untouched — no data was lost.");
return false;
}

/**
* Rebuild a live sandbox while preserving registered agent state and policies.
*
Expand Down Expand Up @@ -160,23 +249,27 @@ export async function rebuildSandbox(
// credential when onboard runs in non-interactive mode. Checking now
// lets us abort with the sandbox still intact. See #2273.
const session = onboardSession.loadSession();
const sessionMatchesTarget = session?.sandboxName === sandboxName;
let rebuildCredentialEnv: string | null = null;
if (session && session.sandboxName && session.sandboxName !== sandboxName) {
if (!sessionMatchesTarget) {
// Session belongs to a different sandbox — its credentialEnv may be
// wrong (e.g. hermes session while rebuilding openclaw). Resolve the
// target sandbox provider from the registry instead so destructive
// operations still get a credential preflight for the sandbox being rebuilt.
rebuildCredentialEnv = getRebuildCredentialEnvFromRegistry(sb.provider);
log(
`Preflight warning: session belongs to '${session.sandboxName}', not '${sandboxName}' — using registry credential env ${rebuildCredentialEnv || "(none)"}`,
);
console.log(
` ${D}Note: onboard session belongs to '${session.sandboxName}', not '${sandboxName}'. ` +
`Using the '${sandboxName}' registry entry for credential preflight.${R}`,
);
if (session?.sandboxName) {
log(
`Preflight warning: session belongs to '${session.sandboxName}', not '${sandboxName}' — using registry credential env ${rebuildCredentialEnv || "(none)"}`,
);
console.log(
` ${D}Note: onboard session belongs to '${session.sandboxName}', not '${sandboxName}'. ` +
`Using the '${sandboxName}' registry entry for credential preflight.${R}`,
);
}
} else {
rebuildCredentialEnv = session?.credentialEnv || null;
}
const rebuildProvider = sessionMatchesTarget ? session?.provider || sb.provider : sb.provider;
// Legacy migration: pre-fix local-inference sandboxes (GH #2519, GH #2625)
// recorded credentialEnv="OPENAI_API_KEY" in onboard-session.json even
// though the sandbox does not actually need a host OpenAI key (ollama-local
Expand All @@ -201,6 +294,22 @@ export async function rebuildSandbox(
);
rebuildCredentialEnv = null;
}
if (rebuildProvider === hermesProviderAuth.HERMES_PROVIDER_NAME) {
if (
!preflightHermesProviderCredentials(
sessionMatchesTarget ? session : null,
rebuildCredentialEnv,
log,
)
) {
bail("Missing Hermes Provider credentials");
return;
}
// Hermes Provider credentials belong to OpenShell provider storage. Do not
// fall through to the generic env-var preflight, which would incorrectly
// demand OPENAI_API_KEY/NOUS_API_KEY after the provider is registered.
rebuildCredentialEnv = null;
}
if (rebuildCredentialEnv) {
// hydrateCredentialEnv migrates any pre-fix legacy credentials.json
// into process.env once, so users upgrading from a release that wrote
Expand Down
1 change: 1 addition & 0 deletions src/lib/agent/base-image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function makeAgent(overrides: Partial<AgentDefinition> = {}): AgentDefinition {
envFile: ".env",
format: "yaml",
},
inferenceProviderOptions: [],
stateDirs: [],
stateFiles: [],
versionCommand: "hermes --version",
Expand Down
33 changes: 33 additions & 0 deletions src/lib/agent/defs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ describe("agent definitions", () => {
envFile: ".env",
format: "yaml",
});
expect(hermes.inferenceProviderOptions).toEqual(["hermesProvider"]);
expect(hermes.healthProbe.url).toBe("http://localhost:8642/health");
expect(hermes.messagingPlatforms).toEqual(["telegram", "discord", "slack"]);
});
Expand Down Expand Up @@ -117,4 +118,36 @@ describe("agent definitions", () => {

expect(() => loadAgent(agentName)).toThrow(/health_probe\.port/);
});

it("rejects invalid inference provider options in manifests", () => {
const agentName = `invalid-inference-options-${String(Date.now())}`;
writeTempAgentManifest(
agentName,
[
`name: ${agentName}`,
"display_name: Broken Inference",
"inference:",
" provider_options:",
" - hermesProvider",
" - 42",
].join("\n"),
);

expect(() => loadAgent(agentName)).toThrow(/inference\.provider_options/);
});

it("rejects invalid inference provider type in manifests", () => {
const agentName = `invalid-inference-provider-type-${String(Date.now())}`;
writeTempAgentManifest(
agentName,
[
`name: ${agentName}`,
"display_name: Broken Inference Type",
"inference:",
" provider_type: 42",
].join("\n"),
);

expect(() => loadAgent(agentName)).toThrow(/inference\.provider_type/);
});
});
42 changes: 42 additions & 0 deletions src/lib/agent/defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export interface AgentDashboard {
path: string;
}

export interface AgentInference {
provider_type?: string;
provider_options?: string[];
}

export interface AgentLegacyPaths {
dockerfileBase: string | null;
dockerfile: string | null;
Expand All @@ -68,6 +73,7 @@ export interface AgentDefinition {
forward_ports?: number[];
health_probe?: AgentHealthProbe;
config?: ManifestRecord;
inference?: AgentInference;
state_dirs?: string[];
state_files?: AgentStateFile[];
messaging_platforms?: { supported?: string[] };
Expand All @@ -79,6 +85,7 @@ export interface AgentDefinition {
readonly forwardPort: number;
readonly dashboard: AgentDashboard;
readonly configPaths: AgentConfigPaths;
readonly inferenceProviderOptions: string[];
readonly stateDirs: string[];
readonly stateFiles: AgentStateFile[];
readonly versionCommand: string;
Expand Down Expand Up @@ -252,6 +259,35 @@ function readMessagingPlatforms(record: ManifestRecord): { supported?: string[]
return supported ? { supported } : {};
}

function readInference(record: ManifestRecord): AgentInference | undefined {
const inference = readObject(record, "inference");
if (!inference) return undefined;

const providerType = inference.provider_type;
if (providerType !== undefined && typeof providerType !== "string") {
throw new Error("Agent manifest field 'inference.provider_type' must be a string");
}

const providerOptions = inference.provider_options;
let providerOptionList: string[] | undefined;
if (providerOptions !== undefined) {
if (
!Array.isArray(providerOptions) ||
providerOptions.some((entry) => typeof entry !== "string")
) {
throw new Error(
"Agent manifest field 'inference.provider_options' must be an array of strings",
);
}
providerOptionList = providerOptions as string[];
}

return {
provider_type: providerType,
provider_options: providerOptionList,
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function loadManifestRecord(manifestPath: string): ManifestRecord {
const parsed = yaml.load(fs.readFileSync(manifestPath, "utf8"));
if (!isManifestRecord(parsed)) {
Expand Down Expand Up @@ -298,6 +334,7 @@ export function loadAgent(name: string): AgentDefinition {
const forwardPorts = readPortArray(raw, "forward_ports");
const healthProbe = readHealthProbe(raw);
const config = readObject(raw, "config");
const inference = readInference(raw);
const stateDirs = readStringArray(raw, "state_dirs");
const stateFiles = readStateFiles(raw);
const phoneHomeHosts = readStringArray(raw, "phone_home_hosts");
Expand All @@ -318,6 +355,7 @@ export function loadAgent(name: string): AgentDefinition {
forward_ports: forwardPorts,
health_probe: healthProbe,
config,
inference,
state_dirs: stateDirs,
state_files: stateFiles,
messaging_platforms: messagingPlatforms,
Expand Down Expand Up @@ -366,6 +404,10 @@ export function loadAgent(name: string): AgentDefinition {
};
},

get inferenceProviderOptions(): string[] {
return inference?.provider_options ?? [];
},

get stateDirs(): string[] {
return stateDirs ?? [];
},
Expand Down
1 change: 1 addition & 0 deletions src/lib/agent/onboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function makeAgent(overrides: Partial<AgentDefinition> = {}): AgentDefinition {
envFile: null,
format: "yaml",
},
inferenceProviderOptions: [],
stateDirs: [],
stateFiles: [],
versionCommand: "agent --version",
Expand Down
1 change: 1 addition & 0 deletions src/lib/agent/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function makeAgent(overrides: Partial<AgentDefinition> = {}): AgentDefinition {
envFile: null,
format: "yaml",
},
inferenceProviderOptions: [],
stateDirs: [],
stateFiles: [],
versionCommand: "test-agent --version",
Expand Down
Loading
Loading