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
4 changes: 4 additions & 0 deletions agents/hermes/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/lib/nemoclaw/sandbox-init
# Build args for config that varies per deployment.
ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b
ARG NEMOCLAW_PROVIDER_KEY=custom
ARG NEMOCLAW_PRIMARY_MODEL_REF=inference/claude-opus-4-7
ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1
ARG NEMOCLAW_INFERENCE_API=openai-completions
# CHAT_UI_URL is a legacy name shared with the OpenClaw build arg. For
# Hermes this URL points at the OpenAI-compatible API server (port 8642,
# exposing /v1 and /health), NOT a browser chat UI. Callers authenticate
Expand All @@ -59,7 +61,9 @@ ARG NEMOCLAW_BUILD_ID=default
# Promote build-args to env vars for the config generation script.
ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \
NEMOCLAW_PROVIDER_KEY=${NEMOCLAW_PROVIDER_KEY} \
NEMOCLAW_PRIMARY_MODEL_REF=${NEMOCLAW_PRIMARY_MODEL_REF} \
NEMOCLAW_INFERENCE_BASE_URL=${NEMOCLAW_INFERENCE_BASE_URL} \
NEMOCLAW_INFERENCE_API=${NEMOCLAW_INFERENCE_API} \
CHAT_UI_URL=${CHAT_UI_URL} \
NEMOCLAW_MESSAGING_CHANNELS_B64=${NEMOCLAW_MESSAGING_CHANNELS_B64} \
NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${NEMOCLAW_MESSAGING_ALLOWED_IDS_B64}
Expand Down
77 changes: 75 additions & 2 deletions agents/hermes/generate-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,54 @@ const ALLOWED_USERS_ENV: Record<string, string> = {
slack: "SLACK_ALLOWED_USERS",
};

/**
* Per-provider API key env-var names. Hermes's provider adapters
* (anthropic_adapter.py, openai client, etc.) read these from the
* process environment before sending a request — they short-circuit
* with an "in-process credentials missing" error if the env var is
* empty, *even when* the actual outbound call is going to be
* substituted by OpenShell's L7 proxy.
*
* We satisfy the in-process check by writing an OpenShell resolve
* placeholder. The proxy rewrites the real header value at egress
* (verified empirically: any non-empty x-api-key works because the
* proxy overrides it). The placeholder string `openshell:resolve:env:X`
* is the same pattern OpenShell uses for messaging tokens.
*/
const PROVIDER_API_KEY_ENV: Record<string, string> = {
anthropic: "ANTHROPIC_API_KEY",
openai: "OPENAI_API_KEY",
// "custom"/"inference" providers don't have a fixed credential env var
// in Hermes — those flows expect either no key (handled by proxy) or
// a per-user override via .env. No emission needed by default.
};

/**
* Map NemoClaw's provider key (from getSandboxInferenceConfig in
* src/lib/onboard-providers.ts) to the Hermes-side provider value
* accepted in config.yaml's `model.provider` field.
*
* Hermes recognises a small set of provider names; "custom" means
* "generic OpenAI-compatible endpoint" which is the right behaviour
* for everything that isn't Anthropic-Messages-shaped.
*/
function mapProvider(providerKey: string): string {
switch (providerKey) {
case "anthropic":
return "anthropic";
case "openai":
return "openai";
case "inference":
case "custom":
default:
return "custom";
}
}

