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
2 changes: 2 additions & 0 deletions agents/hermes/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/lib/nemoclaw/sandbox-init
ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b
ARG NEMOCLAW_PROVIDER_KEY=custom
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 Down Expand Up @@ -99,6 +100,7 @@ ARG NEMOCLAW_DARWIN_VM_COMPAT=0
ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \
NEMOCLAW_PROVIDER_KEY=${NEMOCLAW_PROVIDER_KEY} \
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
14 changes: 8 additions & 6 deletions agents/hermes/Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ ENV DEBIAN_FRONTEND=noninteractive
# Calver tag v2026.5.16 = Hermes Agent v0.14.0.
ARG HERMES_VERSION=v2026.5.16
ARG HERMES_TARBALL_SHA256=c0a554050a50ee9a62f3fa5cd288a167ba5640c42d647d100cdea084b7294143
ARG HERMES_UV_EXTRAS="messaging web pty"
ARG HERMES_UV_EXTRAS="anthropic messaging web pty"
ARG UV_VERSION=0.11.8

RUN apt-get update && apt-get install -y --no-install-recommends \
Expand Down Expand Up @@ -157,11 +157,13 @@ RUN printf '%s\n' \

# Install Hermes Agent from the selected GitHub release.
# The image prebakes only the extras mapped to NemoClaw-supported onboarding
# integrations: messaging (Telegram, Discord, Slack, WeChat, WhatsApp),
# web (API health/UI runtime), and pty (optional browser TUI bridge).
# New Hermes integrations should be installed
# by the agent workflow when they are enabled rather than shipped in the
# base image by default.
# integrations: anthropic (native Anthropic Messages routing), messaging
# (Telegram, Discord, Slack, WeChat, WhatsApp), web (API health/UI runtime),
# and pty (optional browser TUI bridge). These extras are resolved from the
# selected Hermes release's uv.lock via `uv sync --frozen`, so dependency
# changes remain tied to HERMES_VERSION/HERMES_TARBALL_SHA256 review.
# New Hermes integrations should be installed by the agent workflow when they
# are enabled rather than shipped in the base image by default.
# Root Node dependencies provide Hermes browser tooling such as agent-browser.
RUN pip3 install --no-cache-dir --break-system-packages "uv==${UV_VERSION}"
RUN mkdir -p /opt/hermes \
Expand Down
34 changes: 28 additions & 6 deletions agents/hermes/config/hermes-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,38 @@ const API_SERVER_TOOLSETS = [
"audio",
];

function hermesApiMode(inferenceApi: string): string | null {
// Source of truth: the host-side inference selector and Dockerfile patcher
// only write the closed set below into NEMOCLAW_INFERENCE_API. Fail fast for
// any other non-empty value so host/sandbox routing contract drift does not
// silently fall back to Hermes' default OpenAI-compatible mode.
switch (inferenceApi) {
case "":
case "openai-completions":
return null;
case "anthropic-messages":
return "anthropic_messages";
case "openai-responses":
return "codex_responses";
default:
throw new Error(`Unsupported Hermes inference API: ${inferenceApi}`);
}
}

