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
19 changes: 19 additions & 0 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,25 @@ reviews:
gh workflow run nightly-e2e.yaml --ref <branch> -f jobs=messaging-compatible-endpoint-e2e
```

- path: "test/e2e/test-kimi-inference-compat.sh"
instructions: &e2e-kimi-inference-compat |
This script validates Kimi K2.6 managed inference.local
compatibility with a hermetic OpenAI-compatible mock endpoint.
Changes here affect OpenClaw tool-call splitting and trajectory
proof for the Kimi compatibility path.

**E2E test recommendation:**
- `kimi-inference-compat-e2e` — Kimi managed inference.local
compatibility and safe exec splitting

To run selectively:
```
gh workflow run nightly-e2e.yaml --ref <branch> -f jobs=kimi-inference-compat-e2e
```

- path: "nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/**"
instructions: *e2e-kimi-inference-compat

- path: ".github/workflows/nightly-e2e.yaml"
instructions: |
This is the nightly E2E workflow definition. Changes here affect
Expand Down
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ This repo ships agent skills under `.agents/skills/`, organized into three audie
| `nemoclaw/src/commands/` | TypeScript | Slash commands, migration state |
| `nemoclaw/src/onboard/` | TypeScript | Onboarding config |
| `nemoclaw-blueprint/` | YAML | Blueprint definition and network policies |
| `nemoclaw-blueprint/model-specific-setup/` | JSON | Agent-scoped model/provider compatibility registry |
| `scripts/` | Bash/JS/TS | Install helpers, setup, automation, E2E tooling |
| `test/` | JavaScript (ESM) | Root-level integration tests (Vitest) |
| `test/e2e/` | Bash/JS | End-to-end tests (Brev cloud instances) |
Expand Down Expand Up @@ -166,6 +167,15 @@ All hooks managed by [prek](https://prek.j178.dev/) (installed via `npm install`
- Add YAML to `nemoclaw-blueprint/policies/presets/`
- Follow existing preset structure (see `slack.yaml`, `discord.yaml`)

**Adding model-specific sandbox compatibility:**

- Add a declarative manifest under `nemoclaw-blueprint/model-specific-setup/<agent>/`
- Use one exact `agent` per manifest (`openclaw`, `hermes`, etc.); do not make shared multi-agent manifests
- Put OpenClaw executable wrappers under `nemoclaw-blueprint/openclaw-plugins/`
- Put Hermes executable wrappers under `agents/hermes/`
- Keep `agents/hermes/generate-config.ts` as a thin build-time entrypoint; add Hermes env parsing, config construction, registry handling, and serialization under `agents/hermes/config/`
- Do not add Hermes behavior for an OpenClaw issue without a Hermes-specific repro or acceptance test

### Gotchas

- `npm install` at root triggers `prek install` which sets up git hooks. If hooks fail, check that `core.hooksPath` is unset: `git config --unset core.hooksPath`
Expand Down
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -230,15 +230,15 @@ COPY scripts/nemoclaw-start.sh /usr/local/bin/nemoclaw-start
COPY nemoclaw-blueprint/scripts/ws-proxy-fix.js /usr/local/lib/nemoclaw/ws-proxy-fix.js
COPY scripts/codex-acp-wrapper.sh /usr/local/bin/nemoclaw-codex-acp
COPY scripts/generate-openclaw-config.py /usr/local/lib/nemoclaw/generate-openclaw-config.py
COPY nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/ /usr/local/share/nemoclaw/openclaw-plugins/kimi-inference-compat/
COPY nemoclaw-blueprint/openclaw-plugins/ /usr/local/share/nemoclaw/openclaw-plugins/
RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-codex-acp \
/usr/local/lib/nemoclaw/sandbox-init.sh \
/usr/local/lib/nemoclaw/generate-openclaw-config.py \
&& chmod 644 /usr/local/lib/nemoclaw/ws-proxy-fix.js \
&& chmod 755 /usr/local/share/nemoclaw \
/usr/local/share/nemoclaw/openclaw-plugins \
/usr/local/share/nemoclaw/openclaw-plugins/kimi-inference-compat \
&& chmod 644 /usr/local/share/nemoclaw/openclaw-plugins/kimi-inference-compat/*
&& find /usr/local/share/nemoclaw/openclaw-plugins -type d -exec chmod 755 {} + \
&& find /usr/local/share/nemoclaw/openclaw-plugins -type f -exec chmod 644 {} +

# Build args for config that varies per deployment.
# nemoclaw onboard passes these at image build time.
Expand Down
10 changes: 6 additions & 4 deletions agents/hermes/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ ENV HERMES_TELEGRAM_DISABLE_FALLBACK_IPS=1
COPY agents/hermes/plugin/ /opt/nemoclaw-hermes-plugin/
RUN chmod -R a+rX /opt/nemoclaw-hermes-plugin/

# Copy config generation script and URL-decode proxy
COPY agents/hermes/generate-config.ts /opt/nemoclaw-generate-config.ts
RUN chmod 444 /opt/nemoclaw-generate-config.ts
# Copy config generator and URL-decode proxy
COPY agents/hermes/generate-config.ts /opt/nemoclaw-hermes-config/generate-config.ts
COPY agents/hermes/config/ /opt/nemoclaw-hermes-config/config/
RUN find /opt/nemoclaw-hermes-config -type d -exec chmod 755 {} + \
&& find /opt/nemoclaw-hermes-config -type f -exec chmod 444 {} +
COPY agents/hermes/decode-proxy.py /usr/local/bin/nemoclaw-decode-proxy
RUN chmod 755 /usr/local/bin/nemoclaw-decode-proxy

Expand Down Expand Up @@ -92,7 +94,7 @@ RUN mkdir -p /sandbox/.nemoclaw/blueprints/0.1.0 \
# via `shields up`. .env holds API key placeholders for OpenShell provider pipeline.
# SECURITY: Uses a separate script file instead of inline code to avoid
# code injection via build-arg interpolation (same concern as OpenClaw C-2).
RUN node --experimental-strip-types /opt/nemoclaw-generate-config.ts
RUN node --experimental-strip-types /opt/nemoclaw-hermes-config/generate-config.ts

# Install NemoClaw plugin into Hermes
RUN mkdir -p /sandbox/.hermes/plugins/nemoclaw \
Expand Down
72 changes: 72 additions & 0 deletions agents/hermes/config/build-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { Buffer } from "node:buffer";

export type MessagingAllowedIds = Record<string, (string | number)[]>;

export type DiscordGuilds = Record<
string,
{
requireMention?: boolean;
users?: (string | number)[];
}
>;

export type TelegramConfig = {
requireMention?: boolean;
};

export type HermesBuildSettings = {
model: string;
baseUrl: string;
providerKey: string;
inferenceApi: string;
messaging: {
enabledChannels: Set<string>;
allowedIds: MessagingAllowedIds;
discordGuilds: DiscordGuilds;
telegramConfig: TelegramConfig;
};
};

export function readHermesBuildSettings(env: NodeJS.ProcessEnv): HermesBuildSettings {
const model = readRequiredEnv(env, "NEMOCLAW_MODEL");
const baseUrl = readRequiredEnv(env, "NEMOCLAW_INFERENCE_BASE_URL");

return {
model,
baseUrl,
providerKey: env.NEMOCLAW_PROVIDER_KEY || "custom",
inferenceApi: env.NEMOCLAW_INFERENCE_API || "",
messaging: {
enabledChannels: new Set(
readBase64Json<string[]>(env, "NEMOCLAW_MESSAGING_CHANNELS_B64", "W10="),
),
allowedIds: readBase64Json<MessagingAllowedIds>(
env,
"NEMOCLAW_MESSAGING_ALLOWED_IDS_B64",
"e30=",
),
discordGuilds: readBase64Json<DiscordGuilds>(env, "NEMOCLAW_DISCORD_GUILDS_B64", "e30="),
telegramConfig: readBase64Json<TelegramConfig>(
env,
"NEMOCLAW_TELEGRAM_CONFIG_B64",
"e30=",
),
},
};
}

function readRequiredEnv(env: NodeJS.ProcessEnv, name: string): string {
const value = env[name];
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}

function readBase64Json<T>(env: NodeJS.ProcessEnv, name: string, defaultValue: string): T {
const encoded = env[name] || defaultValue;
return JSON.parse(Buffer.from(encoded, "base64").toString("utf-8")) as T;
}
67 changes: 67 additions & 0 deletions agents/hermes/config/hermes-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { HermesBuildSettings } from "./build-env.ts";
import { buildDiscordConfig } from "./messaging-config.ts";

export function buildHermesConfig(settings: HermesBuildSettings): Record<string, unknown> {
const config: Record<string, unknown> = {
_config_version: 12,
model: {
default: settings.model,
provider: "custom",
base_url: settings.baseUrl,
},
terminal: {
backend: "local",
timeout: 180,
},
agent: {
max_turns: 60,
reasoning_effort: "medium",
},
memory: {
memory_enabled: true,
user_profile_enabled: true,
},
skills: {
creation_nudge_interval: 15,
},
display: {
compact: false,
tool_progress: "all",
},
};

// Hermes v2026.4.23 reads Discord behavior from top-level `discord:`.
// Bot tokens and user allowlists stay in .env so config.yaml never carries
// real secrets or credential placeholders under platforms.discord.
if (settings.messaging.enabledChannels.has("discord")) {
config.discord = buildDiscordConfig(settings.messaging.discordGuilds);
}

const telegramConfig = settings.messaging.telegramConfig;
if (
settings.messaging.enabledChannels.has("telegram") &&
typeof telegramConfig.requireMention === "boolean"
) {
config.telegram = {
require_mention: telegramConfig.requireMention,
};
}

// API server — internal port only.
// Hermes binds to 127.0.0.1 regardless of config (upstream bug).
// socat in start.sh forwards 0.0.0.0:8642 -> 127.0.0.1:18642.
config.platforms = {
api_server: {
enabled: true,
extra: {
port: 18642,
host: "127.0.0.1",
},
},
};

return config;
}
71 changes: 71 additions & 0 deletions agents/hermes/config/messaging-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { DiscordGuilds, MessagingAllowedIds } from "./build-env.ts";

const CHANNEL_TOKEN_ENVS: Record<string, string[]> = {
telegram: ["TELEGRAM_BOT_TOKEN"],
discord: ["DISCORD_BOT_TOKEN"],
slack: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"],
};

export function buildMessagingEnvLines(
enabledChannels: Set<string>,
allowedIds: MessagingAllowedIds,
discordGuilds: DiscordGuilds,
): string[] {
const envLines = ["API_SERVER_PORT=18642", "API_SERVER_HOST=127.0.0.1"];

for (const channel of enabledChannels) {
const envKeys = CHANNEL_TOKEN_ENVS[channel] ?? [];
for (const envKey of envKeys) {
envLines.push(`${envKey}=openshell:resolve:env:${envKey}`);
}
}

const discordAllowedUsers = collectDiscordAllowedUsers(allowedIds, discordGuilds);
if (discordAllowedUsers.length > 0) {
envLines.push(`DISCORD_ALLOWED_USERS=${discordAllowedUsers.join(",")}`);
}
if (allowedIds.telegram?.length) {
envLines.push(`TELEGRAM_ALLOWED_USERS=${allowedIds.telegram.map(String).join(",")}`);
}

return envLines;
}

export function buildDiscordConfig(discordGuilds: DiscordGuilds): Record<string, unknown> {
return {
require_mention: getDiscordRequireMention(discordGuilds),
free_response_channels: "",
allowed_channels: "",
auto_thread: true,
reactions: true,
channel_prompts: {},
};
}

function getDiscordRequireMention(discordGuilds: DiscordGuilds): boolean {
for (const guildConfig of Object.values(discordGuilds)) {
if (typeof guildConfig?.requireMention === "boolean") {
return guildConfig.requireMention;
}
}
return true;
}

function collectDiscordAllowedUsers(
allowedIds: MessagingAllowedIds,
discordGuilds: DiscordGuilds,
): string[] {
const users = new Set<string>();
for (const user of allowedIds.discord ?? []) {
users.add(String(user));
}
for (const guildConfig of Object.values(discordGuilds)) {
for (const user of guildConfig?.users ?? []) {
users.add(String(user));
}
}
return [...users];
}
Loading
Loading