function main(): void {
const model = process.env.NEMOCLAW_MODEL!;
const baseUrl = process.env.NEMOCLAW_INFERENCE_BASE_URL!;
const providerKey = (process.env.NEMOCLAW_PROVIDER_KEY ?? "custom").trim();

const channelsB64 = process.env.NEMOCLAW_MESSAGING_CHANNELS_B64 || "W10=";
const allowedIdsB64 = process.env.NEMOCLAW_MESSAGING_ALLOWED_IDS_B64 || "e30=";
Expand All @@ -41,11 +86,30 @@ function main(): void {
Buffer.from(allowedIdsB64, "base64").toString("utf-8"),
);

// Map NemoClaw's provider key (set by onboard.ts via getSandboxInferenceConfig)
// to the Hermes-side provider name in config.yaml. The key values come from
// src/lib/onboard-providers.ts:
// "anthropic" — anthropic-prod / compatible-anthropic-endpoint
// "openai" — openai-api
// "inference" — gemini-api / nvidia-prod / nvidia-nim / compatible-endpoint
// (everything that ends up routed as OpenAI-compatible
// through the inference.local proxy)
// "custom" — legacy/fallback default; treated as OpenAI-compatible
//
// Hermes accepts these provider names natively (see hermes_cli/web_server.py):
// "anthropic" — Anthropic Messages API (POST /v1/messages)
// "openai" — OpenAI Chat Completions
// "custom" — generic OpenAI-compatible endpoint
//
// Without this mapping, Hermes would always speak OpenAI-format to the
// proxy, which fails for any Anthropic-routed sandbox.
const hermesProvider = mapProvider(providerKey);

const config: Record<string, unknown> = {
_config_version: 22,
model: {
default: model,
provider: "custom",
provider: hermesProvider,
base_url: baseUrl,
},
terminal: {
Expand Down Expand Up @@ -108,8 +172,17 @@ function main(): void {
writeFileSync(configPath, toYaml(config));
chmodSync(configPath, 0o600);

// Write .env — API server config and messaging token placeholders
// Write .env — API server config, provider credential placeholder
// (so Hermes's in-process credential checks pass; the OpenShell L7
// proxy substitutes the real value on egress), and messaging token
// placeholders.
const envLines: string[] = ["API_SERVER_PORT=18642", "API_SERVER_HOST=127.0.0.1"];

const providerCredEnv = PROVIDER_API_KEY_ENV[providerKey];
if (providerCredEnv) {
envLines.push(`${providerCredEnv}=openshell:resolve:env:${providerCredEnv}`);
}

for (const ch of msgChannels) {
if (ch in TOKEN_ENV) {
envLines.push(`${TOKEN_ENV[ch]}=openshell:resolve:env:${TOKEN_ENV[ch]}`);
Expand Down
27 changes: 27 additions & 0 deletions agents/hermes/policy-additions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,33 @@ process:
run_as_group: sandbox

network_policies:
# ── Managed inference — primary egress path for the agent ─────
# All Hermes inference traffic goes through inference.local, the
# OpenShell gateway's virtual provider router. OpenShell terminates
# TLS, swaps in the host-side credential for whichever provider
# was selected during onboard (Anthropic / OpenAI / Gemini /
# NVIDIA Endpoints / custom OpenAI-compatible / custom
# Anthropic-compatible / Ollama / vLLM / NIM), and forwards.
# This single policy entry covers every supported provider —
# Hermes never directly contacts a provider host while running
# under NemoClaw.
managed_inference:
name: managed_inference
endpoints:
- host: inference.local
port: 443
protocol: rest
enforcement: enforce
rules:
- allow: { method: GET, path: "/**" }
- allow: { method: POST, path: "/**" }
binaries:
- { path: /usr/local/bin/hermes }
- { path: /usr/bin/python3 }
- { path: /usr/bin/python3.11 }
- { path: /usr/bin/curl }
- { path: /usr/local/bin/curl }

claude_code:
name: claude_code
endpoints:
Expand Down
87 changes: 87 additions & 0 deletions test/hermes-generate-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,91 @@ describe("Hermes config generation", () => {
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});

// Provider mapping cases — NEMOCLAW_PROVIDER_KEY drives both the
// Hermes-side model.provider value and the credential placeholder
// emitted to .env.
type ProviderCase = {
label: string;
providerKey: string;
inferenceBaseUrl: string;
expectedProvider: string;
expectedEnvKeyLine: string | null;
forbiddenEnvKeys?: string[];
};

const providerCases: ProviderCase[] = [
{
label: "anthropic-prod (and compatible-anthropic-endpoint)",
providerKey: "anthropic",
inferenceBaseUrl: "https://inference.local",
expectedProvider: "anthropic",
expectedEnvKeyLine: "ANTHROPIC_API_KEY=openshell:resolve:env:ANTHROPIC_API_KEY",
forbiddenEnvKeys: ["OPENAI_API_KEY"],
},
{
label: "openai-api",
providerKey: "openai",
inferenceBaseUrl: "https://inference.local/v1",
expectedProvider: "openai",
expectedEnvKeyLine: "OPENAI_API_KEY=openshell:resolve:env:OPENAI_API_KEY",
forbiddenEnvKeys: ["ANTHROPIC_API_KEY"],
},
{
label: "inference (gemini / nvidia / compatible-endpoint)",
providerKey: "inference",
inferenceBaseUrl: "https://inference.local/v1",
expectedProvider: "custom",
expectedEnvKeyLine: null,
forbiddenEnvKeys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
},
{
label: "legacy/default custom",
providerKey: "custom",
inferenceBaseUrl: "https://inference.local/v1",
expectedProvider: "custom",
expectedEnvKeyLine: null,
forbiddenEnvKeys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
},
];

for (const tc of providerCases) {
it(`maps NEMOCLAW_PROVIDER_KEY=${tc.providerKey} (${tc.label})`, () => {
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-config-"));
const hermesDir = path.join(tmpHome, ".hermes");
fs.mkdirSync(hermesDir, { recursive: true });

try {
const result = spawnSync("node", ["--experimental-strip-types", SCRIPT], {
encoding: "utf8",
env: {
...process.env,
HOME: tmpHome,
NEMOCLAW_MODEL: "claude-opus-4-7",
NEMOCLAW_PROVIDER_KEY: tc.providerKey,
NEMOCLAW_INFERENCE_BASE_URL: tc.inferenceBaseUrl,
},
});

expect(result.status).toBe(0);

const configRaw = fs.readFileSync(path.join(hermesDir, "config.yaml"), "utf8");
const config = YAML.parse(configRaw) as {
model: { provider: string; default: string; base_url: string };
};
expect(config.model.provider).toBe(tc.expectedProvider);
expect(config.model.base_url).toBe(tc.inferenceBaseUrl);

const envRaw = fs.readFileSync(path.join(hermesDir, ".env"), "utf8");
if (tc.expectedEnvKeyLine) {
expect(envRaw).toContain(tc.expectedEnvKeyLine);
}
for (const forbidden of tc.forbiddenEnvKeys ?? []) {
expect(envRaw).not.toContain(`${forbidden}=`);
}
} finally {
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
}
});
Loading