export function buildHermesConfig(settings: HermesBuildSettings): Record<string, unknown> {
const apiServerToolsets = [...API_SERVER_TOOLSETS];
const modelConfig: Record<string, unknown> = {
default: settings.model,
provider: "custom",
base_url: settings.baseUrl,
api_key: "sk-OPENSHELL-PROXY-REWRITE",
};
const apiMode = hermesApiMode(settings.inferenceApi);
if (apiMode) modelConfig.api_mode = apiMode;

const config: Record<string, unknown> = {
_config_version: 12,
model: {
default: settings.model,
provider: "custom",
base_url: settings.baseUrl,
api_key: "sk-OPENSHELL-PROXY-REWRITE",
},
model: modelConfig,
terminal: {
backend: "local",
timeout: 180,
Expand Down
2 changes: 2 additions & 0 deletions agents/hermes/policy-additions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ network_policies:
enforcement: enforce
rules:
- allow: { method: POST, path: "/v1/chat/completions" }
- allow: { method: POST, path: "/v1/messages" }
- allow: { method: POST, path: "/v1/responses" }
- allow: { method: POST, path: "/v1/completions" }
- allow: { method: POST, path: "/v1/embeddings" }
- allow: { method: GET, path: "/v1/models" }
Expand Down
4 changes: 2 additions & 2 deletions src/lib/actions/sandbox/policy-channel-conflict.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,8 +535,8 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {

// Scenario 11
it("slack two-token channel: matching SLACK_BOT_TOKEN hash is detected", async () => {
const slackBot = "xoxb-1234-5678-abcdef";
const slackApp = "xapp-1-A0000-12345-abcdef";
const slackBot = "test-slack-bot-token";
const slackApp = "test-slack-app-token";
const slackBotHash = hashCredential(slackBot) as string;
arrangeRegistry({
current: { name: "alpha", messagingChannels: [] },
Expand Down
37 changes: 37 additions & 0 deletions test/generate-hermes-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,43 @@ describe("agents/hermes/generate-config.ts", () => {
expect(envFile).toContain("API_SERVER_HOST=127.0.0.1\n");
});

it("regression #4230: configures Anthropic Messages routing for Hermes managed inference", () => {
const { config } = runConfigScript({
NEMOCLAW_PROVIDER_KEY: "anthropic",
NEMOCLAW_INFERENCE_BASE_URL: "https://inference.local",
NEMOCLAW_INFERENCE_API: "anthropic-messages",
});

expect(config.model).toEqual({
default: "test-model",
provider: "custom",
base_url: "https://inference.local",
api_key: "sk-OPENSHELL-PROXY-REWRITE",
api_mode: "anthropic_messages",
});
});

it("maps OpenAI Responses routing to Hermes' codex_responses api mode", () => {
const { config } = runConfigScript({
NEMOCLAW_INFERENCE_API: "openai-responses",
});

expect(config.model).toMatchObject({
api_mode: "codex_responses",
});
});

it("fails fast for unsupported Hermes inference API values", () => {
const result = runConfigScriptRaw({
NEMOCLAW_INFERENCE_API: "graphql",
});

expect(result.status).not.toBe(0);
expect(`${result.stderr}\n${result.stdout}`).toContain(
"Unsupported Hermes inference API: graphql",
);
});

it("emits a model.api_key placeholder that satisfies the LiteLLM sk- prefix gate", () => {
const { config } = runConfigScript();

Expand Down
42 changes: 42 additions & 0 deletions test/sandbox-provisioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,48 @@ describe("Hermes sandbox provisioning", () => {
expect(result.stdout).toContain("hermes manifest version");
});

function runHermesUvExtrasExpansion() {
const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8");
const extras = dockerfile.match(/^ARG HERMES_UV_EXTRAS="([^"]*)"$/m)?.[1];
if (!extras) {
throw new Error("Expected HERMES_UV_EXTRAS ARG in Hermes base Dockerfile");
}
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-uv-extras-"));
const command = [
"set -euo pipefail",
`HERMES_UV_EXTRAS=${JSON.stringify(extras)}`,
"set --",
'for extra in ${HERMES_UV_EXTRAS}; do set -- "$@" --extra "$extra"; done',
'printf "%s\\n" "$@"',
].join("\n");
const result = spawnSync("bash", ["-c", command], {
encoding: "utf-8",
cwd: tmp,
timeout: 5000,
});
return { result, tmp };
}

it("regression #4230: installs Hermes' native Anthropic provider dependency", () => {
const { result, tmp } = runHermesUvExtrasExpansion();
try {
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout.trim().split(/\n/)).toEqual([
"--extra",
"anthropic",
"--extra",
"messaging",
"--extra",
"web",
"--extra",
"pty",
]);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});

it("final image rejects a hermes binary from a different PATH location", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-wrong-path-"));
const wrongBin = path.join(tmp, "bin");
Expand Down
42 changes: 42 additions & 0 deletions test/validate-blueprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ const PERMISSIVE_POLICY_PATH = new URL(
"../nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml",
import.meta.url,
);
const HERMES_POLICY_PATH = new URL(
"../agents/hermes/policy-additions.yaml",
import.meta.url,
);
const REQUIRED_PROFILE_FIELDS: ReadonlyArray<keyof BlueprintProfile> = [
"provider_type",
"endpoint",
Expand Down Expand Up @@ -510,6 +514,44 @@ describe("permissive sandbox policy", () => {
});
});

describe("Hermes sandbox policy", () => {
const policy = loadYaml<SandboxPolicy>(HERMES_POLICY_PATH);

function expectManagedInferenceSecurityShape(): void {
const np = policy.network_policies ?? {};
const managedInference = np.managed_inference;
expect(managedInference?.name).toBe("managed_inference");
expect(managedInference?.binaries?.map((b) => b.path)).toEqual([
"/usr/local/bin/hermes",
"/usr/bin/python3.11",
"/opt/hermes/.venv/bin/python",
]);

const endpoints = managedInference?.endpoints ?? [];
expect(endpoints).toHaveLength(1);
expect(endpoints[0]).toMatchObject({
host: "inference.local",
port: 443,
protocol: "rest",
enforcement: "enforce",
});
expect(endpoints[0].access).toBeUndefined();
expect(endpoints[0].rules).toEqual([
{ allow: { method: "POST", path: "/v1/chat/completions" } },
{ allow: { method: "POST", path: "/v1/messages" } },
{ allow: { method: "POST", path: "/v1/responses" } },
{ allow: { method: "POST", path: "/v1/completions" } },
{ allow: { method: "POST", path: "/v1/embeddings" } },
{ allow: { method: "GET", path: "/v1/models" } },
{ allow: { method: "GET", path: "/v1/models/**" } },
]);
}

it("regression #4230: managed_inference keeps a narrow inference API allowlist", () => {
expectManagedInferenceSecurityShape();
});
});

describe("github preset", () => {
// The fix for #1583 was *only* meaningful if the github preset
// actually exists and is loadable — otherwise users have no way to
Expand Down
Loading